Skip to content
Commitwise
Menu
Design Patterns

Decorator

Add caching, logging, retries and metrics to an object without changing it or subclassing it, by wrapping it in objects with the same interface, with examples in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The product repository works. Now it needs caching. Then logging, for a bug hunt. Then retries, because the API is flaky. Subclassing gives you CachedProductRepository, then LoggingCachedProductRepository, then RetryingLoggingCachedProductRepository, and a new class for every combination someone wants.

The DecoratorDecorator patternWrapping an object in another object with the same interface to add behavior, without changing the original.Open in glossary → pattern adds each behavior as a separate wrapper with the same interface, so you combine them like building blocks, in any order, without touching the original.

New to some of these? Open what you need, or skip ahead.

Basics:What is a design pattern?

A design pattern is a named, reusable solution to a problem that keeps coming up in software design, like “swap one algorithm for another” or “notify many objects of a change”. It’s not a library you install; it’s a way of arranging classes and interfaces that experienced developers recognize.

Knowing the names helps in two ways: you recognize the pattern in code you read (Android, iOS and Spring use many of them), and you can discuss designs quickly in reviews and interviews (“let’s use a Strategy here”).

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java, TypeScript) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What is inheritance?

A class can extend (inherit from) another class: class AdminUser : User (Kotlin), class AdminUser extends User (Java, TypeScript), class AdminUser: User (Swift). The subclass gets everything the parent has and can add more, or override a method to change what it does.

Inheritance is useful but couples the two classes tightly: a change in the parent can break every subclass. Many designs prefer composition (an object holding another object) instead, which several tutorials here show.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Basics:What is recursion?

A recursive function calls itself on a smaller version of the problem, until it reaches a case small enough to answer directly (the base case):

countdown(3) → prints 3, calls countdown(2) → prints 2, calls countdown(1) → prints 1, calls countdown(0) → stops

Two rules keep it correct: always have a base case, and make sure every call gets closer to it. Each call waits on the call stack until the smaller call finishes, so very deep recursion (hundreds of thousands of levels) can run out of stack space.

Part Not started

From subclasses to wrappers

Think of a phone. A case adds protection, a pop-socket adds grip, a wallet sleeve adds card storage. Each one wraps the phone, and you still use it exactly like a phone: same screen, same buttons. You can combine them freely, take one off, and the phone itself never changed.

A decorator works the same way:

  1. it implements the same interface as the object it wraps, so callers can’t tell the difference,
  2. it holds the wrapped object (often called inner or delegate),
  3. each method does its extra work (log, cache, retry) and delegates the real work to the wrapped object.

Decorator attaches responsibilities dynamically by wrapping an object behind its own interface, composing behavior instead of inheriting it. It’s the backbone of cross-cutting concerns: java.io streams, OkHttp and URLSession-style middleware, Express and Koa middleware, Spring’s proxies for @Transactional and @Cacheable, SwiftUI modifiers, Kotlin’s class delegation (by).

The senior questions are about composition semantics: wrapper order changes behavior (retry around timeout vs timeout around retry), decorators apply to every method of an interface (including non-idempotent ones), delegation vs inheritance and self-use (the fragile base class problem), and identity and type checks seeing the wrapper instead of the original. The Detailed version covers each.

Before: A subclass for every combination

class APIProductSource {
    func product(id: Int) throws -> Product { try api.fetchProduct(id) }
}

class CachedAPIProductSource: APIProductSource { /* overrides product(id:) with a cache */ }
class LoggingCachedAPIProductSource: CachedAPIProductSource { /* adds logging */ }
class LoggingAPIProductSource: APIProductSource { /* logging without the cache: copy-paste */ }
open class ApiProductSource {
    open fun product(id: Int): Product = api.fetchProduct(id)
}

open class CachedApiProductSource : ApiProductSource() { /* overrides product with a cache */ }
class LoggingCachedApiProductSource : CachedApiProductSource() { /* adds logging */ }
class LoggingApiProductSource : ApiProductSource() { /* logging without the cache: copy-paste */ }
class ApiProductSource {
    Product product(int id) { return api.fetchProduct(id); }
}

class CachedApiProductSource extends ApiProductSource { /* overrides product with a cache */ }
class LoggingCachedApiProductSource extends CachedApiProductSource { /* adds logging */ }
class LoggingApiProductSource extends ApiProductSource { /* logging without the cache: copy-paste */ }
class ApiProductSource {
  product(id: number): Promise<Product> { return api.fetchProduct(id); }
}

