Skip to content

Low Level DesignDesign patterns

Decorator pattern

The Decorator design pattern through three real examples, from compressed and encrypted exports to HTTP retries and timeouts to a stampede-safe cache, with diagrams, live demos and code in five languages.

Mid-level20 min read

A decorator wraps an object in another object with the same interface. The wrapper adds one thing (compression, a retry, a cache, a log line) and passes the call on to the object inside. Callers can’t tell the difference, so wrappers can be stacked in any combination, chosen at run time, without a class for every combination.

You have used it many times. Java’s new GZIPOutputStream(new FileOutputStream(...)) is one. So is .NET’s DelegatingHandler chain inside HttpClient. So is Go’s http.TimeoutHandler, which takes an http.Handler and returns one. This page builds in three steps. First, optional features on a file writer. Then layers whose order changes behavior. Then a cache that stays correct under a burst of requests at the same time.

The idea in one picture
ExportJob holds "a Writer" write(row) CompressWriter PAID,,,,,,,, → PAID[,*8] EncryptWriter PAID[,*8] → SDLG[,*8] FileWriter writes SDLG[,*8] to disk knows nothing about options same interface at every layer: add one thing, then delegate subclassing instead: CompressedFileWriter EncryptedFileWriter CompressedEncrypted- FileWriter ...and the same for BucketWriter one more option = twice the classes
Scroll or pinch to zoom, drag to pan
Each wrapper is a Writer and has a Writer. The call passes through every layer, and each one adds one thing. Subclassing would need a class per combination.

Every decorator setup has the same parts:

  • The component interface is what callers use, for example Writer.
  • Concrete components do the real work, for example FileWriter. They know nothing about the extras.
  • Decorators implement the interface and hold a reference to another instance of it. Each method does its extra work before or after calling the wrapped object.
The general shape

uses

wraps

wraps

Client

«interface»

Component

+operation()

ConcreteComponent

+operation()

DecoratorA

-inner: Component

+operation()

DecoratorB

-inner: Component

+operation()

uses

wraps

wraps

Client

«interface»

Component

+operation()

ConcreteComponent

+operation()

DecoratorA

-inner: Component

+operation()

DecoratorB

-inner: Component

+operation()

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

The two arrows from each decorator to Component are the whole pattern: it implements the interface and it holds one. The client only has an arrow to the interface, so it can’t tell how many layers it’s talking to.

The Gang of Four version adds an abstract Decorator base class. It holds inner, and forwards every method. That helps when the interface has many methods and each decorator changes one. With a one-method interface, like the ones on this page, it adds nothing.

Easy

Compressed and encrypted exports

  • The basic shape
  • A class per feature, not per combination
  • Choosing features at run time

A nightly job exports order rows to files and to a storage bucket. Some destinations need the data compressed, some need it encrypted, some both, and finance now wants a count of the bytes that reach the disk. Which options apply depends on the destination’s settings, so they’re known only at run time.

Give each combination its own subclass:

Codec.javacompressed_encrypted_file_writer.pyCodec.cscompressed_encrypted_file_writer.gocompressed-encrypted-file-writer.ts
// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.
final class Codec {
// Toy compression: a run of 3 or more equal characters becomes [c*n].
static String compress(String s) {
StringBuilder out = new StringBuilder();
int i = 0;
while (i < s.length()) {
char c = s.charAt(i);
int n = 1;
while (i + n < s.length() && s.charAt(i + n) == c) n++;
out.append(n >= 3 ? "[" + c + "*" + n + "]" : String.valueOf(c).repeat(n));
i += n;
}
return out.toString();
}
// Toy encryption: shift each letter 3 places. It only stands in for a real cipher.
static String encrypt(String s) {
StringBuilder out = new StringBuilder();
for (char c : s.toCharArray()) {
if (c >= 'A' && c <= 'Z') out.append((char) ('A' + (c - 'A' + 3) % 26));
else if (c >= 'a' && c <= 'z') out.append((char) ('a' + (c - 'a' + 3) % 26));
else out.append(c);
}
return out.toString();
}
}
class FileWriter {
private final String path;
FileWriter(String path) { this.path = path; }
void write(String data) { System.out.println(path + " <- " + data); }
}
// Before: one subclass per combination of options.
class CompressedFileWriter extends FileWriter {
CompressedFileWriter(String path) { super(path); }
@Override void write(String data) { super.write(Codec.compress(data)); }
}
class EncryptedFileWriter extends FileWriter {
EncryptedFileWriter(String path) { super(path); }
@Override void write(String data) { super.write(Codec.encrypt(data)); }
}
// flaw 1: this class repeats the other two. A fix to either must be copied here.
class CompressedEncryptedFileWriter extends FileWriter {
CompressedEncryptedFileWriter(String path) { super(path); }
@Override void write(String data) { super.write(Codec.encrypt(Codec.compress(data))); }
}
// flaw 2: uploads to a bucket need the same three subclasses of BucketWriter.
// flaw 3: one more option (say, counting bytes) doubles everything: 2 options make
// 4 classes per destination, 3 options make 8, and n options make 2^n.
class Main {
public static void main(String[] args) {
String row = "ORD-7,PAID,,,,,,,,EUR,0000000495";
new FileWriter("orders.csv").write(row); // orders.csv <- ORD-7,PAID,,,,,,,,EUR,0000000495
new CompressedEncryptedFileWriter("orders.csv.z.enc").write(row); // orders.csv.z.enc <- RUG-7,SDLG[,*8]HXU,[0*7]495
}
}

