InterviewKata
Design patterns · 07 of 26 · Fitting objects together

Decorator

A decorator wraps an object in another with the same interface that adds one behaviour, so behaviours can be stacked in any combination. This page wraps Pedal's remote fare service in logging, caching and retries, and shows why the order of the layers is a real decision.

1. The pain

Pedal's fares come from a pricing service across the network. Operations wants every quote logged. The service sometimes times out, so quotes should be retried. Riders ask for the same quote several times on the ride summary screen, so answers should be cached. Each of these is a small, separate concern, and different callers want different mixes.

Putting all three inside the fare service mixes network code with logging and caching, and every caller gets all of them whether it wants them or not. Subclassing for each mix is worse: three behaviours already make seven subclasses.

2. The idea in one picture

A courier packs a phone in its box, the box in bubble wrap, and the bubble wrap in a padded envelope. Each layer adds one kind of protection, and from the outside it's still "a parcel" with an address. Take a layer away, or add one, and nothing else about the parcel changes.

Decorator wraps an object in another object with the same interface that adds one behaviour and passes the call on. Because a decorator is itself a FareService, it can wrap another decorator, so behaviours stack: Logging(Caching(Retrying(remote))). Callers hold a FareService and never know how many layers there are.

Order matters. With logging outside the cache, every quote is logged, cached or not; with logging inside, only real lookups are. Neither is wrong, but you should choose. Section 6 lets you try orders.

3. The roles

RoleIts jobIn Pedal
ComponentThe interface both the real object and every decorator implement.FareService.quote(ride_id)
Concrete componentThe object that does the actual work.RemoteFares
DecoratorHolds a component, adds one behaviour, delegates the rest.Logging, Caching, Retrying
ClientUses the interface; doesn't know about the layers.The ride summary screen

4. Before and after

The fare service both ways (Python sketch; the full program is in section 5).

Without the pattern

class RemoteFares:
    def quote(self, ride_id):
        if ride_id in self.cache:
            return self.cache[ride_id]
        for attempt in range(3):
            try:
                p = self.http_get(ride_id)
                break
            except Timeout:
                ...
        self.cache[ride_id] = p
        log(f"quote {ride_id} -> {p}p")
        return p

With it

class Caching(FareService):
    def __init__(self, inner):
        self.inner, self.cache = inner, {}

    def quote(self, ride_id):
        if ride_id not in self.cache:
            self.cache[ride_id] = \
                self.inner.quote(ride_id)
        return self.cache[ride_id]

fares = Logging(Caching(Retrying(RemoteFares())))
The next changeWithout the patternWith it
The admin tool wants fares without the cacheA flag inside quote(), or a copy of the classLeave Caching out of its stack
Add timing metricsEdit the one big method againA Timing decorator; nothing else changes
Test the retry ruleTest it through the network codeWrap a fake that fails twice

Each concern is one small class, and each caller builds the stack it needs.

5. The code

Three decorators around a remote fare service that times out on its 1st and 3rd calls. The same three wrappers in a different order produce a different log.

from __future__ import annotations
from abc import ABC, abstractmethod


class FareService(ABC):
    @abstractmethod
    def quote(self, ride_id: str) -> int: ...


class FareTimeout(Exception):
    pass


class RemoteFares(FareService):
    """The real thing: a pricing service across the network that times out on calls 1 and 3."""

    def __init__(self) -> None:
        self.prices = {"R-1": 280, "R-2": 120}
        self.calls = 0

    def quote(self, ride_id: str) -> int:
        self.calls += 1
        if self.calls in (1, 3):
            raise FareTimeout(ride_id)
        return self.prices[ride_id]


# ---- Decorators: each is a FareService that holds another FareService and adds one behaviour. ----
class Retrying(FareService):
    def __init__(self, inner: FareService, attempts: int = 3) -> None:
        self.inner, self.attempts = inner, attempts

    def quote(self, ride_id: str) -> int:
        for attempt in range(1, self.attempts + 1):
            try:
                return self.inner.quote(ride_id)
            except FareTimeout:
                if attempt == self.attempts:
                    raise
        raise AssertionError("unreachable")


class Caching(FareService):
    def __init__(self, inner: FareService) -> None:
        self.inner = inner
        self.cache: dict[str, int] = {}

    def quote(self, ride_id: str) -> int:
        if ride_id not in self.cache:
            self.cache[ride_id] = self.inner.quote(ride_id)
        return self.cache[ride_id]


class Logging(FareService):
    def __init__(self, inner: FareService, log: list[str]) -> None:
        self.inner, self.log = inner, log

    def quote(self, ride_id: str) -> int:
        pence = self.inner.quote(ride_id)
        self.log.append(f"quote {ride_id} -> {pence}p")
        return pence