class CachedApiProductSource extends ApiProductSource { /* overrides product with a cache */ }
class LoggingCachedApiProductSource extends CachedApiProductSource { /* adds logging */ }
class LoggingApiProductSource extends ApiProductSource { /* logging without the cache: copy-paste */ }

After: One interface, one wrapper per behavior

protocol ProductSource {
    func product(id: Int) throws -> Product
}

struct APIProductSource: ProductSource {
    func product(id: Int) throws -> Product { try api.fetchProduct(id) }
}

struct LoggingProductSource: ProductSource {
    let inner: any ProductSource
    func product(id: Int) throws -> Product {
        print("Loading product \(id)")
        return try inner.product(id: id)            // delegate the real work
    }
}

final class CachingProductSource: ProductSource {
    private let inner: any ProductSource
    private var cache: [Int: Product] = [:]
    init(_ inner: any ProductSource) { self.inner = inner }

    func product(id: Int) throws -> Product {
        if let cached = cache[id] { return cached }
        let product = try inner.product(id: id)     // only on a miss
        cache[id] = product
        return product
    }
}

// Combine as needed, in any order:
let source: any ProductSource = LoggingProductSource(inner: CachingProductSource(APIProductSource()))
fun interface ProductSource {
    fun product(id: Int): Product
}

class ApiProductSource(private val api: ShopApi) : ProductSource {
    override fun product(id: Int) = api.fetchProduct(id)
}

class LoggingProductSource(private val inner: ProductSource) : ProductSource {
    override fun product(id: Int): Product {
        println("Loading product $id")
        return inner.product(id)                    // delegate the real work
    }
}

class CachingProductSource(private val inner: ProductSource) : ProductSource {
    private val cache = HashMap<Int, Product>()
    override fun product(id: Int): Product =
        cache.getOrPut(id) { inner.product(id) }   // only on a miss
}

// Combine as needed, in any order:
val source: ProductSource = LoggingProductSource(CachingProductSource(ApiProductSource(api)))
interface ProductSource {
    Product product(int id);
}

record ApiProductSource(ShopApi api) implements ProductSource {
    public Product product(int id) { return api.fetchProduct(id); }
}

record LoggingProductSource(ProductSource inner) implements ProductSource {
    public Product product(int id) {
        System.out.println("Loading product " + id);
        return inner.product(id);                   // delegate the real work
    }
}

final class CachingProductSource implements ProductSource {
    private final ProductSource inner;
    private final Map<Integer, Product> cache = new HashMap<>();
    CachingProductSource(ProductSource inner) { this.inner = inner; }

    public Product product(int id) {
        return cache.computeIfAbsent(id, inner::product);   // only on a miss
    }
}

// Combine as needed, in any order:
ProductSource source = new LoggingProductSource(new CachingProductSource(new ApiProductSource(api)));
interface ProductSource {
  product(id: number): Promise<Product>;
}

class ApiProductSource implements ProductSource {
  constructor(private readonly api: ShopApi) {}
  product(id: number) { return this.api.fetchProduct(id); }
}

class LoggingProductSource implements ProductSource {
  constructor(private readonly inner: ProductSource) {}
  product(id: number) {
    console.log("Loading product " + id);
    return this.inner.product(id);                  // delegate the real work
  }
}

class CachingProductSource implements ProductSource {
  private readonly cache = new Map<number, Product>();
  constructor(private readonly inner: ProductSource) {}

  async product(id: number): Promise<Product> {
    const cached = this.cache.get(id);
    if (cached) return cached;
    const product = await this.inner.product(id);   // only on a miss
    this.cache.set(id, product);
    return product;
  }
}

// Combine as needed, in any order:
const source: ProductSource = new LoggingProductSource(new CachingProductSource(new ApiProductSource(api)));

What changed:

  • Each behavior lives in one small class, and none of them knows about the others or about the API. Three behaviors are three classes, not seven combinations.
  • The caller only sees ProductSource. A screen doesn’t know whether it got the plain API, a cached one, or a logged and cached one, which also makes tests easy: pass a fake ProductSource.
  • Combining is just wrapping: Logging(Caching(Api)). Adding retries later means writing one RetryingProductSource and adding it to that line.

The wrapper that forgot to delegate

Spot the bugA cache that crashes on the first miss

Tap the line with the bug.

final class CachingProductSource: ProductSource {
    private let inner: any ProductSource
    private var cache: [Int: Product] = [:]
    init(_ inner: any ProductSource) { self.inner = inner }

    func product(id: Int) throws -> Product {
        if let cached = cache[id] { return cached }
        let product = try product(id: id)
        cache[id] = product
        return product
    }
}
class CachingProductSource(private val inner: ProductSource) : ProductSource {
    private val cache = HashMap<Int, Product>()