Every option doubles the number of classes: two options need four writers per destination, three need eight, and every destination needs its own set. CompressedEncryptedFileWriter repeats the code of the other two, so a fix to compression has to be made in several places. And the choice is fixed at compile time: a setting can’t switch encryption on without the code naming the right class.

Keep one Writer interface. Destinations implement it. Each option becomes a small class that also implements Writer, holds the next Writer, transforms the data and passes it on. Combinations are built by wrapping:

Easy: one interface, one class per option

writes to

wraps

wraps

wraps

ExportJob

+run(rows)

«interface»

Writer

+write(data)

FileWriter

-path

CompressWriter

-inner: Writer

EncryptWriter

-inner: Writer

CountingWriter

-inner: Writer

+count()

writes to

wraps

wraps

wraps

ExportJob

+run(rows)

«interface»

Writer

+write(data)

FileWriter

-path

CompressWriter

-inner: Writer

EncryptWriter

-inner: Writer

CountingWriter

-inner: Writer

+count()

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Switch options on and off, and follow the row through the chain. The codecs are toys, so the output stays readable. A run of three or more equal characters becomes [c*n]. Encryption shifts each letter by three places. Real code uses gzip and AES on bytes.

Options:
export jobCompressWriterEncryptWriterCountingWriterFileWriter
  1. row
  2. CompressWriter
  3. EncryptWriter
  4. CountingWriter
  5. orders.csv gets

Classes for every combination of3options, for 2 destinations (file and bucket)

Subclass per combination
Decorators

Codec.javacounting_writer.pyCodec.cscounting_writer.gocounting-writer.ts
// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.
final class Codec {
// Toy compression: a run of 3 or more equal characters becomes [c*n].
static String compress(String s) {
StringBuilder out = new StringBuilder();
int i = 0;
while (i < s.length()) {
char c = s.charAt(i);
int n = 1;
while (i + n < s.length() && s.charAt(i + n) == c) n++;
out.append(n >= 3 ? "[" + c + "*" + n + "]" : String.valueOf(c).repeat(n));
i += n;
}
return out.toString();
}
// Toy encryption: shift each letter 3 places. It only stands in for a real cipher.
static String encrypt(String s) {
StringBuilder out = new StringBuilder();
for (char c : s.toCharArray()) {
if (c >= 'A' && c <= 'Z') out.append((char) ('A' + (c - 'A' + 3) % 26));
else if (c >= 'a' && c <= 'z') out.append((char) ('a' + (c - 'a' + 3) % 26));
else out.append(c);
}
return out.toString();
}
}
// The one interface. The export job, every destination and every wrapper speak it.
interface Writer { void write(String data); }
// Concrete components: where the data finally goes. They know nothing about options.
class FileWriter implements Writer {
private final String path;
FileWriter(String path) { this.path = path; }
public void write(String data) { System.out.println(path + " <- " + data); }
}
// Each decorator IS a Writer (so it can stand in anywhere a Writer is expected) and HAS a
// Writer (the next one in the chain). It adds one thing, then delegates.
class CompressWriter implements Writer {
private final Writer inner;
CompressWriter(Writer inner) { this.inner = inner; }
public void write(String data) { inner.write(Codec.compress(data)); }
}
class EncryptWriter implements Writer {
private final Writer inner;
EncryptWriter(Writer inner) { this.inner = inner; }
public void write(String data) { inner.write(Codec.encrypt(data)); }
}
// A decorator doesn't have to change the data. This one only observes it.
class CountingWriter implements Writer {
private final Writer inner;
private int count;
CountingWriter(Writer inner) { this.inner = inner; }
public void write(String data) { count += data.length(); inner.write(data); }
int count() { return count; }
}
class Main {
public static void main(String[] args) {
String row = "ORD-7,PAID,,,,,,,,EUR,0000000495";
// No options: the bare destination.
Writer plain = new FileWriter("orders.csv");
plain.write(row); // orders.csv <- ORD-7,PAID,,,,,,,,EUR,0000000495
// Options are chosen by wrapping, at run time. The outermost wrapper runs first,
// so this compresses, then encrypts: encrypted bytes look random and don't compress.
Writer secure = new CompressWriter(new EncryptWriter(new FileWriter("orders.csv.z.enc")));
secure.write(row); // orders.csv.z.enc <- RUG-7,SDLG[,*8]HXU,[0*7]495
// A new option is one new class, and it combines with all the others for free.
CountingWriter onDisk = new CountingWriter(new FileWriter("orders.csv.z"));
Writer counted = new CompressWriter(onDisk);
counted.write(row); // orders.csv.z <- ORD-7,PAID[,*8]EUR,[0*7]495
System.out.println(row.length() + " chars in, " + onDisk.count() + " on disk"); // 32 chars in, 27 on disk
}
}
  • One class per option. Three options are three wrappers, and they combine in any way. A fourth option is one new class, and nothing that exists changes, which is the open-closed principle applied to behavior.
  • The caller doesn’t know. ExportJob writes to a Writer. Whether that is a bare file or three layers deep is decided in one place, where the objects are built.
  • A wrapper doesn’t have to change the data. CountingWriter only observes it. Logging, metrics and access checks are decorators of this kind.
  • Order matters already. new CompressWriter(new EncryptWriter(file)) compresses, then encrypts. The other way round would barely compress at all, because encrypted bytes look random. The same rule applies when stacking Java’s GZIPOutputStream and CipherOutputStream.
  • Where you wrap decides what you see. CountingWriter sits inside CompressWriter. So it counts the 27 characters that reach the disk, not the 32 that went in.