if __name__ == "__main__":
    log: list[str] = []
    remote = RemoteFares()
    fares: FareService = Logging(Caching(Retrying(remote)), log)     # outermost first
    for ride in ["R-1", "R-1", "R-2"]:
        print(f"{ride}: {fares.quote(ride)}p")
    print("log: " + " | ".join(log))
    print(f"remote calls: {remote.calls} (two timeouts retried, one answer from the cache)")

    log2: list[str] = []
    swapped: FareService = Caching(Logging(Retrying(RemoteFares()), log2))   # same wrappers, another order
    for ride in ["R-1", "R-1", "R-2"]:
        swapped.quote(ride)
    print(f"with logging inside the cache: {len(log2)} log lines for 3 quotes")
interface FareService { quote(rideId: string): number; }

class FareTimeout extends Error {}

// The real thing: a pricing service across the network that times out on calls 1 and 3.
class RemoteFares implements FareService {
  private readonly prices: Record<string, number> = { "R-1": 280, "R-2": 120 };
  calls = 0;
  quote(rideId: string): number {
    this.calls++;
    if (this.calls === 1 || this.calls === 3) throw new FareTimeout(rideId);
    return this.prices[rideId];
  }
}

// ---- Decorators: each is a FareService that holds another FareService and adds one behaviour. ----
class Retrying implements FareService {
  constructor(private readonly inner: FareService, private readonly attempts = 3) {}
  quote(rideId: string): number {
    for (let attempt = 1; ; attempt++) {
      try {
        return this.inner.quote(rideId);
      } catch (e) {
        if (!(e instanceof FareTimeout) || attempt === this.attempts) throw e;
      }
    }
  }
}

class Caching implements FareService {
  private readonly cache = new Map<string, number>();
  constructor(private readonly inner: FareService) {}
  quote(rideId: string): number {
    if (!this.cache.has(rideId)) this.cache.set(rideId, this.inner.quote(rideId));
    return this.cache.get(rideId)!;
  }
}

class Logging implements FareService {
  constructor(private readonly inner: FareService, private readonly log: string[]) {}
  quote(rideId: string): number {
    const pence = this.inner.quote(rideId);
    this.log.push(`quote ${rideId} -> ${pence}p`);
    return pence;
  }
}

const log: string[] = [];
const remote = new RemoteFares();
const fares: FareService = new Logging(new Caching(new Retrying(remote)), log);   // outermost first
for (const ride of ["R-1", "R-1", "R-2"]) console.log(`${ride}: ${fares.quote(ride)}p`);
console.log("log: " + log.join(" | "));
console.log(`remote calls: ${remote.calls} (two timeouts retried, one answer from the cache)`);

const log2: string[] = [];
const swapped: FareService = new Caching(new Logging(new Retrying(new RemoteFares()), log2));   // same wrappers, another order
for (const ride of ["R-1", "R-1", "R-2"]) swapped.quote(ride);
console.log(`with logging inside the cache: ${log2.length} log lines for 3 quotes`);
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Main {
    interface FareService { int quote(String rideId); }

    static class FareTimeout extends RuntimeException {
        private static final long serialVersionUID = 1L;
        FareTimeout(String rideId) { super(rideId); }
    }

    // The real thing: a pricing service across the network that times out on calls 1 and 3.
    static class RemoteFares implements FareService {
        private final Map<String, Integer> prices = Map.of("R-1", 280, "R-2", 120);
        int calls;
        public int quote(String rideId) {
            calls++;
            if (calls == 1 || calls == 3) throw new FareTimeout(rideId);
            return prices.get(rideId);
        }
    }

    // ---- Decorators: each is a FareService that holds another FareService and adds one behaviour. ----
    static class Retrying implements FareService {
        private final FareService inner;
        private final int attempts;
        Retrying(FareService inner, int attempts) { this.inner = inner; this.attempts = attempts; }
        public int quote(String rideId) {
            for (int attempt = 1; ; attempt++) {
                try {
                    return inner.quote(rideId);
                } catch (FareTimeout e) {
                    if (attempt == attempts) throw e;
                }
            }
        }
    }

    static class Caching implements FareService {
        private final FareService inner;
        private final Map<String, Integer> cache = new HashMap<>();
        Caching(FareService inner) { this.inner = inner; }
        public int quote(String rideId) {
            Integer hit = cache.get(rideId);
            if (hit != null) return hit;
            int pence = inner.quote(rideId);
            cache.put(rideId, pence);
            return pence;
        }
    }

    static class Logging implements FareService {
        private final FareService inner;
        private final List<String> log;
        Logging(FareService inner, List<String> log) { this.inner = inner; this.log = log; }
        public int quote(String rideId) {
            int pence = inner.quote(rideId);
            log.add("quote " + rideId + " -> " + pence + "p");
            return pence;
        }
    }

    public static void main(String[] args) {
        List<String> log = new ArrayList<>();
        RemoteFares remote = new RemoteFares();
        FareService fares = new Logging(new Caching(new Retrying(remote, 3)), log);   // outermost first
        for (String ride : List.of("R-1", "R-1", "R-2")) System.out.println(ride + ": " + fares.quote(ride) + "p");
        System.out.println("log: " + String.join(" | ", log));
        System.out.println("remote calls: " + remote.calls + " (two timeouts retried, one answer from the cache)");

        List<String> log2 = new ArrayList<>();
        FareService swapped = new Caching(new Logging(new Retrying(new RemoteFares(), 3), log2));   // same wrappers, another order
        for (String ride : List.of("R-1", "R-1", "R-2")) swapped.quote(ride);
        System.out.println("with logging inside the cache: " + log2.size() + " log lines for 3 quotes");
    }
}