    override fun product(id: Int): Product {
        cache[id]?.let { return it }
        val product = product(id)
        cache[id] = product
        return product
    }
}
class CachingProductSource implements ProductSource {
    private final ProductSource inner;
    private final Map<Integer, Product> cache = new HashMap<>();

    CachingProductSource(ProductSource inner) { this.inner = inner; }

    public Product product(int id) {
        Product cached = cache.get(id);
        if (cached != null) return cached;
        Product product = product(id);
        cache.put(id, product);
        return product;
    }
}
class CachingProductSource implements ProductSource {
  private readonly cache = new Map<number, Product>();
  constructor(private readonly inner: ProductSource) {}

  async product(id: number): Promise<Product> {
    const cached = this.cache.get(id);
    if (cached) return cached;
    const product = await this.product(id);
    this.cache.set(id, product);
    return product;
  }
}

Decorator, proxy, chain, aspect

  • Decorator adds behavior and is composed by the client; several stack freely.
  • Proxy controls access to the same interface (lazy loading, remote calls, security checks), usually one, created by infrastructure. Hibernate’s lazy entities and Spring’s @Transactional beans are proxies that behave like decorators.
  • Chain of responsibility (OkHttp interceptors, servlet filters, Ktor plugins, Express and Koa middleware) is a decorator stack with an explicit chain.proceed(), where any link may short-circuit.
  • AOP generates decorators from annotations (TypeScript’s @decorator syntax is a language-level cousin that can replace a method with a wrapper), which is convenient and hides the order and the self-invocation limit (a call through this skips the proxy).
SwiftKotlinJavaTypeScript
Interfaceprotocolinterface / fun interfaceinterfaceinterface (structural: any object with the right methods fits)
Forwarding boilerplatewrite each method: Iface by innerforwarding base class, or each methodeach method, or a function returning a wrapper object
NeighborsSwiftUI modifiers, URLProtocolOkHttp interceptors (chain)Spring proxies, servlet filtersExpress/Koa middleware (chain), @decorator syntax (aspect)
  • Decorator is composed by the client and stacks freely; proxy controls access and is usually created by infrastructure; a chain is a decorator stack with an explicit next/proceed(); AOP generates decorators from annotations.
  • The classic bug: calling product(id) on yourself instead of inner, which recurses until the stack overflows.

Check this part

  1. What does a decorator do?

  2. On a cache miss, the decorator calls try product(id: id) instead of try inner.product(id: id). What happens?On a cache miss, the decorator calls product(id) instead of inner.product(id). What happens?On a cache miss, the decorator calls this.product(id) instead of this.inner.product(id). What happens?

  3. You wrap a CachingProductSource in a LoggingProductSource. What does source is CachingProductSource return?You wrap a CachingProductSource in a LoggingProductSource. What does source instanceof CachingProductSource return?

  4. What distinguishes a proxy from a decorator?

A decorator implements the same interface as the object it wraps, adds one behavior and delegates the real work to inner. One wrapper per behavior replaces a subclass per combination.

Part Not started

Order matters

Decorators are applied from the outside in: the outermost wrapper sees every call first.

Predict the output

The source is a cache wrapped around a logger wrapped around the API. product(1) is called twice. How many times is "Loading product 1" printed?

let source = CachingProductSource(LoggingProductSource(inner: APIProductSource()))
_ = try source.product(id: 1)
_ = try source.product(id: 1)
val source = CachingProductSource(LoggingProductSource(ApiProductSource(api)))
source.product(1)
source.product(1)
ProductSource source = new CachingProductSource(new LoggingProductSource(new ApiProductSource(api)));
source.product(1);
source.product(1);
const source = new CachingProductSource(new LoggingProductSource(new ApiProductSource(api)));
await source.product(1);
await source.product(1);

Practice: a retry decorator

A flaky API fails now and then with a network error. Let’s add retries without touching any existing class, step by step:

  1. Implement the same interface as the thing you wrap.
  2. Call the inner object inside a loop.
  3. Retry only errors that are worth retrying (network failures), with a small growing delay between tries.
  4. Give up after a few attempts and rethrow the last error.
struct RetryingProductSource: ProductSource {
    let inner: any ProductSource
    var attempts = 3

