Skip to content
aipatterns.dev

Design patternStructural Patterns

Decorator

Wrap an object in another object with the same interface that adds behavior before or after delegating, so features stack without subclassing.

Complexity
Medium
Seen in the wild
Medium
Also known as
Wrapper

Intent

Add responsibilities to an object at runtime by wrapping it in another object that implements the same interface. The wrapper does its extra work (retry, cache, log, measure, authorize) and delegates the core call to the object inside. Because the wrapper looks exactly like the original, wrappers nest in any order.

Forces

  • Optional features vs subclass explosion: retry, caching and logging in every combination would need RetryingCachingLoggingRates and seven siblings.
  • Cross-cutting concerns vs focused classes: the HTTP client should fetch rates, not also count latency and back off on timeouts.
  • Configuring per use vs fixed at compile time: tests want no retries, production wants three, a batch job wants caching too.
  • Ordering matters vs hidden ordering: cache-outside-retry behaves differently from retry-outside-cache, and the order must be visible somewhere.

Therefore

Implement the component's interface in a wrapper class that holds another instance of that interface, add your behavior around the delegated call, and compose wrappers at the composition root.

Each decorator does one thing. The client receives the outermost wrapper as a plain RateSource and cannot tell it from the real one. The stacking order is written once, where the object graph is built.

Structure

Diagram source (Mermaid)
classDiagram
  class RateSource {
    <<interface>>
    +rate(pair) Number
  }
  class RemoteRates {
    +rate(pair) Number
  }
  class RateDecorator {
    <<abstract>>
    -RateSource inner
    +rate(pair) Number
  }
  class Retrying {
    -int attempts
    +rate(pair) Number
  }
  class Caching {
    -Map cache
    +rate(pair) Number
  }
  class Logging {
    +rate(pair) Number
  }
  RateSource <|.. RemoteRates
  RateSource <|.. RateDecorator
  RateDecorator o-- RateSource : inner
  RateDecorator <|-- Retrying
  RateDecorator <|-- Caching
  RateDecorator <|-- Logging

Participants

  • Component (RateSource): the interface both the real object and every wrapper implement.
  • Concrete component (RemoteRates): the object doing the core work.
  • Base decorator (RateDecorator): optional; holds inner and forwards by default. Useful when the interface has many methods and each decorator overrides only a few. With one method, skip it, as the example does.
  • Concrete decorators (Retrying, Caching, Logging): one added behavior each.
  • Client: depends on RateSource only. The composition root decides the stack.

Use it when

  • You need to add retry, caching, metrics, tracing, rate limiting or authorization to an object without editing it.
  • Features are optional and combinable, and each combination would otherwise be a subclass.
  • You cannot subclass the component (it is final, sealed, or built by a third-party factory), but you can wrap it.
  • Different environments need different stacks: no cache in tests, extra logging in staging.

Do not use it when

  • The interface has one method and function composition fits. withRetry(withCache(fetchRate)) is the whole decorator stack, with no wrapper classes.
  • You are in a web framework or RPC stack that already has middleware or interceptors (Express middleware, Go http.Handler wrappers, gRPC interceptors, Spring AOP). Use that mechanism instead of hand-rolled wrapper classes.
  • The extra behavior is always on. Put it in the class itself; a wrapper that every instance always gets is just indirection.
  • The interface is wide (twenty methods) and you only want to change one. Every decorator must forward the other nineteen; prefer a function hook, an event, or a narrower interface.
  • Callers need to reach the concrete type behind the wrapper (instanceof RemoteRates). Decorators break identity checks; fix the caller first.
  • The wrapper changes the interface. That is Adapter.

Note that Python's @decorator syntax is a related but different tool: it wraps a function at definition time. For one function, it is often the right choice over this pattern.

Application grill

Inspect first: the cross-cutting behavior already present as middleware, wrappers or higher-order functions (fetch wrappers, tracing clients) - the pattern may exist unlabelled; where instances are constructed, since stacking needs a wiring point; the interface's stability.

Missing pieces: usually none for in-process stacking. If decorators would wrap an HTTP client for retries, caching or auth, check what the platform already provides (interceptors, SDK middleware stacks, OpenTelemetry instrumentation) before writing wrappers.

Ask before you build:

  • Scale: how many behaviors stack on one object - three deep is readable, ten deep means the composition is the problem?
  • Performance: what does each layer allocate per call on hot paths?
  • Reliability: which layer owns errors, and is the ordering load-bearing (does auth wrap retry, or retry wrap auth)?
  • Budget: who owns the wiring order - it is logic, not configuration, and reordering changes behavior?
  • Implementation: do the same tests pass for base and decorated instances (the contract of an exact interface)?