Interviewer

Exports can now be compressed, encrypted, or both, per destination. Then legal asks for a checksum on some of them. How do you model that?

What they are testing

Whether you see that options combine, and reach for one wrapper per option around a shared interface instead of a subclass per combination. A class name with two “and”s in it, or a write method full of if (compress) flags, is the red flag.

Say this out loud

The options combine freely, so subclassing would need a class per combination. I’ll keep one Writer interface and make each option a decorator: it implements Writer, holds the next Writer, does its one job and delegates. The job’s settings decide what to wrap.

Medium

Retries, timeouts and logs

  • Cross-cutting concerns as layers
  • Order changes behavior
  • Deadlines that flow inward

A checkout service calls a currency-rates API over HTTP. The API is flaky. Some calls hang, and some return 503. Every call needs a timeout, retries with backoff, and a log line. The same is true for the five other APIs the service calls. Some callers need each attempt to have its own timeout. The checkout page needs one total limit, because a customer is waiting.

Put everything into the client:

RatesClient.javarates_client.pyRatesClient.csrates_client.gorates-client.ts
// A fake clock in milliseconds, so timing is deterministic: nothing here really sleeps.
final class Clock {
private long now;
long now() { return now; }
void advance(long ms) { now += ms; }
void advanceTo(long t) { now = t; }
}
class TimeoutException extends RuntimeException {
TimeoutException() { super("timeout"); }
}
record Step(long latencyMs, int status) {}
// The flaky upstream: slow on the first attempt, 503 on the second, fine on the third.
final class FlakyServer {
private final Clock clock;
private final Step[] script = { new Step(800, 200), new Step(50, 503), new Step(120, 200) };
int calls;
FlakyServer(Clock clock) { this.clock = clock; }
int send(String path, long deadline) {
Step step = script[Math.min(calls, script.length - 1)];
calls++;
if (clock.now() + step.latencyMs() > deadline) {
clock.advanceTo(deadline);
throw new TimeoutException();
}
clock.advance(step.latencyMs());
return step.status();
}
}
// Before: one client does the call, the timeout, the retries and the logging, all in
// one method, switched on and off by flags.
final class RatesClient {
private final FlakyServer server;
private final Clock clock;
private final boolean logging;
private final int maxAttempts;
private final long timeoutMs;
RatesClient(FlakyServer server, Clock clock, boolean logging, int maxAttempts, long timeoutMs) {
this.server = server; this.clock = clock; this.logging = logging;
this.maxAttempts = maxAttempts; this.timeoutMs = timeoutMs;
}
int get(String path) {
long start = clock.now();
long backoffMs = 100;
// flaw 1: the order is baked in. Logging is always outside the loop and the timeout
// is always per attempt. A caller that needs one total deadline can't have it.
for (int attempt = 1; ; attempt++) {
String why;
try {
int status = server.send(path, clock.now() + timeoutMs);
if (status < 500) {
if (logging) System.out.println("log: GET " + path + " -> " + status + " in " + (clock.now() - start) + " ms");
return status;
}
why = status + " at t=" + clock.now();
} catch (TimeoutException e) {
why = "timeout at t=" + clock.now();
}
// flaw 2: three concerns braided together. Adding a circuit breaker or metrics
// means editing this loop, and every other service client has its own copy.
if (attempt == maxAttempts) {
if (logging) System.out.println("log: GET " + path + " -> failed in " + (clock.now() - start) + " ms");
throw new TimeoutException();
}
System.out.println("retry: attempt " + attempt + " failed (" + why + "), waiting " + backoffMs + " ms");
clock.advance(backoffMs);
backoffMs *= 2;
}
}
}
class Main {
public static void main(String[] args) {
Clock clock = new Clock();
FlakyServer server = new FlakyServer(clock);
// flaw 3: a test that wants no retries and no logging still goes through this code,
// steered by flags instead of simply not wrapping.
RatesClient rates = new RatesClient(server, clock, true, 3, 300);
rates.get("/rates");
// retry: attempt 1 failed (timeout at t=300), waiting 100 ms
// retry: attempt 2 failed (503 at t=450), waiting 200 ms
// log: GET /rates -> 200 in 770 ms
}
}

The logic works, but it’s welded together. The order of the three concerns is fixed by how the loop is written. Every API client carries its own copy. And adding a circuit breaker means editing the loop again.

Make the client an interface with one method, send(path, deadline). The real client and each concern implement it. The deadline travels with the call. So an outer timeout can limit everything inside it. Go’s context.Context, .NET’s CancellationToken and the web’s AbortSignal work the same way:

Medium: three layers around one interface

calls

wraps

wraps

wraps

RatesService

+convert(amount, currency)

«interface»

HttpClient

+send(path, deadline) : status

RealHttpClient

LoggingClient

-inner: HttpClient

RetryClient

-inner: HttpClient

-maxAttempts

TimeoutClient