    func product(id: Int) throws -> Product {
        var lastError: Error?
        for attempt in 1...attempts {
            do {
                return try inner.product(id: id)
            } catch let error as URLError {          // network problems only
                lastError = error
                Thread.sleep(forTimeInterval: 0.2 * Double(attempt))   // 0.2 s, 0.4 s, …
            }
        }
        throw lastError!
    }
}
class RetryingProductSource(
    private val inner: ProductSource,
    private val attempts: Int = 3,
) : ProductSource {
    override fun product(id: Int): Product {
        var lastError: IOException? = null
        repeat(attempts) { attempt ->
            try {
                return inner.product(id)
            } catch (e: IOException) {               // network problems only
                lastError = e
                Thread.sleep(200L * (attempt + 1))   // 200 ms, 400 ms, …
            }
        }
        throw lastError!!
    }
}
record RetryingProductSource(ProductSource inner, int attempts) implements ProductSource {
    public Product product(int id) {
        UncheckedIOException lastError = null;
        for (int attempt = 1; attempt <= attempts; attempt++) {
            try {
                return inner.product(id);
            } catch (UncheckedIOException e) {        // network problems only
                lastError = e;
                sleep(200L * attempt);                // 200 ms, 400 ms, …
            }
        }
        throw lastError;
    }
}
class RetryingProductSource implements ProductSource {
  constructor(private readonly inner: ProductSource, private readonly attempts = 3) {}

  async product(id: number): Promise<Product> {
    let lastError: unknown;
    for (let attempt = 1; attempt <= this.attempts; attempt++) {
      try {
        return await this.inner.product(id);         // await inside try, or the catch never sees the rejection
      } catch (error) {
        if (!(error instanceof NetworkError)) throw error;   // network problems only
        lastError = error;
        await sleep(200 * attempt);                   // 200 ms, 400 ms, …
      }
    }
    throw lastError;
  }
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

(In real async code you’d use Task.sleep, delay or a scheduler instead of blocking the thread; the decorator’s shape stays the same. The TypeScript version is async already, so it awaits a timer.)

A retry decorator is powerful, which is exactly why it needs care:

Spot the bugCustomers charged twice

Tap the line with the bug.

protocol PaymentGateway {
    func charge(_ amount: Money, card: CardToken) throws -> PaymentID
}

let gateway: any PaymentGateway = RetryingPaymentGateway(inner: StripeGateway(), attempts: 3)
// RetryingPaymentGateway retries any URLError, like RetryingProductSource
interface PaymentGateway {
    fun charge(amount: Money, card: CardToken): PaymentId
}

val gateway: PaymentGateway = RetryingPaymentGateway(StripeGateway(), attempts = 3)
// RetryingPaymentGateway retries any IOException, like RetryingProductSource
interface PaymentGateway {
    PaymentId charge(Money amount, CardToken card);
}

PaymentGateway gateway = new RetryingPaymentGateway(new StripeGateway(), 3);
// RetryingPaymentGateway retries any UncheckedIOException, like RetryingProductSource
interface PaymentGateway {
  charge(amount: Money, card: CardToken): Promise<PaymentId>;
}

const gateway: PaymentGateway = new RetryingPaymentGateway(new StripeGateway(), 3);
// RetryingPaymentGateway retries any NetworkError, like RetryingProductSource

Exerciseeasy

A timing decorator

Write a TimedProductSource that measures how long each product(id) call takes and reports it to a metrics object with metrics.record(name: String, millis: Long), under the name "product_source.product". It must report the time even when the call throws, and must not change the result or the error.

Then show how you’d combine it with caching so that it measures: (a) every call, including cache hits; (b) only real API calls.

›

Take the time before and after delegating. Where can you put “after” so it runs whether the inner call returns or throws?

›

defer (Swift) and finally (Kotlin, Java, TypeScript) run on every exit path. For (a) and (b), think about which wrapper is outside.

SolutionTry it yourself first
struct TimedProductSource: ProductSource {
    let inner: any ProductSource
    let metrics: Metrics

    func product(id: Int) throws -> Product {
        let start = ContinuousClock.now
        defer {                                            // runs on return and on throw
            let elapsed = start.duration(to: .now)
            metrics.record(name: "product_source.product", millis: Int64(elapsed / .milliseconds(1)))
        }
        return try inner.product(id: id)
    }
}

let everyCall = TimedProductSource(inner: CachingProductSource(APIProductSource()), metrics: metrics)   // (a)
let apiOnly = CachingProductSource(TimedProductSource(inner: APIProductSource(), metrics: metrics))     // (b)
class TimedProductSource(
    private val inner: ProductSource,
    private val metrics: Metrics,
) : ProductSource {
    override fun product(id: Int): Product {
        val start = System.nanoTime()
        try {
            return inner.product(id)
        } finally {                                        // runs on return and on throw
            metrics.record("product_source.product", (System.nanoTime() - start) / 1_000_000)
        }
    }
}

val everyCall = TimedProductSource(CachingProductSource(ApiProductSource(api)), metrics)   // (a)
val apiOnly = CachingProductSource(TimedProductSource(ApiProductSource(api), metrics))     // (b)
record TimedProductSource(ProductSource inner, Metrics metrics) implements ProductSource {
    public Product product(int id) {
        long start = System.nanoTime();
        try {
            return inner.product(id);
        } finally {                                        // runs on return and on throw
            metrics.record("product_source.product", (System.nanoTime() - start) / 1_000_000);
        }
    }
}

ProductSource everyCall = new TimedProductSource(new CachingProductSource(new ApiProductSource(api)), metrics);  // (a)
ProductSource apiOnly = new CachingProductSource(new TimedProductSource(new ApiProductSource(api), metrics));    // (b)
class TimedProductSource implements ProductSource {
  constructor(private readonly inner: ProductSource, private readonly metrics: Metrics) {}