Output (the same in all three languages)

R-1: 280p
R-1: 280p
R-2: 120p
log: quote R-1 -> 280p | quote R-1 -> 280p | quote R-2 -> 120p
remote calls: 4 (two timeouts retried, one answer from the cache)
with logging inside the cache: 2 log lines for 3 quotes

6. Try it

Stack the wrappers

Three quotes run through your stack: R-1, R-1 again, then R-2. The remote service times out on its 1st and 3rd calls. Turn layers on or off and change their order.

    The top of the list is the outermost layer: the first one a call meets.

    7. When not to use it

    What you get

    • Behaviours mix and match: each caller stacks what it needs.
    • Small classes: one concern each, easy to test.
    • No edits to the real object: add behaviour around code you don't own.
    • Runtime choice: build the stack from configuration.

    What you pay

    • Order is a hidden decision: the same layers in another order behave differently.
    • Many tiny objects: debugging means stepping through layers.
    • Identity is lost: the wrapper isn't the original object, so is checks and type checks fail.
    • Wide interfaces hurt: every decorator must forward every method.
    If…Use instead
    You need a different interface, not extra behaviour06 Adapter
    You need to control access (laziness, permissions, remoteness)09 Proxy
    The behaviour is one fixed step every call needsPut it in the class
    Your framework has middleware or interceptorsUse them: they're decorators already

    8. How it goes wrong

    Forgetting to forward a methodA decorator that implements only quote() of a two-method interface silently breaks the second. Generate or inherit the forwarding.
    Order chosen by accidentRetry inside or outside the cache, logging inside or outside: decide on purpose and write the reason next to the stack.
    Decorators that depend on each otherIf Caching only works when Logging is outside it, they aren't independent layers any more.
    Changing the meaningA decorator that rounds prices or swaps currencies is changing behaviour callers rely on. That's a different service, not a wrapper.

    9. Remember it and say it

    Memory hook: "Parcel in layers"Each wrapper adds one protection; from outside it's still the same parcel.
    How this hook works: what “Parcel in layers” means

    What the slogan means, phrase by phrase

    “Parcel”
    The interface: always a FareService.
    “in layers”
    Decorators wrapping decorators.
    “one protection”
    One concern per wrapper.

    Why this picture fits: Packing makes layering physical: you can see that each layer is independent and that order matters (envelope outside, box inside).

    The story → the concept

    The parcelThe code
    The phoneRemoteFares
    Box, bubble wrap, envelopeRetrying, Caching, Logging
    The address labelThe FareService interface
    Repacking in another orderChanging what gets logged or retried

    Watch it on an example

    1. The screen asks for R-1's fare.
    2. Logging passes it in, Caching misses, Retrying calls the remote: a timeout, then 280p.
    3. The second R-1 stops at Caching: logged, but no remote call.

    The trap it saves you from

    Cramming logging, caching and retries into one class, or one subclass per combination.

    It still works when…

    Yes: Python's @functools.cache decorator, Java's BufferedInputStream, and HTTP middleware all wrap the same interface.

    In an interview or code review

    10. Check yourself

    What must a decorator share with the object it wraps?

    That's what lets decorators stack and stay invisible to callers.

    Logging(Caching(Retrying(remote))): the second quote for R-1. What happens?

    Logging is outside the cache, so it sees every request.

    Three independent behaviours via subclassing need how many classes for every mix?

    Every non-empty combination; decorators need three.

    Which change is a decorator's job?

    Same interface, extra behaviour.

    What's a common decorator pitfall?

    Every method must pass through.

    11. Go deeper

    ResourceWhy
    Python functools.lru_cacheA caching decorator for functions, built into the language.
    Java BufferedInputStreamThe classic standard-library decorator: buffering around any stream.
    09 ProxySame shape, different intent: controlling access.
    Wikipedia: Decorator patternStructure and the comparison with subclassing.