-inner: HttpClient

-limitMs

calls

wraps

wraps

wraps

RatesService

+convert(amount, currency)

«interface»

HttpClient

+send(path, deadline) : status

RealHttpClient

LoggingClient

-inner: HttpClient

RetryClient

-inner: HttpClient

-maxAttempts

TimeoutClient

-inner: HttpClient

-limitMs

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

The fake server below is slow on the first attempt (800 ms), returns 503 on the second and succeeds on the third. Reorder the layers and send the request:

Layers, outermost first

  1. LoggingClientone log line per call through it
  2. RetryClientup to 3 attempts, backoff 100, 200 ms
  3. TimeoutClient300 ms deadline for what is inside
  4. FlakyServer800 ms, then 503, then 200

What this order means

    Timeline (fake clock, ms)

    0250500750

      Reorder the layers, then send the request.

      Main.javalogging_client.pyProgram.cslogging_client.gologging-client.ts
      // A fake clock in milliseconds, shared by the server and every layer. It makes timing
      // deterministic: nothing here really sleeps.
      final class Clock {
      private long now;
      long now() { return now; }
      void advance(long ms) { now += ms; }
      void advanceTo(long t) { now = t; }
      }
      class TimeoutException extends RuntimeException {
      TimeoutException() { super("timeout"); }
      }
      // The one interface for the real client and every layer around it. The deadline travels
      // with the call, the way Go's context or .NET's CancellationToken does, so an outer layer
      // can limit everything inside it.
      interface HttpClient {
      long NO_DEADLINE = Long.MAX_VALUE;
      int send(String path, long deadline);
      }
      record Step(long latencyMs, int status) {}
      // The flaky upstream: slow on the first attempt, 503 on the second, fine on the third.
      // A real client stops waiting at the deadline; the fake does the same.
      final class FlakyServer implements HttpClient {
      private final Clock clock;
      private final Step[] script = { new Step(800, 200), new Step(50, 503), new Step(120, 200) };
      int calls;
      FlakyServer(Clock clock) { this.clock = clock; }
      public int send(String path, long deadline) {
      Step step = script[Math.min(calls, script.length - 1)];
      calls++;
      if (clock.now() + step.latencyMs() > deadline) {
      clock.advanceTo(deadline);
      throw new TimeoutException();
      }
      clock.advance(step.latencyMs());
      return step.status();
      }
      }
      // Timeout: shortens the deadline for everything inside it. It never extends one set
      // further out, so an outer, tighter deadline still wins.
      final class TimeoutClient implements HttpClient {
      private final HttpClient inner;
      private final long limitMs;
      private final Clock clock;
      TimeoutClient(HttpClient inner, long limitMs, Clock clock) { this.inner = inner; this.limitMs = limitMs; this.clock = clock; }
      public int send(String path, long deadline) {
      return inner.send(path, Math.min(deadline, clock.now() + limitMs));
      }
      }
      // Retry with exponential backoff. It retries timeouts and 5xx, and stops early when the
      // deadline it was given leaves no time for another wait: retrying past it is wasted work.
      final class RetryClient implements HttpClient {
      private final HttpClient inner;
      private final int maxAttempts;
      private final Clock clock;
      RetryClient(HttpClient inner, int maxAttempts, Clock clock) { this.inner = inner; this.maxAttempts = maxAttempts; this.clock = clock; }
      public int send(String path, long deadline) {
      long backoffMs = 100;
      for (int attempt = 1; ; attempt++) {
      int status = 0;
      TimeoutException error = null;
      try {
      status = inner.send(path, deadline);
      if (status < 500) return status;
      } catch (TimeoutException e) {
      error = e;
      }
      String why = (error != null ? "timeout" : String.valueOf(status)) + " at t=" + clock.now();
      String stop = attempt == maxAttempts ? "no attempts left"
      : clock.now() + backoffMs >= deadline ? "no time left" : null;
      if (stop != null) {
      System.out.println("retry: attempt " + attempt + " failed (" + why + "), " + stop);
      if (error != null) throw error;
      return status;
      }
      System.out.println("retry: attempt " + attempt + " failed (" + why + "), waiting " + backoffMs + " ms");
      clock.advance(backoffMs);
      backoffMs *= 2;
      }
      }
      }
      // Logging: one line per call that passes through it. Where it sits decides what a "call" is.
      final class LoggingClient implements HttpClient {
      private final HttpClient inner;
      private final Clock clock;
      LoggingClient(HttpClient inner, Clock clock) { this.inner = inner; this.clock = clock; }
      public int send(String path, long deadline) {
      long start = clock.now();
      try {
      int status = inner.send(path, deadline);
      System.out.println("log: GET " + path + " -> " + status + " in " + (clock.now() - start) + " ms");
      return status;
      } catch (TimeoutException e) {
      System.out.println("log: GET " + path + " -> timeout in " + (clock.now() - start) + " ms");
      throw e;
      }
      }
      }
      class Main {
      static void call(HttpClient client, FlakyServer server) {
      String result;
      try {
      result = String.valueOf(client.send("/rates", HttpClient.NO_DEADLINE));
      } catch (TimeoutException e) {
      result = "timeout";
      }
      System.out.println("caller got " + result + " (server calls: " + server.calls + ")");
      }
      public static void main(String[] args) {
      // Same three layers, three orders. Only the wrapping changes.
      System.out.println("== logging > retry > timeout: each attempt gets 300 ms ==");
      Clock c1 = new Clock();
      FlakyServer s1 = new FlakyServer(c1);
      call(new LoggingClient(new RetryClient(new TimeoutClient(s1, 300, c1), 3, c1), c1), s1);
      // retry: attempt 1 failed (timeout at t=300), waiting 100 ms
      // retry: attempt 2 failed (503 at t=450), waiting 200 ms
      // log: GET /rates -> 200 in 770 ms
      // caller got 200 (server calls: 3)
      System.out.println("== logging > timeout > retry: 300 ms for everything ==");
      Clock c2 = new Clock();
      FlakyServer s2 = new FlakyServer(c2);
      call(new LoggingClient(new TimeoutClient(new RetryClient(s2, 3, c2), 300, c2), c2), s2);
      // retry: attempt 1 failed (timeout at t=300), no time left
      // log: GET /rates -> timeout in 300 ms
      // caller got timeout (server calls: 1)
      System.out.println("== retry > logging > timeout: one log line per attempt ==");
      Clock c3 = new Clock();
      FlakyServer s3 = new FlakyServer(c3);
      call(new RetryClient(new LoggingClient(new TimeoutClient(s3, 300, c3), c3), 3, c3), s3);
      // log: GET /rates -> timeout in 300 ms
      // retry: attempt 1 failed (timeout at t=300), waiting 100 ms
      // log: GET /rates -> 503 in 50 ms
      // retry: attempt 2 failed (503 at t=450), waiting 200 ms
      // log: GET /rates -> 200 in 120 ms
      // caller got 200 (server calls: 3)
      }
      }
      • Retry outside timeout: each attempt gets its own limit. The first attempt is cut off at 300 ms. The retry tries again, and the request succeeds at 770 ms.
      • Timeout outside retry: one limit for everything. The 300 ms covers every attempt and every backoff wait. The first attempt uses it all, and the retry sees there’s no time left and stops. That is the right behavior when a person is waiting, and the wrong one for a background job.
      • Logging outside retry logs requests. Inside, it logs attempts. One line per request is what a dashboard wants. One line per attempt is what you want while debugging a flaky upstream. Neither is wrong. It’s a choice, and the order is how you make it.
      • Retry respects the deadline it was given. Waiting 100 ms when only 20 remain is wasted work. And retrying after the caller has given up adds load to a struggling server, for an answer nobody will read.
      • The clock is injected. Each layer reads time from a Clock. So the demo and the code give the same result every run, and a test can check “gives up after 300 ms” without sleeping.
      • Same classes, different programs. All three runs use the same three classes. Only the composition code changes, so it belongs in one place, reviewed like any other logic.
      Interviewer

      Our checkout calls the rates API with a 2 second timeout and up to 3 attempts. During an incident, checkout pages took over 6 seconds to fail. Why, and how do you fix it?

      What they are testing

      Whether you see that the timeout was per attempt (retry wrapping timeout). So the worst case is attempts times timeout, plus backoff. And whether you fix it with an outer total deadline that retry respects. The red flag is shortening the timeout, or dropping retries, without asking what each caller needs.

      Hard

      A stampede-safe price cache

      • A decorator with state
      • Single-flight under concurrency
      • TTL, errors and bounded size
      • Identity and debugging costs

      Product pages call a pricing service. It takes about 100 ms per call, and starts failing above a few hundred calls per second. A cache in front of it is the obvious fix. A decorator is the natural shape: callers keep asking a PriceService for prices, and never know a cache is there.

      The interesting part is what happens when the cache is cold. After a deploy, or when a popular item’s entry expires, a hundred requests for the same SKU can arrive within the time one load takes. A naive cache checks, misses, and calls the backend, and so does every other request that arrives before the first load finishes. That is a cache stampede, and it hits the backend hardest exactly when the cache is not protecting it.

      Cache entry for SKU-1: missing

      Backend calls
      0
      Called the backend
      0
      Waited for a shared load
      0
      Cache hit
      0
      Peak calls in flight
      0

      The entry is missing, as after a deploy or a TTL expiry. Fire a burst.

      The caching decorator owns state, so it has more to get right than a stateless wrapper:

      • Single-flight. At most one backend call per key at a time. A request that misses while a load is running waits for that load’s result.
      • TTL. Each entry expires, so price changes reach users within a known time.
      • Errors aren’t cached. A failed load is passed to everyone waiting on it, then forgotten, so the next request tries again.
      • Bounded size. The cache evicts the least recently used entry when it is full.
      • Metrics. Requests, loads, failures and evictions, so the hit rate can be watched.
      Hard: a caching decorator with single-flight

      asks

      wraps

      stores

      ProductPage

      «interface»

      PriceService

      +price(sku) : cents

      PricingApi

      +price(sku) : cents

      CachingPriceService

      -inner: PriceService

      -entries: LRU map sku to Entry

      -inFlight: Map sku to pending load

      -ttlMs, maxEntries, clock

      +price(sku) : cents

      +metrics()

      Entry

      +price

      +expiresAt

      asks

      wraps

      stores

      ProductPage

      «interface»

      PriceService

      +price(sku) : cents

      PricingApi

      +price(sku) : cents

      CachingPriceService

      -inner: PriceService

      -entries: LRU map sku to Entry

      -inFlight: Map sku to pending load

      -ttlMs, maxEntries, clock

      +price(sku) : cents

      +metrics()

      Entry

      +price

      +expiresAt

      Scroll or pinch to zoom, drag to pan
      • Color key
      • Facade
      • Entity
      • Value object
      • Enum
      • Interface
      • Implementation
      Two requests miss the same key at once
      PricingApiCachingPriceServiceRequest BRequest APricingApiCachingPriceServiceRequest BRequest Amiss, but a load is in flight: B waits on itprice("SKU-1")miss: register A's load as in flightprice("SKU-1")price("SKU-1")1999store entry (expires in 60 s), clear in-flight19991999 (same load)PricingApiCachingPriceServiceRequest BRequest APricingApiCachingPriceServiceRequest BRequest Amiss, but a load is in flight: B waits on itprice("SKU-1")miss: register A's load as in flightprice("SKU-1")price("SKU-1")1999store entry (expires in 60 s), clear in-flight19991999 (same load)
      Scroll or pinch to zoom, drag to pan
      • Color key
      • Implementation
      • Facade
      CachingPriceService.javacaching_price_service.pyCachingPriceService.cscaching_price_service.gocaching-price-service.ts
      import java.util.ArrayList;
      import java.util.HashMap;
      import java.util.LinkedHashMap;
      import java.util.List;
      import java.util.Map;
      import java.util.Set;
      import java.util.concurrent.CompletableFuture;
      import java.util.concurrent.CompletionException;
      import java.util.concurrent.ConcurrentHashMap;
      import java.util.concurrent.CountDownLatch;
      import java.util.concurrent.ExecutorService;
      import java.util.concurrent.Executors;
      import java.util.concurrent.Future;
      import java.util.concurrent.atomic.AtomicInteger;
      import java.util.concurrent.atomic.AtomicLong;
      import java.util.function.LongSupplier;
      interface PriceService { int price(String sku); }
      // The slow upstream. Each call takes 100 ms; calls are counted so a stampede shows up.
      final class PricingApi implements PriceService {
      final AtomicInteger calls = new AtomicInteger();
      final Set<String> down = ConcurrentHashMap.newKeySet();
      private final Map<String, Integer> prices = Map.of("SKU-1", 1999, "SKU-2", 4999, "SKU-9", 2499);
      public int price(String sku) {
      calls.incrementAndGet();
      try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
      if (down.contains(sku)) throw new IllegalStateException("pricing api unavailable");
      return prices.get(sku);
      }
      }
      // Time is injected so TTL can be tested without waiting a minute.
      final class FakeClock implements LongSupplier {
      private final AtomicLong now = new AtomicLong();
      public long getAsLong() { return now.get(); }
      void advance(long ms) { now.addAndGet(ms); }
      }
      // The decorator. Callers still see a PriceService; the cache is invisible to them.
      final class CachingPriceService implements PriceService {
      private record Entry(int price, long expiresAt) {}
      private final PriceService inner;
      private final long ttlMs;
      private final int maxEntries;
      private final LongSupplier clock;
      // One lock guards the map, the in-flight table and the counters. It is never held
      // while the backend is called, so a slow load of one key doesn't block other keys.
      private final Object lock = new Object();
      // Access order: a read moves the entry to the end, so the first entry is the least
      // recently used one. Bounded, because an unbounded cache is a memory leak with a hit rate.
      private final LinkedHashMap<String, Entry> entries = new LinkedHashMap<>(16, 0.75f, true);
      // Single-flight: at most one backend call per key at a time. Everyone else who misses
      // while it runs waits for the same result instead of starting their own call.
      private final Map<String, CompletableFuture<Integer>> inFlight = new HashMap<>();
      private long requests, loads, failures, evictions;
      CachingPriceService(PriceService inner, long ttlMs, int maxEntries, LongSupplier clock) {
      this.inner = inner; this.ttlMs = ttlMs; this.maxEntries = maxEntries; this.clock = clock;
      }
      public int price(String sku) {
      CompletableFuture<Integer> call;
      boolean leader = false;
      synchronized (lock) {
      requests++;
      Entry e = entries.get(sku);
      if (e != null) {
      if (clock.getAsLong() < e.expiresAt()) return e.price();
      entries.remove(sku); // expired: never serve it, load again
      }
      call = inFlight.get(sku);
      if (call == null) {
      call = new CompletableFuture<>();
      inFlight.put(sku, call);
      loads++;
      leader = true;
      }
      }
      if (!leader) {
      try {
      return call.join(); // a follower: wait for the leader's answer
      } catch (CompletionException ex) {
      throw (RuntimeException) ex.getCause(); // same error the leader got
      }
      }
      int price;
      try {
      price = inner.price(sku); // outside the lock
      } catch (RuntimeException ex) {
      // Errors are not cached: the next request tries again. (Some caches keep an
      // error for a second or two so a dead backend isn't hammered; that's a choice.)
      synchronized (lock) { inFlight.remove(sku); failures++; }
      call.completeExceptionally(ex);
      throw ex;
      }
      synchronized (lock) {
      // Store, then remove from in-flight, in one step: a request that arrives now
      // sees either the in-flight call or the fresh entry, never neither.
      entries.put(sku, new Entry(price, clock.getAsLong() + ttlMs));
      inFlight.remove(sku);
      while (entries.size() > maxEntries) {
      entries.remove(entries.keySet().iterator().next());
      evictions++;
      }
      }
      call.complete(price);
      return price;
      }
      List<String> keys() { synchronized (lock) { return new ArrayList<>(entries.keySet()); } }
      String metrics() {
      synchronized (lock) {
      return "requests=" + requests + " loads=" + loads + " failures=" + failures + " evictions=" + evictions;
      }
      }
      }
      class Main {
      public static void main(String[] args) throws Exception {
      PricingApi api = new PricingApi();
      FakeClock clock = new FakeClock();
      CachingPriceService cache = new CachingPriceService(api, 60_000, 2, clock);
      PriceService prices = cache; // what callers get: the same interface as the api
      // 1. A cold key under load: 100 requests released at the same moment.
      ExecutorService pool = Executors.newFixedThreadPool(100);
      CountDownLatch go = new CountDownLatch(1);
      List<Future<Integer>> results = new ArrayList<>();
      for (int i = 0; i < 100; i++) results.add(pool.submit(() -> { go.await(); return prices.price("SKU-1"); }));
      go.countDown();
      int got1999 = 0;
      for (Future<Integer> f : results) if (f.get() == 1999) got1999++;
      pool.shutdown();
      System.out.println("100 concurrent requests, backend calls: " + api.calls.get()); // 100 concurrent requests, backend calls: 1
      System.out.println("callers that got 1999: " + got1999); // callers that got 1999: 100
      // 2. TTL: fresh for 60 s, then loaded again.
      clock.advance(30_000);
      prices.price("SKU-1");
      System.out.println("after 30 s, backend calls: " + api.calls.get()); // after 30 s, backend calls: 1
      clock.advance(31_000);
      prices.price("SKU-1");
      System.out.println("after 61 s, backend calls: " + api.calls.get()); // after 61 s, backend calls: 2
      // 3. A failure is passed on, not remembered.
      api.down.add("SKU-9");
      try {
      prices.price("SKU-9");
      } catch (IllegalStateException e) {
      System.out.println("SKU-9: " + e.getMessage()); // SKU-9: pricing api unavailable
      }
      api.down.remove("SKU-9");
      System.out.println("SKU-9 after recovery: " + prices.price("SKU-9")); // SKU-9 after recovery: 2499
      // 4. Bounded: a third key evicts the least recently used one (SKU-1).
      prices.price("SKU-2");
      System.out.println("cached: " + String.join(", ", cache.keys())); // cached: SKU-9, SKU-2
      System.out.println(cache.metrics()); // requests=105 loads=5 failures=1 evictions=1
      }
      }
      • The lock is never held during the backend call. The decorator locks to check the map, and to register the in-flight load. Then it unlocks, and calls the backend. Then it locks again to store the result. Holding one lock across a 100 ms call would be a problem. A slow load of one SKU would block every other SKU.
      • Storing and clearing the in-flight entry happen together. A request arriving at that moment sees either the pending load or the fresh entry. It never sees neither, which would start a second load.
      • Each language waits in its own way. Java shares a CompletableFuture. C# shares a TaskCompletionSource. Python uses a small object with a threading.Event. Go uses a channel that is closed when the load is done (the same idea as golang.org/x/sync/singleflight). TypeScript has one thread, but price awaits the backend, so it shares the promise.
      • Errors reach everyone, then are forgotten. All waiters get the leader’s error, and the in-flight entry is removed, so the next request retries. Some caches keep an error for a second or two (negative caching) so a dead backend isn’t hammered by retries. That’s a choice to make on purpose, with a short TTL.
      • Expired entries are never served here. A common refinement is called stale-while-revalidate. Serve the expired value to everyone, while one request refreshes it in the background. It removes the wait entirely. The price is showing a slightly old price.
      • Time is injected. The TTL test advances a fake clock by 61 seconds instead of sleeping.
      • The decorator is not the object it wraps. cache == api is false. cache instanceof PricingApi is false. Code that casts to the concrete class breaks when a decorator is added. So does code that puts both in a set keyed by identity. Code against the interface. Don’t rely on identity or equals across layers.
      • Stacks get hard to debug. A stack trace through logging, metrics, retry, cache and timeout layers is long. And a bug can sit in the composition, not in any class. Give layers clear names. Keep composition in one place. Log which layer produced an error.
      Interviewer

      We put a cache in front of the pricing service. Every time a popular product’s entry expires, the pricing service gets a spike and sometimes falls over. Design a fix.

      What they are testing

      Whether you name the cache stampede, and fix it with single-flight per key (or stale-while-revalidate). Whether you keep the lock off the slow path, avoid caching errors forever, and bound the cache. The red flag is “make the TTL longer” or “add more pricing servers”, without dealing with misses that arrive at the same time.

      Say this out loud

      The cache is a decorator around PriceService, so nothing else changes. On a miss, the first request registers an in-flight load. It calls the backend without holding the lock. Later misses for that key wait on the same load. Entries expire after a TTL. Errors aren’t cached. And the map is bounded, with LRU eviction.

      Use a decorator in three cases. Behavior must be added to objects you don’t own or can’t change. Features combine freely. Or the same concern applies to many implementations of one interface. Put the behavior in the class itself when there’s one implementation, and the feature is always on. And when the interface has twenty methods, think twice. Every decorator must forward all of them.

      Language Usual form Built in
      Java A class that implements the interface and takes it in the constructor BufferedInputStream, GZIPOutputStream, Collections.unmodifiableList, Collections.synchronizedMap
      Python A class with the same methods (duck typing), or a function that wraps a function io.BufferedReader around a raw file, gzip.GzipFile(fileobj=...), functools.lru_cache
      C# A class that implements the interface; DelegatingHandler for HTTP BufferedStream, GZipStream, CryptoStream, HttpClient handler pipelines
      Go A struct that embeds or holds the interface; func(http.Handler) http.Handler middleware bufio.NewReader(r), gzip.NewWriter(w), http.TimeoutHandler, http.StripPrefix
      TypeScript A class implementing the interface, or a higher-order function Wrapping fetch; Node’s stream pipe chains are a close cousin

      Go’s io.Reader and io.Writer are the cleanest example. They have one method each, so a decorator is a few lines, and the standard library is full of them. Java’s I/O streams are the textbook example. They’re why “decorator” is the first pattern many people meet without the name.

      Pattern Looks like Decorator because The difference
      Proxy Same structure: wraps an object with the same interface A proxy controls access to the object (lazy loading, a remote call, a permission check) and usually decides whether and when the real call happens. A decorator adds behavior and is stacked freely by the caller. A caching wrapper is often called a “caching proxy”; the names overlap here.
      Adapter Both wrap one object An adapter changes the interface so an object fits where it otherwise wouldn’t. A decorator keeps the interface and adds behavior.
      Chain of responsibility and middleware A chain of objects each passing a call on In a classic chain, each handler may handle the request and stop. Middleware pipelines (ASP.NET Core, Express, Go http.Handler wrappers, Python WSGI middleware) are usually decorators in practice: each layer wraps the next and runs code before and after it.
      Inheritance Both “extend” a class A subclass is fixed at compile time and one subclass is needed per combination. A decorator is chosen at run time and works with any implementation of the interface.
      Python’s @decorator syntax Same name, and it also wraps @ is syntax for replacing a function with whatever another function returns. It is often used to implement the Decorator pattern for functions (functools.lru_cache, a retry wrapper), but it can do anything, such as registering a route. functools.wraps copies the name and docstring and sets __wrapped__, which is the identity problem from the Hard level, solved by convention.
      Strategy Both are small objects behind an interface A strategy replaces how a step is done. A decorator adds to it and still calls the original.
      Composite Both hold children of the same interface A composite holds many children and combines their results. A decorator holds exactly one.

      On this site:

      • Logging framework: AsyncAppender wraps any appender with a bounded queue and a writer thread.
      • LRU cache: SynchronizedCache adds locking to any Cache without touching it.
      • Parking lot: pricing decorators add fees or discounts around a base pricing policy.
      • Strategy: FreeOver wraps any shipping rule, a strategy that is also a decorator.

      In real systems, you’ll find decorators in:

      • I/O streams in every major standard library.
      • HTTP client handler pipelines, and server middleware.
      • Resilience libraries such as Polly (.NET) and resilience4j (Java). They build retry, timeout and circuit breaker layers around a call.
      • Caching layers in front of slow services.
      Interviewer asksWhat's the difference between Decorator and Proxy?

      The structure is the same: a wrapper with the same interface. The intent differs. A proxy controls access to one object, often creating it lazily or standing in for a remote one, and decides whether the call happens. A decorator adds behavior and is meant to be stacked with others in any combination. In practice the names overlap, especially for caching wrappers.

      Interviewer asksWhy not just use inheritance?

      Inheritance fixes the combination at compile time and needs a subclass for every combination of features, so the class count grows exponentially. It also ties the feature to one parent class. A decorator is one class per feature, works with any implementation of the interface, and the combination is chosen at run time.

      Interviewer asksDoes the order of decorators matter?

      Yes, whenever a layer depends on what another one does. Compression before encryption works. The reverse doesn’t compress. A timeout inside a retry limits each attempt. Outside, it limits the whole call. Logging outside a retry logs requests. Inside, it logs attempts. Keep composition in one place, and write the reason for the order there.

      Interviewer asksWhat problems do decorators cause?

      The wrapper isn’t the wrapped object, so identity checks, equals and casts to the concrete class break. Long stacks make stack traces and debugging harder. Wide interfaces force every decorator to forward methods it doesn’t change. And a stateful decorator, like a cache, brings concurrency, expiry and memory concerns of its own.

      Decision We chose We gave up
      Optional features One wrapper per feature, same interface Seeing the whole behavior in one class
      Choosing features By wrapping, at run time, in one place Compile-time checks on combinations and order
      Cross-cutting concerns Logging, retry and timeout as layers The order is now a correctness decision
      Time limits A deadline passed inward with the call One more parameter on the interface
      Cold-cache bursts Single-flight: one load per key, others wait Waiters depend on the leader’s call
      Freshness TTL; errors not cached Prices up to one TTL old
      Memory A bounded LRU map with metrics Evicted keys pay a load again
      • Strategy, which replaces behavior where a decorator adds to it
      • Open-closed principle, the principle decorators put into practice
      • Chain of responsibility, for pipelines where a handler may stop the call
      • LRU cache, for the data structure behind the Hard level’s bounded map
      • Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns, the Decorator chapter