  async product(id: number): Promise<Product> {
    const start = performance.now();
    try {
      return await this.inner.product(id);           // without await, finally would run before the call ends
    } finally {                                       // runs on return and on throw
      this.metrics.record("product_source.product", Math.round(performance.now() - start));
    }
  }
}

const everyCall = new TimedProductSource(new CachingProductSource(new ApiProductSource(api)), metrics);  // (a)
const apiOnly = new CachingProductSource(new TimedProductSource(new ApiProductSource(api), metrics));    // (b)

The decorator is invisible to callers: same result, same errors, plus a measurement. finally/defer guarantees the failed calls are measured too, which matters: slow failures (timeouts) are often the most interesting latency. And the two compositions answer different questions: (a) is what users experience, (b) is how the API behaves. In practice you’d tag the metric with the outcome (success/error) as well.

Retry outside, timeout inside

Predict the output

The call hangs forever. The stack is Retry(3 attempts, Timeout(2 s, call)), with no delay between attempts. When does the caller get an error?

let source = RetryingProductSource(inner: TimeoutProductSource(inner: hangingSource, limit: .seconds(2)), attempts: 3)
val source = RetryingProductSource(TimeoutProductSource(hangingSource, limit = 2.seconds), attempts = 3)
ProductSource source = new RetryingProductSource(new TimeoutProductSource(hangingSource, Duration.ofSeconds(2)), 3);
const source = new RetryingProductSource(new TimeoutProductSource(hangingSource, 2_000), 3);
Exercisemedium

A resilient HTTP client

Every network call in an app goes through one interface:

interface HttpClient {
    suspend fun send(request: Request): Response
}
interface HttpClient {
  send(request: Request): Promise<Response>;
}

Add, with decorators: an auth header, per-attempt timeouts (5 s), retries (up to 3, only for idempotent requests and retryable failures, with backoff), a circuit breaker (stop calling a host that keeps failing), and metrics. Decide the order, and justify it.

›

Ask what each layer should see: should metrics count each attempt or each logical call? Should the circuit breaker count retried attempts? Should the auth header be added once or per attempt (tokens can be refreshed between attempts)?

›

Write the stack from the outside in, and walk one failing request through it.

SolutionTry it yourself first
class AuthClient(private val inner: HttpClient, private val tokens: TokenProvider) : HttpClient {
    override suspend fun send(request: Request) =
        inner.send(request.withHeader("Authorization", "Bearer ${tokens.current()}"))
}

class TimeoutClient(private val inner: HttpClient, private val limit: Duration) : HttpClient {
    override suspend fun send(request: Request) = withTimeout(limit) { inner.send(request) }
}

class RetryClient(private val inner: HttpClient, private val attempts: Int = 3) : HttpClient {
    override suspend fun send(request: Request): Response {
        var attempt = 0
        while (true) {
            try {
                val response = inner.send(request)
                if (response.status !in setOf(429, 502, 503, 504) || !request.isIdempotent || ++attempt >= attempts) {
                    return response
                }
            } catch (e: CancellationException) {
                if (e is TimeoutCancellationException && request.isIdempotent && ++attempt < attempts) {
                    // per-attempt timeout: retry
                } else throw e
            } catch (e: IOException) {
                if (!request.isIdempotent || ++attempt >= attempts) throw e
            }
            delay(backoffWithJitter(attempt))
        }
    }
}

class CircuitBreakerClient(private val inner: HttpClient, private val breaker: CircuitBreaker) : HttpClient {
    override suspend fun send(request: Request): Response {
        if (!breaker.allow(request.host)) throw CircuitOpenException(request.host)   // fail fast
        return try {
            inner.send(request).also { breaker.record(request.host, success = it.status < 500) }
        } catch (e: IOException) {
            breaker.record(request.host, success = false); throw e
        }
    }
}

class MetricsClient(private val inner: HttpClient, private val metrics: Metrics) : HttpClient {
    override suspend fun send(request: Request): Response {
        val start = TimeSource.Monotonic.markNow()
        var outcome = "error"
        try {
            return inner.send(request).also { outcome = it.status.toString() }
        } finally {
            metrics.timer("http.client", "host" to request.host, "outcome" to outcome).record(start.elapsedNow())
        }
    }
}

// Composition root, outside in:
val client: HttpClient =
    MetricsClient(                    // one measurement per logical call: what callers experience
        CircuitBreakerClient(         // one decision per logical call; open circuit = no retries at all
            RetryClient(              // retries the layers below, never the breaker or metrics
                AuthClient(           // header per attempt: a refreshed token is picked up on retry
                    TimeoutClient(    // each attempt gets its own 5 s
                        OkHttpEngine(okHttp), 5.seconds,
                    ), tokens,
                ),
            ),
            breaker,
        ),
        metrics,
    )
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
const isIdempotent = (r: Request) => ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"].includes(r.method);
const isRetryable = (e: unknown) =>
  e instanceof TypeError ||                                    // fetch: network failure
  (e instanceof DOMException && e.name === "TimeoutError");    // our per-attempt timeout

class AuthClient implements HttpClient {
  constructor(private readonly inner: HttpClient, private readonly tokens: TokenProvider) {}
  async send(request: Request) {
    const headers = new Headers(request.headers);
    headers.set("Authorization", "Bearer " + (await this.tokens.current()));
    return this.inner.send(new Request(request, { headers }));
  }
}

class TimeoutClient implements HttpClient {
  constructor(private readonly inner: HttpClient, private readonly limitMs: number) {}
  send(request: Request) {
    const signal = AbortSignal.any([request.signal, AbortSignal.timeout(this.limitMs)]);
    return this.inner.send(new Request(request, { signal }));
  }
}

class RetryClient implements HttpClient {
  constructor(private readonly inner: HttpClient, private readonly attempts = 3) {}
  async send(request: Request): Promise<Response> {
    for (let attempt = 1; ; attempt++) {
      const last = attempt >= this.attempts || !isIdempotent(request);
      try {
        const response = await this.inner.send(request.clone());   // a body can be read only once
        if (!RETRYABLE_STATUS.has(response.status) || last) return response;
      } catch (error) {
        if (request.signal.aborted || last || !isRetryable(error)) throw error;   // caller cancelled: never retry
      }
      await sleep(backoffWithJitter(attempt));
    }
  }
}

class CircuitBreakerClient implements HttpClient {
  constructor(private readonly inner: HttpClient, private readonly breaker: CircuitBreaker) {}
  async send(request: Request) {
    const host = new URL(request.url).host;
    if (!this.breaker.allow(host)) throw new CircuitOpenError(host);   // fail fast
    try {
      const response = await this.inner.send(request);
      this.breaker.record(host, response.status < 500);
      return response;
    } catch (error) {
      this.breaker.record(host, false);
      throw error;
    }
  }
}

class MetricsClient implements HttpClient {
  constructor(private readonly inner: HttpClient, private readonly metrics: Metrics) {}
  async send(request: Request) {
    const start = performance.now();
    let outcome = "error";
    try {
      const response = await this.inner.send(request);
      outcome = String(response.status);
      return response;
    } finally {
      this.metrics.timer("http.client", { host: new URL(request.url).host, outcome }).record(performance.now() - start);
    }
  }
}

const fetchEngine: HttpClient = { send: (request) => fetch(request) };

// Composition root, outside in:
const client: HttpClient =
  new MetricsClient(              // one measurement per logical call: what callers experience
    new CircuitBreakerClient(     // one decision per logical call; open circuit = no retries at all
      new RetryClient(            // retries the layers below, never the breaker or metrics
        new AuthClient(           // header per attempt: a refreshed token is picked up on retry
          new TimeoutClient(fetchEngine, 5_000),   // each attempt gets its own 5 s
          tokens,
        ),
      ),
      breaker,
    ),
    metrics,
  );

Why this order:

  • Metrics outermost: it measures what callers experience, including retries and fast failures from an open circuit. (Add a separate per-attempt metric inside the retry if you need both.)
  • Circuit breaker outside the retry: when a host is down, the breaker fails fast without three attempts each, and a logical call that eventually succeeded after retries isn’t counted as a failure. (Some teams put it inside to count attempts; the point is to decide.)
  • Retry outside auth and timeout: every attempt gets a fresh token and its own timeout; retries are only for idempotent requests and retryable outcomes (network errors, 429, 502–504, timeouts), never for CancellationException from the caller (an aborted signal in TypeScript).
  • Timeout innermost bounds one attempt; an overall deadline (the caller’s withTimeout) bounds the whole stack.

Each decorator is tiny and testable alone with a fake inner client, and the order lives in one place. That composition root is the design: changing behavior means reordering or swapping one line, not editing five classes. OkHttp’s interceptor chain (application vs network interceptors) expresses the same choices.

  • Order is semantics: Retry(Timeout(call)) bounds each attempt, Timeout(Retry(call)) bounds the whole operation; production stacks often use both. Metrics and circuit breakers measure what they wrap.
  • A decorator applies to every method of the interface: only retry idempotent operations (or add an idempotency key), and never retry or swallow cancellation.

TypeScript

In an async decorator, return await inner.call() inside try matters: without await, catch misses the rejection and finally runs before the call finishes. Cancellation is an aborted AbortSignal; check signal.aborted before retrying.

Kotlin

A retry loop must rethrow CancellationException (but withTimeout’s TimeoutCancellationException is a subclass you may want to retry per attempt).

Swift

A retry loop must rethrow CancellationError and check Task.isCancelled; use Task.sleep, never Thread.sleep, between attempts.

Java

Resilience4j decorators (Retry, TimeLimiter, CircuitBreaker) compose in an explicit order; Spring’s annotation order for the same is easy to get wrong.

Check this part

