Decorator — Optimize¶
Each section presents a Decorator that works but is wasteful. Profile, optimize, measure.
Table of Contents¶
- Optimization 1: Consolidate adjacent decorators
- Optimization 2: Pre-format log messages once
- Optimization 3: Avoid per-call map allocation
- Optimization 4: Pointer receivers for stack of decorators (Go)
- Optimization 5: Lazy decorator construction
- Optimization 6: Drop unused decorators
- Optimization 7: Reorder for short-circuit
- Optimization 8: Async-friendly chain
- Optimization 9: Replace dynamic proxy with explicit decorator
- Optimization 10: Code-gen vs runtime composition
- Optimization Tips
Optimization 1: Consolidate adjacent decorators¶
Before¶
service = new LoggingService(service, log);
service = new MetricsService(service, metrics);
service = new TracingService(service, tracer);
Three decorators each do a tiny thing: log, count, span. Three indirections per call.
After¶
One decorator with all three responsibilities. Trade: less granular control over enabling individual concerns. Gain: one indirection, less per-call work.
Measurement. A 5-decorator stack with three "instrumentation" decorators consolidated into one drops per-call latency by 30-40 ns at warm JVM steady state. For 100k QPS service: meaningful.
Lesson: When several decorators are always together, consolidate. Composition is good; over-decomposition has cost.
Optimization 2: Pre-format log messages once¶
Before¶
public Result call(Request req) {
log.info(String.format("calling %s with %s at %s", inner.getClass().getName(), req, Instant.now()));
return inner.call(req);
}
String.format allocates per call. Instant.now() is a syscall.
After¶
private static final String CALL_MSG = "calling";
public Result call(Request req) {
log.atInfo()
.addKeyValue("inner", inner.getClass().getName())
.addKeyValue("request", req)
.log(CALL_MSG);
return inner.call(req);
}
Structured logging APIs (SLF4J 2.x, Log4j 2 fluent) avoid per-call string allocations.
Measurement. GC pressure drops; per-call latency improves a few hundred ns.
Lesson: Logging is a hot path; format lazily or use structured APIs. String.format everywhere is a tax.
Optimization 3: Avoid per-call map allocation¶
Before¶
class LoggingDecorator:
def call(self, req):
ctx = {"req": str(req), "user": req.user_id, "t": time.time()}
log.info("call", **ctx)
return self._inner.call(req)
A new dict every call. Python GC handles it, but allocations + reference counting add up at high QPS.
After¶
class LoggingDecorator:
def call(self, req):
# Pass kwargs directly; logger handles formatting lazily.
log.info("call", req=str(req), user=req.user_id, t=time.time())
return self._inner.call(req)
Or use a logger that takes a callable for lazy formatting:
Skip the dict if the log level is below INFO.
Measurement. ~20% improvement in tight Python loops. More with logging.disable(logging.INFO) in dev.
Lesson: Don't build context maps unconditionally. Use lazy logging APIs and check log levels.
Optimization 4: Pointer receivers for stack of decorators (Go)¶
Before¶
type RetryDecorator struct{ inner Service; max int }
func (r RetryDecorator) Call(req Request) Result { ... } // value receiver
processor := RetryDecorator{inner: real, max: 3}
var s Service = processor // copies; allocates
Each interface conversion copies the struct. Many decorations = many copies.
After¶
func (r *RetryDecorator) Call(req Request) Result { ... } // pointer receiver
processor := &RetryDecorator{inner: real, max: 3}
var s Service = processor // single pointer; no copy
Measurement. Allocations drop; CPU profile cleaner.
Lesson: In Go, decorators going through interfaces should use pointer receivers. Same advice as Adapter and Bridge.
Optimization 5: Lazy decorator construction¶
Before¶
public class ServiceFactory {
public static Service build(Config cfg) {
Service s = new RealService(cfg);
s = new LoggingService(s, new ExpensiveLogger(cfg)); // eager
s = new MetricsService(s, new ExpensiveMetrics(cfg)); // eager
return s;
}
}
Every service constructed at app startup pays for full instrumentation, even features not used.
After¶
public static Service build(Config cfg) {
Service s = new RealService(cfg);
if (cfg.loggingEnabled()) s = new LoggingService(s, ...);
if (cfg.metricsEnabled()) s = new MetricsService(s, ...);
return s;
}
Or use lazy initialization:
public static Service build(Config cfg) {
return new LazyDecorator(() -> {
Service s = new RealService(cfg);
// ... heavy decoration
return s;
});
}
Measurement. App boot time drops; memory footprint shrinks.
Lesson: Construct only the decorators you need. Boot time and memory are real costs.
Optimization 6: Drop unused decorators¶
Before¶
A 12-layer stack accumulated over 3 years. git blame shows nobody owns half of them.
After¶
Audit: profile, count log lines per decorator, check feature flag usage. Decorators with no recent observability hits get dropped.
Measurement. Per-call latency drops. Stack traces shrink. Mental load on new engineers drops.
Lesson: Decorators are easy to add and easy to forget. Periodic audits keep stacks sane.
Optimization 7: Reorder for short-circuit¶
Before¶
service = new MetricsService(
new ValidationService(
new ExpensiveAuthService(
new RealService(),
authProvider),
validator),
metrics);
Auth runs before validation. Even invalid requests hit the auth provider — expensive.
After¶
service = new MetricsService(
new ExpensiveAuthService(
new ValidationService(
new RealService(),
validator),
authProvider),
metrics);
Validation rejects bad requests before auth runs.
Measurement. Auth provider load drops noticeably for buggy clients sending malformed requests. Latency on valid requests unchanged.
Lesson: Order decorators so cheap rejects happen before expensive operations. Short-circuit early.
Optimization 8: Async-friendly chain¶
Before (Python)¶
class CachingDecorator:
def __init__(self, inner, cache): self._inner = inner; self._cache = cache
def call(self, req):
if req.id in self._cache: return self._cache[req.id]
result = self._inner.call(req)
self._cache[req.id] = result
return result
Wrapped service is async; this decorator is sync. Calling self._inner.call(req) returns a coroutine; storing the coroutine in cache; future "hits" return coroutines, not results.
After¶
class AsyncCachingDecorator:
def __init__(self, inner, cache): self._inner = inner; self._cache = cache
async def call(self, req):
if req.id in self._cache: return self._cache[req.id]
result = await self._inner.call(req)
self._cache[req.id] = result
return result
Measurement. Cache works correctly; throughput restored.
Lesson: Match the concurrency model. Async inner → async decorator. Don't mix.
Optimization 9: Replace dynamic proxy with explicit decorator¶
Before (Spring AOP)¶
@Service
public class PaymentService {
@Retryable(maxAttempts = 3)
@Cacheable("payments")
public Receipt pay(PaymentRequest req) { ... }
}
Spring weaves a dynamic proxy. ~30-50 ns per intercepted call (reflection + plumbing). For 1M QPS hot paths, real cost.
After¶
public final class PaymentService {
public Receipt pay(PaymentRequest req) { ... } // pure
}
PaymentService svc = new RetryingPaymentService(
new CachingPaymentService(new PaymentService(), cache),
3
);
Explicit decorators; no dynamic proxy. JVM can inline the entire chain.
Measurement. Hot-path latency drops 30-100 ns/call. For most apps invisible; for inner-loop services, meaningful.
Lesson: AOP is great for ergonomics; explicit Decorator wins for performance-critical code.
Optimization 10: Code-gen vs runtime composition¶
Before¶
For 50 service interfaces, each with @Retryable and @Cacheable, Spring generates 100 dynamic proxies at runtime. Boot time and memory cost.
After¶
Use an annotation processor (Java) or code generator (Go: go generate, Rust: macros) that produces decorator classes at compile time. No runtime proxy generation; faster boot; predictable performance.
// Generated:
public class RetryablePaymentServiceDecorator implements PaymentService {
private final PaymentService inner;
public RetryablePaymentServiceDecorator(PaymentService inner) { this.inner = inner; }
@Override
public Receipt pay(PaymentRequest req) {
for (int i = 0; i < 3; i++) {
try { return inner.pay(req); }
catch (TransientException e) { /* ... */ }
}
throw new RetriesExhaustedException();
}
}
Measurement. Boot time drops (no runtime weaving). Hot-path performance improves slightly. CI build time increases (extra annotation processing).
Lesson: Code-gen trades build complexity for runtime performance. Worth it for services where boot time or hot-path latency matters.
Optimization Tips¶
- Profile first. Most decorator overhead is invisible behind I/O. Don't optimize what you can't measure.
- Consolidate decorators that always travel together.
Logging+Metrics+Tracing→Observability. - Use structured logging APIs that avoid per-call string allocation.
- Lazy-construct heavy decorators. Boot time matters.
- Reorder for short-circuit. Cheap rejects before expensive operations.
- Match concurrency models. Async outer → async inner; don't mix.
- Drop unused decorators. Audits pay off; stack traces shrink.
- Prefer pointer receivers in Go. Avoid per-conversion allocations.
- Replace dynamic proxies with explicit decorators in performance-critical paths.
- Code-gen decorators when boot time or runtime perf matters more than build complexity.
- Cap stack depth with a code-review rule. Discipline beats cleanup.
- Optimize for change too. A clean 4-layer stack beats a tweaked 12-layer one.
← Back to Decorator folder · ↑ Structural Patterns · ↑↑ Roadmap Home
You've completed the Decorator pattern suite. Continue to: Facade · Flyweight · Proxy