Implementation steps

  1. Make sure callers depend on an interface, not on the concrete class. Extract one if needed (Extract Interface).
  2. Write one decorator per concern. Each takes the inner component in its constructor and implements the full interface.
  3. In each method, do the extra work and delegate to inner. Forward unchanged methods as-is.
  4. Remove the cross-cutting code from the concrete component once a decorator covers it.
  5. Build the stack in one place (composition root, DI module, factory function) and document the order there.
  6. Test each decorator alone against a fake inner component.

Example

A pricing service reads currency rates from a flaky upstream API. Production wraps it with retry, a cache and logging; tests use the bare fake.

interface RateSource {
  rate(pair: string): number;
}

// Concrete component: an upstream API that times out on every other call.
class RemoteRates implements RateSource {
  private calls = 0;
  private readonly table: Record<string, number> = { "EUR/USD": 1.09, "GBP/USD": 1.27 };
  rate(pair: string): number {
    if (++this.calls % 2 === 1) throw new Error(`timeout fetching ${pair}`);
    console.log(`  [remote] served ${pair}`);
    return this.table[pair];
  }
}

class Retrying implements RateSource {
  constructor(private readonly inner: RateSource, private readonly attempts: number) {}
  rate(pair: string): number {
    for (let i = 1; ; i++) {
      try {
        return this.inner.rate(pair);
      } catch (e) {
        if (i >= this.attempts) throw e;
        console.log(`  [retry] attempt ${i} failed: ${(e as Error).message}`);
      }
    }
  }
}

class Caching implements RateSource {
  private readonly cache = new Map<string, number>();
  constructor(private readonly inner: RateSource) {}
  rate(pair: string): number {
    let r = this.cache.get(pair);
    if (r === undefined) {
      r = this.inner.rate(pair);
      this.cache.set(pair, r);
    }
    return r;
  }
}

class Logging implements RateSource {
  constructor(private readonly inner: RateSource) {}
  rate(pair: string): number {
    const r = this.inner.rate(pair);
    console.log(`rate(${pair}) = ${r}`);
    return r;
  }
}

// Order is explicit: cache hits skip retries; retries happen below the cache.
const rates: RateSource = new Logging(new Caching(new Retrying(new RemoteRates(), 3)));

const toUsd = (source: RateSource, amount: number, currency: string) =>
  amount * source.rate(`${currency}/USD`);

console.log(toUsd(rates, 100, "EUR").toFixed(2)); // timeout, retry, remote, 109.00
console.log(toUsd(rates, 250, "EUR").toFixed(2)); // cache hit, 272.50
console.log(toUsd(rates, 40, "GBP").toFixed(2));  // timeout, retry, remote, 50.80

The function-composition version of the same stack in TypeScript is const rate = logged(cached(retried(fetchRate, 3))), where each helper takes and returns (pair: string) => number. Prefer it when the interface is a single function.

Trade-offs

Gains

  • Each concern is a small class with a single job, tested alone.
  • Features combine freely at runtime without a subclass per combination.
  • The core component stays free of retry, cache and logging code.
  • You can decorate classes you cannot modify or subclass.

Costs

  • Behavior depends on stacking order, and the wrong order (retry outside cache, logging inside retry) produces subtle bugs.
  • Stack traces and debuggers show layers of wrappers.
  • Identity and type checks fail: the outer object is not a RemoteRates.
  • Wide interfaces force each decorator to forward every method, which invites copy-paste.

Relations

  • Adapter changes an object's interface. Decorator keeps it and adds behavior.
  • Proxy has the same shape, but controls access to the subject (lazy creation, permission, remote calls) and usually manages its lifecycle. Decorators are stacked by the client; a proxy usually is not.
  • Composite also recurses through a shared interface, but aggregates many children. A decorator has exactly one.
  • Chain of Responsibility links handlers in the same way, but any handler may stop the request. Decorators normally always delegate.
  • Strategy swaps an object's internals. Decorator changes its outside.

Agent checklist

  • Callers depend on an interface, so a wrapper can be substituted without changing them.
  • You considered function composition or the framework's middleware before writing wrapper classes.
  • Each decorator does one thing and forwards every method it does not change.
  • The stacking order is defined in one place and its reasoning is in a comment.
  • No caller checks the concrete type of the component after the change.
  • Each decorator has a test against a fake inner component, including the error path.

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.