  1. Caching(Logging(Api)) vs Logging(Caching(Api)): what differs?

  2. Why is a generic retry decorator dangerous on a payment gateway?

  3. Retry(3 attempts, Timeout(2 s, call)) on a call that hangs, with no delay between attempts. When does the caller get an error?

  4. The user leaves the screen and the task is cancelled during a retry. The retry loop catches every error. What happens?The user leaves the screen and the coroutine is cancelled during a retry. The retry loop catches every Exception. What happens?The calling thread is interrupted during a retry, and the retry loop catches every exception, including InterruptedException, without restoring the flag. What happens?The user leaves the page section and the request's AbortSignal fires during a retry. The retry loop catches every error. What happens?

Wrappers are applied from the outside in, and order is behavior: what each layer sees decides whether cache hits are logged or retries share a timeout. A decorator applies to every method, so never retry what isn't idempotent.

Part Not started

Decorators you already use

PlatformDecorator
Javanew BufferedReader(new InputStreamReader(new FileInputStream(file))): each stream wraps another and adds buffering or decoding; Collections.unmodifiableList(list)
Android / KotlinOkHttp interceptors (logging, auth headers, retries around the real call), Kotlin class delegation by
iOSSwiftUI modifiers: .padding().background(…) wraps the view in new views; a URLProtocol subclass wrapping networking
Spring@Transactional, @Cacheable and @Async beans are proxies: decorators generated around your class
TypeScript / webExpress and Koa middleware wrapping the next handler, fetch wrappers that add auth or retries, React higher-order components (withAuth(Page)), TypeScript @decorators on methods

Once you know the shape (same interface, holds the wrapped object, adds a bit and delegates), you’ll see it everywhere. And because a decorator only needs the interface, it can wrap classes you can’t edit, like a third-party SDK or a final class from a library.

Delegation and self-use

Kotlin’s class delegation (class Logging(inner: Repo) : Repo by inner) generates forwarding methods for every interface member, so a decorator only overrides what it changes. That’s also its trap: forwarded calls go straight to inner, so the decorator’s overrides are never involved when inner calls its own methods, or when a caller uses a method you didn’t override (the second puzzle). Inheritance has the opposite trap: overridden methods are called by the superclass’s own code (self-use), which the subclass may not expect. Swift and TypeScript have no class delegation: a wrapper forwards each method by hand, which is more typing but routes exactly what you wrote through you.

Spot the bugAn add counter that's wrong in both directions

Tap the line with the bug.

class CountingSet<T>(private val inner: MutableSet<T> = HashSet()) : MutableSet<T> by inner {
    var addAttempts = 0
        private set

    override fun add(element: T): Boolean {
        addAttempts++
        return inner.add(element)
    }
}

val set = CountingSet<String>()
set.addAll(listOf("a", "b", "c"))
println(set.addAttempts)            // expected 3
class CountingSet<T> extends HashSet<T> {
    int addAttempts = 0;
    @Override public boolean add(T e) { addAttempts++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends T> c) { addAttempts += c.size(); return super.addAll(c); }
}

CountingSet<String> set = new CountingSet<>();
set.addAll(List.of("a", "b", "c"));
System.out.println(set.addAttempts);   // expected 3

Wiring and identity

  • Composition root: build the decorator stack in one place (DI module, factory), not at every call site. In Spring, a @Primary bean that takes the real implementation as a constructor argument; in Hilt/Dagger, a @Provides method that wraps.
  • Identity and type: decorated === original is false, is CachingProductSource checks see only the outer wrapper, and equals on decorators rarely makes sense. Code that downcasts to reach a concrete type defeats the pattern.
  • Async and cancellation: decorators of async/suspend APIs must preserve cancellation (never swallow CancellationError/CancellationException, or an aborted AbortSignal, in a retry loop) and shouldn’t block (use Task.sleep/delay/a timer promise).

Deep dives

Deep diveWhy java.io has so many classesInterview favorite

java.io is built almost entirely from decorators: FileInputStream reads bytes, BufferedInputStream adds buffering, DataInputStream adds reading numbers, GZIPInputStream adds decompression. Each is an InputStream wrapping an InputStream, so new DataInputStream(new BufferedInputStream(new GZIPInputStream(new FileInputStream(f)))) combines four behaviors without a class for the combination.

It’s the pattern’s best showcase and its usability warning at the same time: four nested constructors to read a file. Later APIs (Files.newBufferedReader, Files.readString) hide the stacking behind factory methods, which is the usual way to keep decorators flexible and pleasant.

  • Build the decorator stack in one composition root (DI module, factory), not at every call site.
  • Prefer composition with explicit forwarding over inheritance: a subclass “decorator” depends on its superclass’s self-calls (the fragile base class problem).

Kotlin

: Iface by inner forwards every unoverridden member straight to inner, so those calls, and inner’s internal self-calls, bypass your logic.

Java

Extending HashSet to count adds double-counts with addAll, which calls the overridden add. Use a forwarding class around a Set.

TypeScript

Built-ins have self-use too: the Set and Map constructors call this.add / this.set for initial entries, so a subclass sees those calls before its own fields exist. Wrap instead of extending.

Swift

Swift has no automatic forwarding: a decorator writes each protocol requirement, and protocol extension methods that aren’t requirements are dispatched statically, so a wrapper can’t intercept them.

struct Logged<Inner: ProductSource>: ProductSource {
    let inner: Inner
    func product(id: Int) throws -> Product {
        print("product(\(id))"); return try inner.product(id: id)
    }
}
class Logged(private val inner: ProductRepository) : ProductRepository by inner {
    override fun product(id: Int): Product = inner.product(id).also { println("product($id)") }
    // every other method is forwarded to inner automatically
}
record Logged(ProductSource inner) implements ProductSource {
    public Product product(int id) { System.out.println("product(" + id + ")"); return inner.product(id); }
}
const logged = (inner: ProductSource): ProductSource => ({
  product: (id) => {
    console.log("product(" + id + ")");
    return inner.product(id);
  },
});

Check this part

  1. In SwiftUI, Text("Hi").padding().background(.yellow) wraps the text in new views, each adding one thing. Which pattern is that?An OkHttp interceptor adds a header, then passes the request on to the real call. Which pattern is that?new BufferedReader(new InputStreamReader(new FileInputStream(file))): each stream wraps another and adds one thing. Which pattern is that?An Express middleware logs the request, then calls next() so the real handler runs. Which pattern is that?

  2. The payment SDK's client class is final and you can't edit it. How do you add logging to its calls?

  3. Where should the stack Metrics(Retry(Auth(Timeout(engine)))) be built?

  4. With Kotlin's : MutableSet<T> by inner, which methods run the decorator's logic?

  5. Why can extending HashSet to count adds double-count with addAll?

Streams, interceptors, middleware, SwiftUI modifiers and framework proxies are all decorators. Build stacks in one composition root, and prefer explicit forwarding over inheritance, which breaks on self-use.

Key takeaways

  • A decorator implements the same interface as the object it wraps, adds behavior, and delegates the real work.
  • One small decorator per behavior (cache, log, retry, metrics) replaces a subclass per combination.
  • Always call inner, and decide the order of wrappers: the outermost sees every call.
  • Decorators apply to every method of the interface; don’t retry operations that aren’t idempotent.
  • Composition order is semantics: per-attempt vs overall timeouts, where metrics and breakers sit.
  • Prefer composition with explicit forwarding over inheritance; know what Kotlin by does and doesn’t route through you.
  • Build stacks in one composition root; avoid identity and type checks on decorated objects.
  • In async code, decorators must preserve cancellation and avoid blocking.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.