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.
- Read first
- StrategyOpen/closed
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 shape
Section titled “The shape”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.
- 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.
Worked examples
Section titled “Worked examples”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.
Without the pattern
Section titled “Without the pattern”Give each combination its own subclass:
// 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 }}# Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.def compress(s: str) -> str: # Toy compression: a run of 3 or more equal characters becomes [c*n]. out, i = [], 0 while i < len(s): c, n = s[i], 1 while i + n < len(s) and s[i + n] == c: n += 1 out.append(f"[{c}*{n}]" if n >= 3 else c * n) i += n return "".join(out)
def encrypt(s: str) -> str: # Toy encryption: shift each letter 3 places. It only stands in for a real cipher. def shift(c: str) -> str: if "A" <= c <= "Z": return chr(ord("A") + (ord(c) - ord("A") + 3) % 26) if "a" <= c <= "z": return chr(ord("a") + (ord(c) - ord("a") + 3) % 26) return c
return "".join(shift(c) for c in s)
class FileWriter: def __init__(self, path: str): self.path = path
def write(self, data: str) -> None: print(f"{self.path} <- {data}")
# Before: one subclass per combination of options.class CompressedFileWriter(FileWriter): def write(self, data: str) -> None: super().write(compress(data))
class EncryptedFileWriter(FileWriter): def write(self, data: str) -> None: super().write(encrypt(data))
# flaw 1: this class repeats the other two. A fix to either must be copied here.class CompressedEncryptedFileWriter(FileWriter): def write(self, data: str) -> None: super().write(encrypt(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.
if __name__ == "__main__": row = "ORD-7,PAID,,,,,,,,EUR,0000000495" FileWriter("orders.csv").write(row) # orders.csv <- ORD-7,PAID,,,,,,,,EUR,0000000495 CompressedEncryptedFileWriter("orders.csv.z.enc").write(row) # orders.csv.z.enc <- RUG-7,SDLG[,*8]HXU,[0*7]495using System;using System.Linq;using System.Text;
// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.public static class Codec{ // Toy compression: a run of 3 or more equal characters becomes [c*n]. public static string Compress(string s) { var output = new StringBuilder(); int i = 0; while (i < s.Length) { char c = s[i]; int n = 1; while (i + n < s.Length && s[i + n] == c) n++; output.Append(n >= 3 ? $"[{c}*{n}]" : new string(c, n)); i += n; } return output.ToString(); }
// Toy encryption: shift each letter 3 places. It only stands in for a real cipher. public static string Encrypt(string s) => new string(s.Select(c => c >= 'A' && c <= 'Z' ? (char)('A' + (c - 'A' + 3) % 26) : c >= 'a' && c <= 'z' ? (char)('a' + (c - 'a' + 3) % 26) : c).ToArray());}
public class FileWriter{ private readonly string _path; public FileWriter(string path) => _path = path; public virtual void Write(string data) => Console.WriteLine($"{_path} <- {data}");}
// Before: one subclass per combination of options.public class CompressedFileWriter : FileWriter{ public CompressedFileWriter(string path) : base(path) { } public override void Write(string data) => base.Write(Codec.Compress(data));}
public class EncryptedFileWriter : FileWriter{ public EncryptedFileWriter(string path) : base(path) { } public override void Write(string data) => base.Write(Codec.Encrypt(data));}
// flaw 1: this class repeats the other two. A fix to either must be copied here.public class CompressedEncryptedFileWriter : FileWriter{ public CompressedEncryptedFileWriter(string path) : base(path) { } public override void Write(string data) => base.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.
public static class Program{ public static void Main() { var 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 }}package main
import ( "fmt" "strings")
// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.
// compress is a toy: a run of 3 or more equal characters becomes [c*n].func compress(s string) string { var out strings.Builder for i := 0; i < len(s); { c, n := s[i], 1 for i+n < len(s) && s[i+n] == c { n++ } if n >= 3 { fmt.Fprintf(&out, "[%c*%d]", c, n) } else { out.WriteString(strings.Repeat(string(c), n)) } i += n } return out.String()}
// encrypt is a toy: shift each letter 3 places. It only stands in for a real cipher.func encrypt(s string) string { return strings.Map(func(r rune) rune { switch { case r >= 'A' && r <= 'Z': return 'A' + (r-'A'+3)%26 case r >= 'a' && r <= 'z': return 'a' + (r-'a'+3)%26 } return r }, s)}
type FileWriter struct{ Path string }
func (f FileWriter) Write(data string) { fmt.Printf("%s <- %s\n", f.Path, data) }
// Before: one type per combination of options. Go has no subclassing; embedding is the// closest thing, and it multiplies in exactly the same way.type CompressedFileWriter struct{ FileWriter }
func (c CompressedFileWriter) Write(data string) { c.FileWriter.Write(compress(data)) }
type EncryptedFileWriter struct{ FileWriter }
func (e EncryptedFileWriter) Write(data string) { e.FileWriter.Write(encrypt(data)) }
// flaw 1: this type repeats the other two. A fix to either must be copied here.type CompressedEncryptedFileWriter struct{ FileWriter }
func (c CompressedEncryptedFileWriter) Write(data string) { c.FileWriter.Write(encrypt(compress(data)))}
// flaw 2: uploads to a bucket need the same three types around BucketWriter.// flaw 3: one more option (say, counting bytes) doubles everything: 2 options make// 4 types per destination, 3 options make 8, and n options make 2^n.
func main() { row := "ORD-7,PAID,,,,,,,,EUR,0000000495" FileWriter{"orders.csv"}.Write(row) // orders.csv <- ORD-7,PAID,,,,,,,,EUR,0000000495 CompressedEncryptedFileWriter{FileWriter{"orders.csv.z.enc"}}.Write(row) // orders.csv.z.enc <- RUG-7,SDLG[,*8]HXU,[0*7]495}// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.
// Toy compression: a run of 3 or more equal characters becomes [c*n].function compress(s: string): string { let out = ''; let i = 0; while (i < s.length) { const c = s[i]; let n = 1; while (i + n < s.length && s[i + n] === c) n++; out += n >= 3 ? `[${c}*${n}]` : c.repeat(n); i += n; } return out;}
// Toy encryption: shift each letter 3 places. It only stands in for a real cipher.function encrypt(s: string): string { return s.replace(/[A-Za-z]/g, (c) => { const base = c <= 'Z' ? 65 : 97; return String.fromCharCode(base + ((c.charCodeAt(0) - base + 3) % 26)); });}
class FileWriter { constructor(private readonly path: string) {} write(data: string): void { console.log(`${this.path} <- ${data}`); }}
// Before: one subclass per combination of options.class CompressedFileWriter extends FileWriter { override write(data: string): void { super.write(compress(data)); }}
class EncryptedFileWriter extends FileWriter { override write(data: string): void { super.write(encrypt(data)); }}
// flaw 1: this class repeats the other two. A fix to either must be copied here.class CompressedEncryptedFileWriter extends FileWriter { override write(data: string): void { super.write(encrypt(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.
const row = 'ORD-7,PAID,,,,,,,,EUR,0000000495';new FileWriter('orders.csv').write(row); // orders.csv <- ORD-7,PAID,,,,,,,,EUR,0000000495new CompressedEncryptedFileWriter('orders.csv.z.enc').write(row); // orders.csv.z.enc <- RUG-7,SDLG[,*8]HXU,[0*7]495Every 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.
With Decorator
Section titled “With Decorator”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:
- 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.
- row
- CompressWriter
- EncryptWriter
- CountingWriter
- orders.csv gets
Classes for every combination of3options, for 2 destinations (file and bucket)
// 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 }}from typing import Protocol
# Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.def compress(s: str) -> str: # Toy compression: a run of 3 or more equal characters becomes [c*n]. out, i = [], 0 while i < len(s): c, n = s[i], 1 while i + n < len(s) and s[i + n] == c: n += 1 out.append(f"[{c}*{n}]" if n >= 3 else c * n) i += n return "".join(out)
def encrypt(s: str) -> str: # Toy encryption: shift each letter 3 places. It only stands in for a real cipher. def shift(c: str) -> str: if "A" <= c <= "Z": return chr(ord("A") + (ord(c) - ord("A") + 3) % 26) if "a" <= c <= "z": return chr(ord("a") + (ord(c) - ord("a") + 3) % 26) return c
return "".join(shift(c) for c in s)
# The one interface. The export job, every destination and every wrapper speak it.class Writer(Protocol): def write(self, data: str) -> None: ...
# Concrete component: where the data finally goes. It knows nothing about options.class FileWriter: def __init__(self, path: str): self.path = path
def write(self, data: str) -> None: print(f"{self.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: def __init__(self, inner: Writer): self.inner = inner
def write(self, data: str) -> None: self.inner.write(compress(data))
class EncryptWriter: def __init__(self, inner: Writer): self.inner = inner
def write(self, data: str) -> None: self.inner.write(encrypt(data))
# A decorator doesn't have to change the data. This one only observes it.class CountingWriter: def __init__(self, inner: Writer): self.inner = inner self.count = 0
def write(self, data: str) -> None: self.count += len(data) self.inner.write(data)
if __name__ == "__main__": row = "ORD-7,PAID,,,,,,,,EUR,0000000495"
# No options: the bare destination. plain: Writer = 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. secure: Writer = CompressWriter(EncryptWriter(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. on_disk = CountingWriter(FileWriter("orders.csv.z")) counted: Writer = CompressWriter(on_disk) counted.write(row) # orders.csv.z <- ORD-7,PAID[,*8]EUR,[0*7]495 print(f"{len(row)} chars in, {on_disk.count} on disk") # 32 chars in, 27 on diskusing System;using System.Linq;using System.Text;
// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.public static class Codec{ // Toy compression: a run of 3 or more equal characters becomes [c*n]. public static string Compress(string s) { var output = new StringBuilder(); int i = 0; while (i < s.Length) { char c = s[i]; int n = 1; while (i + n < s.Length && s[i + n] == c) n++; output.Append(n >= 3 ? $"[{c}*{n}]" : new string(c, n)); i += n; } return output.ToString(); }
// Toy encryption: shift each letter 3 places. It only stands in for a real cipher. public static string Encrypt(string s) => new string(s.Select(c => c >= 'A' && c <= 'Z' ? (char)('A' + (c - 'A' + 3) % 26) : c >= 'a' && c <= 'z' ? (char)('a' + (c - 'a' + 3) % 26) : c).ToArray());}
// The one interface. The export job, every destination and every wrapper speak it.public interface IWriter{ void Write(string data);}
// Concrete component: where the data finally goes. It knows nothing about options.public sealed class FileWriter : IWriter{ private readonly string _path; public FileWriter(string path) => _path = path; public void Write(string data) => Console.WriteLine($"{_path} <- {data}");}
// Each decorator IS an IWriter (so it can stand in anywhere an IWriter is expected) and HAS// an IWriter (the next one in the chain). It adds one thing, then delegates.public sealed class CompressWriter : IWriter{ private readonly IWriter _inner; public CompressWriter(IWriter inner) => _inner = inner; public void Write(string data) => _inner.Write(Codec.Compress(data));}
public sealed class EncryptWriter : IWriter{ private readonly IWriter _inner; public EncryptWriter(IWriter inner) => _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.public sealed class CountingWriter : IWriter{ private readonly IWriter _inner; public int Count { get; private set; } public CountingWriter(IWriter inner) => _inner = inner; public void Write(string data) { Count += data.Length; _inner.Write(data); }}
public static class Program{ public static void Main() { var row = "ORD-7,PAID,,,,,,,,EUR,0000000495";
// No options: the bare destination. IWriter 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. IWriter 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. var onDisk = new CountingWriter(new FileWriter("orders.csv.z")); IWriter counted = new CompressWriter(onDisk); counted.Write(row); // orders.csv.z <- ORD-7,PAID[,*8]EUR,[0*7]495 Console.WriteLine($"{row.Length} chars in, {onDisk.Count} on disk"); // 32 chars in, 27 on disk }}package main
import ( "fmt" "strings")
// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.
// compress is a toy: a run of 3 or more equal characters becomes [c*n].func compress(s string) string { var out strings.Builder for i := 0; i < len(s); { c, n := s[i], 1 for i+n < len(s) && s[i+n] == c { n++ } if n >= 3 { fmt.Fprintf(&out, "[%c*%d]", c, n) } else { out.WriteString(strings.Repeat(string(c), n)) } i += n } return out.String()}
// encrypt is a toy: shift each letter 3 places. It only stands in for a real cipher.func encrypt(s string) string { return strings.Map(func(r rune) rune { switch { case r >= 'A' && r <= 'Z': return 'A' + (r-'A'+3)%26 case r >= 'a' && r <= 'z': return 'a' + (r-'a'+3)%26 } return r }, s)}
// Writer is the one interface. The export job, every destination and every wrapper speak it.type Writer interface { Write(data string)}
// FileWriter is where the data finally goes. It knows nothing about options.type FileWriter struct{ Path string }
func (f FileWriter) Write(data string) { fmt.Printf("%s <- %s\n", f.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.type CompressWriter struct{ Inner Writer }
func (c CompressWriter) Write(data string) { c.Inner.Write(compress(data)) }
type EncryptWriter struct{ Inner Writer }
func (e EncryptWriter) Write(data string) { e.Inner.Write(encrypt(data)) }
// CountingWriter doesn't change the data. It only observes it.type CountingWriter struct { Inner Writer Count int}
func (c *CountingWriter) Write(data string) { c.Count += len(data) c.Inner.Write(data)}
func main() { row := "ORD-7,PAID,,,,,,,,EUR,0000000495"
// No options: the bare destination. var plain Writer = 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. var secure Writer = CompressWriter{EncryptWriter{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 type, and it combines with all the others for free. onDisk := &CountingWriter{Inner: FileWriter{"orders.csv.z"}} var counted Writer = CompressWriter{onDisk} counted.Write(row) // orders.csv.z <- ORD-7,PAID[,*8]EUR,[0*7]495 fmt.Printf("%d chars in, %d on disk\n", len(row), onDisk.Count) // 32 chars in, 27 on disk}// Toy codecs so the output stays readable. Real code uses gzip and AES on bytes.
// Toy compression: a run of 3 or more equal characters becomes [c*n].function compress(s: string): string { let out = ''; let i = 0; while (i < s.length) { const c = s[i]; let n = 1; while (i + n < s.length && s[i + n] === c) n++; out += n >= 3 ? `[${c}*${n}]` : c.repeat(n); i += n; } return out;}
// Toy encryption: shift each letter 3 places. It only stands in for a real cipher.function encrypt(s: string): string { return s.replace(/[A-Za-z]/g, (c) => { const base = c <= 'Z' ? 65 : 97; return String.fromCharCode(base + ((c.charCodeAt(0) - base + 3) % 26)); });}
// The one interface. The export job, every destination and every wrapper speak it.interface Writer { write(data: string): void;}
// Concrete component: where the data finally goes. It knows nothing about options.class FileWriter implements Writer { constructor(private readonly path: string) {} write(data: string): void { console.log(`${this.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 { constructor(private readonly inner: Writer) {} write(data: string): void { this.inner.write(compress(data)); }}
class EncryptWriter implements Writer { constructor(private readonly inner: Writer) {} write(data: string): void { this.inner.write(encrypt(data)); }}
// A decorator doesn't have to change the data. This one only observes it.class CountingWriter implements Writer { count = 0; constructor(private readonly inner: Writer) {} write(data: string): void { this.count += data.length; this.inner.write(data); }}
const row = 'ORD-7,PAID,,,,,,,,EUR,0000000495';
// No options: the bare destination.const plain: Writer = 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.const secure: Writer = 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.const onDisk = new CountingWriter(new FileWriter('orders.csv.z'));const counted: Writer = new CompressWriter(onDisk);counted.write(row); // orders.csv.z <- ORD-7,PAID[,*8]EUR,[0*7]495console.log(`${row.length} chars in, ${onDisk.count} on disk`); // 32 chars in, 27 on diskWhat to notice
Section titled “What to notice”- 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.
ExportJobwrites to aWriter. 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.
CountingWriteronly 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’sGZIPOutputStreamandCipherOutputStream. - Where you wrap decides what you see.
CountingWritersits insideCompressWriter. So it counts the 27 characters that reach the disk, not the 32 that went in.
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?
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.
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.
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.
Without the pattern
Section titled “Without the pattern”Put everything into the client:
// 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 }}from dataclasses import dataclass
# A fake clock in milliseconds, so timing is deterministic: nothing here really sleeps.class Clock: def __init__(self) -> None: self.now = 0
def advance(self, ms: int) -> None: self.now += ms
class Timeout(Exception): pass
@dataclass(frozen=True)class Step: latency_ms: int status: int
# The flaky upstream: slow on the first attempt, 503 on the second, fine on the third.class FlakyServer: def __init__(self, clock: Clock): self.clock = clock self.script = [Step(800, 200), Step(50, 503), Step(120, 200)] self.calls = 0
def send(self, path: str, deadline: int) -> int: step = self.script[min(self.calls, len(self.script) - 1)] self.calls += 1 if self.clock.now + step.latency_ms > deadline: self.clock.now = deadline raise Timeout() self.clock.advance(step.latency_ms) 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.class RatesClient: def __init__(self, server: FlakyServer, clock: Clock, logging: bool, max_attempts: int, timeout_ms: int): self.server, self.clock, self.logging = server, clock, logging self.max_attempts, self.timeout_ms = max_attempts, timeout_ms
def get(self, path: str) -> int: start = self.clock.now backoff_ms = 100 attempt = 0 # 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. while True: attempt += 1 try: status = self.server.send(path, self.clock.now + self.timeout_ms) if status < 500: if self.logging: print(f"log: GET {path} -> {status} in {self.clock.now - start} ms") return status why = f"{status} at t={self.clock.now}" except Timeout: why = f"timeout at t={self.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 == self.max_attempts: if self.logging: print(f"log: GET {path} -> failed in {self.clock.now - start} ms") raise Timeout() print(f"retry: attempt {attempt} failed ({why}), waiting {backoff_ms} ms") self.clock.advance(backoff_ms) backoff_ms *= 2
if __name__ == "__main__": clock = Clock() server = 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. rates = RatesClient(server, clock, logging=True, max_attempts=3, timeout_ms=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 msusing System;
// A fake clock in milliseconds, so timing is deterministic: nothing here really sleeps.public sealed class Clock{ public long Now { get; set; } public void Advance(long ms) => Now += ms;}
public sealed class RequestTimeoutException : Exception{ public RequestTimeoutException() : base("timeout") { }}
public readonly record struct Step(long LatencyMs, int Status);
// The flaky upstream: slow on the first attempt, 503 on the second, fine on the third.public sealed class FlakyServer{ private readonly Clock _clock; private readonly Step[] _script = { new(800, 200), new(50, 503), new(120, 200) }; public int Calls { get; private set; } public FlakyServer(Clock clock) => _clock = clock;
public int Send(string path, long deadline) { var step = _script[Math.Min(Calls, _script.Length - 1)]; Calls++; if (_clock.Now + step.LatencyMs > deadline) { _clock.Now = deadline; throw new RequestTimeoutException(); } _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.public sealed class RatesClient{ private readonly FlakyServer _server; private readonly Clock _clock; private readonly bool _logging; private readonly int _maxAttempts; private readonly long _timeoutMs;
public RatesClient(FlakyServer server, Clock clock, bool logging, int maxAttempts, long timeoutMs) { _server = server; _clock = clock; _logging = logging; _maxAttempts = maxAttempts; _timeoutMs = timeoutMs; }
public 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) Console.WriteLine($"log: GET {path} -> {status} in {_clock.Now - start} ms"); return status; } why = $"{status} at t={_clock.Now}"; } catch (RequestTimeoutException) { 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) Console.WriteLine($"log: GET {path} -> failed in {_clock.Now - start} ms"); throw new RequestTimeoutException(); } Console.WriteLine($"retry: attempt {attempt} failed ({why}), waiting {backoffMs} ms"); _clock.Advance(backoffMs); backoffMs *= 2; } }}
public static class Program{ public static void Main() { var clock = new Clock(); var 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. var rates = new RatesClient(server, clock, logging: true, maxAttempts: 3, timeoutMs: 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 }}package main
import ( "errors" "fmt")
// Clock is a fake clock in milliseconds, so timing is deterministic: nothing really sleeps.type Clock struct{ Now int64 }
func (c *Clock) Advance(ms int64) { c.Now += ms }
var ErrTimeout = errors.New("timeout")
type step struct { latencyMs int64 status int}
// FlakyServer is slow on the first attempt, 503 on the second, fine on the third.type FlakyServer struct { clock *Clock script []step Calls int}
func (s *FlakyServer) Send(path string, deadline int64) (int, error) { st := s.script[min(s.Calls, len(s.script)-1)] s.Calls++ if s.clock.Now+st.latencyMs > deadline { s.clock.Now = deadline return 0, ErrTimeout } s.clock.Advance(st.latencyMs) return st.status, nil}
// RatesClient is the before: one client does the call, the timeout, the retries and the// logging, all in one method, switched on and off by flags.type RatesClient struct { server *FlakyServer clock *Clock logging bool maxAttempts int timeoutMs int64}
func (r *RatesClient) Get(path string) (int, error) { start := r.clock.Now backoffMs := int64(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 attempt := 1; ; attempt++ { status, err := r.server.Send(path, r.clock.Now+r.timeoutMs) if err == nil && status < 500 { if r.logging { fmt.Printf("log: GET %s -> %d in %d ms\n", path, status, r.clock.Now-start) } return status, nil } why := fmt.Sprintf("%d at t=%d", status, r.clock.Now) if err != nil { why = fmt.Sprintf("timeout at t=%d", r.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 == r.maxAttempts { if r.logging { fmt.Printf("log: GET %s -> failed in %d ms\n", path, r.clock.Now-start) } return 0, ErrTimeout } fmt.Printf("retry: attempt %d failed (%s), waiting %d ms\n", attempt, why, backoffMs) r.clock.Advance(backoffMs) backoffMs *= 2 }}
func main() { clock := &Clock{} server := &FlakyServer{clock: clock, script: []step{{800, 200}, {50, 503}, {120, 200}}} // flaw 3: a test that wants no retries and no logging still goes through this code, // steered by flags instead of simply not wrapping. rates := &RatesClient{server: server, clock: clock, logging: true, maxAttempts: 3, timeoutMs: 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}// A fake clock in milliseconds, so timing is deterministic: nothing here really sleeps.class Clock { now = 0; advance(ms: number): void { this.now += ms; }}
class TimeoutError extends Error { constructor() { super('timeout'); }}
type Step = { latencyMs: number; status: number };
// The flaky upstream: slow on the first attempt, 503 on the second, fine on the third.class FlakyServer { calls = 0; private readonly script: Step[] = [ { latencyMs: 800, status: 200 }, { latencyMs: 50, status: 503 }, { latencyMs: 120, status: 200 }, ]; constructor(private readonly clock: Clock) {}
send(path: string, deadline: number): number { const step = this.script[Math.min(this.calls, this.script.length - 1)]; this.calls++; if (this.clock.now + step.latencyMs > deadline) { this.clock.now = deadline; throw new TimeoutError(); } this.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.class RatesClient { constructor( private readonly server: FlakyServer, private readonly clock: Clock, private readonly logging: boolean, private readonly maxAttempts: number, private readonly timeoutMs: number, ) {}
get(path: string): number { const start = this.clock.now; let 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 (let attempt = 1; ; attempt++) { let why: string; try { const status = this.server.send(path, this.clock.now + this.timeoutMs); if (status < 500) { if (this.logging) console.log(`log: GET ${path} -> ${status} in ${this.clock.now - start} ms`); return status; } why = `${status} at t=${this.clock.now}`; } catch (e) { if (!(e instanceof TimeoutError)) throw e; why = `timeout at t=${this.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 === this.maxAttempts) { if (this.logging) console.log(`log: GET ${path} -> failed in ${this.clock.now - start} ms`); throw new TimeoutError(); } console.log(`retry: attempt ${attempt} failed (${why}), waiting ${backoffMs} ms`); this.clock.advance(backoffMs); backoffMs *= 2; } }}
const clock = new Clock();const 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.const 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 msThe 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.
With Decorator
Section titled “With Decorator”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:
- 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
- LoggingClientone log line per call through it
- RetryClientup to 3 attempts, backoff 100, 200 ms
- TimeoutClient300 ms deadline for what is inside
- FlakyServer800 ms, then 503, then 200
What this order means
Timeline (fake clock, ms)
Reorder the layers, then send the request.
// 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) }}import mathfrom dataclasses import dataclassfrom typing import Protocol
NO_DEADLINE = math.inf
# A fake clock in milliseconds, shared by the server and every layer. It makes timing# deterministic: nothing here really sleeps.class Clock: def __init__(self) -> None: self.now = 0
def advance(self, ms: int) -> None: self.now += ms
class Timeout(Exception): def __init__(self) -> None: super().__init__("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.class HttpClient(Protocol): def send(self, path: str, deadline: float) -> int: ...
@dataclass(frozen=True)class Step: latency_ms: int status: int
# 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.class FlakyServer: def __init__(self, clock: Clock): self.clock = clock self.script = [Step(800, 200), Step(50, 503), Step(120, 200)] self.calls = 0
def send(self, path: str, deadline: float) -> int: step = self.script[min(self.calls, len(self.script) - 1)] self.calls += 1 if self.clock.now + step.latency_ms > deadline: self.clock.now = int(deadline) raise Timeout() self.clock.advance(step.latency_ms) 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.class TimeoutClient: def __init__(self, inner: HttpClient, limit_ms: int, clock: Clock): self.inner, self.limit_ms, self.clock = inner, limit_ms, clock
def send(self, path: str, deadline: float) -> int: return self.inner.send(path, min(deadline, self.clock.now + self.limit_ms))
# 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.class RetryClient: def __init__(self, inner: HttpClient, max_attempts: int, clock: Clock): self.inner, self.max_attempts, self.clock = inner, max_attempts, clock
def send(self, path: str, deadline: float) -> int: backoff_ms = 100 attempt = 0 while True: attempt += 1 status, error = 0, None try: status = self.inner.send(path, deadline) if status < 500: return status except Timeout as e: error = e why = f"{'timeout' if error else status} at t={self.clock.now}" stop = ( "no attempts left" if attempt == self.max_attempts else "no time left" if self.clock.now + backoff_ms >= deadline else None ) if stop: print(f"retry: attempt {attempt} failed ({why}), {stop}") if error: raise error return status print(f"retry: attempt {attempt} failed ({why}), waiting {backoff_ms} ms") self.clock.advance(backoff_ms) backoff_ms *= 2
# Logging: one line per call that passes through it. Where it sits decides what a "call" is.class LoggingClient: def __init__(self, inner: HttpClient, clock: Clock): self.inner, self.clock = inner, clock
def send(self, path: str, deadline: float) -> int: start = self.clock.now try: status = self.inner.send(path, deadline) except Timeout: print(f"log: GET {path} -> timeout in {self.clock.now - start} ms") raise print(f"log: GET {path} -> {status} in {self.clock.now - start} ms") return status
def call(client: HttpClient, server: FlakyServer) -> None: try: result = str(client.send("/rates", NO_DEADLINE)) except Timeout: result = "timeout" print(f"caller got {result} (server calls: {server.calls})")
if __name__ == "__main__": # Same three layers, three orders. Only the wrapping changes. print("== logging > retry > timeout: each attempt gets 300 ms ==") c1 = Clock() s1 = FlakyServer(c1) call(LoggingClient(RetryClient(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)
print("== logging > timeout > retry: 300 ms for everything ==") c2 = Clock() s2 = FlakyServer(c2) call(LoggingClient(TimeoutClient(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)
print("== retry > logging > timeout: one log line per attempt ==") c3 = Clock() s3 = FlakyServer(c3) call(RetryClient(LoggingClient(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)using System;
// A fake clock in milliseconds, shared by the server and every layer. It makes timing// deterministic: nothing here really sleeps.public sealed class Clock{ public long Now { get; set; } public void Advance(long ms) => Now += ms;}
public sealed class RequestTimeoutException : Exception{ public RequestTimeoutException() : base("timeout") { }}
// The one interface for the real client and every layer around it. The deadline travels// with the call, the way a CancellationToken does, so an outer layer can limit everything// inside it. (Real code is async and takes a token; the fake clock keeps this synchronous.)public interface IHttpClient{ int Send(string path, long deadline);}
public readonly record struct 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.public sealed class FlakyServer : IHttpClient{ private readonly Clock _clock; private readonly Step[] _script = { new(800, 200), new(50, 503), new(120, 200) }; public int Calls { get; private set; } public FlakyServer(Clock clock) => _clock = clock;
public int Send(string path, long deadline) { var step = _script[Math.Min(Calls, _script.Length - 1)]; Calls++; if (_clock.Now + step.LatencyMs > deadline) { _clock.Now = deadline; throw new RequestTimeoutException(); } _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.public sealed class TimeoutClient : IHttpClient{ private readonly IHttpClient _inner; private readonly long _limitMs; private readonly Clock _clock; public TimeoutClient(IHttpClient inner, long limitMs, Clock clock) { _inner = inner; _limitMs = limitMs; _clock = clock; }
public int Send(string path, long deadline) => _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.public sealed class RetryClient : IHttpClient{ private readonly IHttpClient _inner; private readonly int _maxAttempts; private readonly Clock _clock; public RetryClient(IHttpClient inner, int maxAttempts, Clock clock) { _inner = inner; _maxAttempts = maxAttempts; _clock = clock; }
public int Send(string path, long deadline) { long backoffMs = 100; for (int attempt = 1; ; attempt++) { int status = 0; RequestTimeoutException? error = null; try { status = _inner.Send(path, deadline); if (status < 500) return status; } catch (RequestTimeoutException e) { error = e; } var why = $"{(error != null ? "timeout" : status.ToString())} at t={_clock.Now}"; string? stop = attempt == _maxAttempts ? "no attempts left" : _clock.Now + backoffMs >= deadline ? "no time left" : null; if (stop != null) { Console.WriteLine($"retry: attempt {attempt} failed ({why}), {stop}"); if (error != null) throw error; return status; } Console.WriteLine($"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.public sealed class LoggingClient : IHttpClient{ private readonly IHttpClient _inner; private readonly Clock _clock; public LoggingClient(IHttpClient inner, Clock clock) { _inner = inner; _clock = clock; }
public int Send(string path, long deadline) { long start = _clock.Now; try { int status = _inner.Send(path, deadline); Console.WriteLine($"log: GET {path} -> {status} in {_clock.Now - start} ms"); return status; } catch (RequestTimeoutException) { Console.WriteLine($"log: GET {path} -> timeout in {_clock.Now - start} ms"); throw; } }}
public static class Program{ private const long NoDeadline = long.MaxValue;
private static void Call(IHttpClient client, FlakyServer server) { string result; try { result = client.Send("/rates", NoDeadline).ToString(); } catch (RequestTimeoutException) { result = "timeout"; } Console.WriteLine($"caller got {result} (server calls: {server.Calls})"); }
public static void Main() { // Same three layers, three orders. Only the wrapping changes. Console.WriteLine("== logging > retry > timeout: each attempt gets 300 ms =="); var c1 = new Clock(); var 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)
Console.WriteLine("== logging > timeout > retry: 300 ms for everything =="); var c2 = new Clock(); var 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)
Console.WriteLine("== retry > logging > timeout: one log line per attempt =="); var c3 = new Clock(); var 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) }}package main
import ( "errors" "fmt" "math")
// NoDeadline means the caller set no limit.const NoDeadline = math.MaxInt64
// Clock is a fake clock in milliseconds, shared by the server and every layer. It makes// timing deterministic: nothing here really sleeps.type Clock struct{ Now int64 }
func (c *Clock) Advance(ms int64) { c.Now += ms }
var ErrTimeout = errors.New("timeout")
// HttpClient is the one interface for the real client and every layer around it. The// deadline travels with the call, the way a context.Context does, so an outer layer can// limit everything inside it. (Real Go code would pass a context.)type HttpClient interface { Send(path string, deadline int64) (int, error)}
type step struct { latencyMs int64 status int}
// FlakyServer is the 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.type FlakyServer struct { clock *Clock script []step Calls int}
func NewFlakyServer(clock *Clock) *FlakyServer { return &FlakyServer{clock: clock, script: []step{{800, 200}, {50, 503}, {120, 200}}}}
func (s *FlakyServer) Send(path string, deadline int64) (int, error) { st := s.script[min(s.Calls, len(s.script)-1)] s.Calls++ if s.clock.Now+st.latencyMs > deadline { s.clock.Now = deadline return 0, ErrTimeout } s.clock.Advance(st.latencyMs) return st.status, nil}
// TimeoutClient shortens the deadline for everything inside it. It never extends one set// further out, so an outer, tighter deadline still wins.type TimeoutClient struct { Inner HttpClient LimitMs int64 Clock *Clock}
func (t TimeoutClient) Send(path string, deadline int64) (int, error) { return t.Inner.Send(path, min(deadline, t.Clock.Now+t.LimitMs))}
// RetryClient retries timeouts and 5xx with exponential backoff, and stops early when the// deadline it was given leaves no time for another wait: retrying past it is wasted work.type RetryClient struct { Inner HttpClient MaxAttempts int Clock *Clock}
func (r RetryClient) Send(path string, deadline int64) (int, error) { backoffMs := int64(100) for attempt := 1; ; attempt++ { status, err := r.Inner.Send(path, deadline) if err == nil && status < 500 { return status, nil } why := fmt.Sprint(status) if err != nil { why = "timeout" } why += fmt.Sprintf(" at t=%d", r.Clock.Now) stop := "" if attempt == r.MaxAttempts { stop = "no attempts left" } else if r.Clock.Now+backoffMs >= deadline { stop = "no time left" } if stop != "" { fmt.Printf("retry: attempt %d failed (%s), %s\n", attempt, why, stop) return status, err } fmt.Printf("retry: attempt %d failed (%s), waiting %d ms\n", attempt, why, backoffMs) r.Clock.Advance(backoffMs) backoffMs *= 2 }}
// LoggingClient writes one line per call that passes through it. Where it sits decides// what a "call" is.type LoggingClient struct { Inner HttpClient Clock *Clock}
func (l LoggingClient) Send(path string, deadline int64) (int, error) { start := l.Clock.Now status, err := l.Inner.Send(path, deadline) if err != nil { fmt.Printf("log: GET %s -> %v in %d ms\n", path, err, l.Clock.Now-start) return status, err } fmt.Printf("log: GET %s -> %d in %d ms\n", path, status, l.Clock.Now-start) return status, nil}
func call(client HttpClient, server *FlakyServer) { result := "" if status, err := client.Send("/rates", NoDeadline); err != nil { result = err.Error() } else { result = fmt.Sprint(status) } fmt.Printf("caller got %s (server calls: %d)\n", result, server.Calls)}
func main() { // Same three layers, three orders. Only the wrapping changes. fmt.Println("== logging > retry > timeout: each attempt gets 300 ms ==") c1 := &Clock{} s1 := NewFlakyServer(c1) call(LoggingClient{RetryClient{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)
fmt.Println("== logging > timeout > retry: 300 ms for everything ==") c2 := &Clock{} s2 := NewFlakyServer(c2) call(LoggingClient{TimeoutClient{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)
fmt.Println("== retry > logging > timeout: one log line per attempt ==") c3 := &Clock{} s3 := NewFlakyServer(c3) call(RetryClient{LoggingClient{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)}const NO_DEADLINE = Infinity;
// A fake clock in milliseconds, shared by the server and every layer. It makes timing// deterministic: nothing here really sleeps.class Clock { now = 0; advance(ms: number): void { this.now += ms; }}
class TimeoutError extends Error { constructor() { super('timeout'); }}
// The one interface for the real client and every layer around it. The deadline travels// with the call, the way an AbortSignal or Go's context does, so an outer layer can limit// everything inside it. (Real code is async; the fake clock keeps this one synchronous.)interface HttpClient { send(path: string, deadline: number): number;}
type Step = { latencyMs: number; status: number };
// 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.class FlakyServer implements HttpClient { calls = 0; private readonly script: Step[] = [ { latencyMs: 800, status: 200 }, { latencyMs: 50, status: 503 }, { latencyMs: 120, status: 200 }, ]; constructor(private readonly clock: Clock) {}
send(path: string, deadline: number): number { const step = this.script[Math.min(this.calls, this.script.length - 1)]; this.calls++; if (this.clock.now + step.latencyMs > deadline) { this.clock.now = deadline; throw new TimeoutError(); } this.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.class TimeoutClient implements HttpClient { constructor( private readonly inner: HttpClient, private readonly limitMs: number, private readonly clock: Clock, ) {}
send(path: string, deadline: number): number { return this.inner.send(path, Math.min(deadline, this.clock.now + this.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.class RetryClient implements HttpClient { constructor( private readonly inner: HttpClient, private readonly maxAttempts: number, private readonly clock: Clock, ) {}
send(path: string, deadline: number): number { let backoffMs = 100; for (let attempt = 1; ; attempt++) { let status = 0; let error: TimeoutError | undefined; try { status = this.inner.send(path, deadline); if (status < 500) return status; } catch (e) { if (!(e instanceof TimeoutError)) throw e; error = e; } const why = `${error ? 'timeout' : status} at t=${this.clock.now}`; const stop = attempt === this.maxAttempts ? 'no attempts left' : this.clock.now + backoffMs >= deadline ? 'no time left' : undefined; if (stop) { console.log(`retry: attempt ${attempt} failed (${why}), ${stop}`); if (error) throw error; return status; } console.log(`retry: attempt ${attempt} failed (${why}), waiting ${backoffMs} ms`); this.clock.advance(backoffMs); backoffMs *= 2; } }}
// Logging: one line per call that passes through it. Where it sits decides what a "call" is.class LoggingClient implements HttpClient { constructor( private readonly inner: HttpClient, private readonly clock: Clock, ) {}
send(path: string, deadline: number): number { const start = this.clock.now; try { const status = this.inner.send(path, deadline); console.log(`log: GET ${path} -> ${status} in ${this.clock.now - start} ms`); return status; } catch (e) { console.log(`log: GET ${path} -> ${(e as Error).message} in ${this.clock.now - start} ms`); throw e; } }}
function call(client: HttpClient, server: FlakyServer): void { let result: string; try { result = String(client.send('/rates', NO_DEADLINE)); } catch (e) { if (!(e instanceof TimeoutError)) throw e; result = 'timeout'; } console.log(`caller got ${result} (server calls: ${server.calls})`);}
// Same three layers, three orders. Only the wrapping changes.console.log('== logging > retry > timeout: each attempt gets 300 ms ==');const c1 = new Clock();const 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)
console.log('== logging > timeout > retry: 300 ms for everything ==');const c2 = new Clock();const 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)
console.log('== retry > logging > timeout: one log line per attempt ==');const c3 = new Clock();const 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)What to notice
Section titled “What to notice”- 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.
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?
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.
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.
The stampede
Section titled “The stampede”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.
With Decorator
Section titled “With Decorator”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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- Color key
- Implementation
- Facade
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 }}import threadingimport timefrom collections import OrderedDictfrom typing import Callable, Protocol
class PriceService(Protocol): def price(self, sku: str) -> int: ...
class Unavailable(Exception): pass
# The slow upstream. Each call takes 100 ms; calls are counted so a stampede shows up.class PricingApi: def __init__(self) -> None: self.calls = 0 self.down: set[str] = set() self._prices = {"SKU-1": 1999, "SKU-2": 4999, "SKU-9": 2499} self._lock = threading.Lock()
def price(self, sku: str) -> int: with self._lock: self.calls += 1 time.sleep(0.1) if sku in self.down: raise Unavailable("pricing api unavailable") return self._prices[sku]
# Time is injected so TTL can be tested without waiting a minute.class FakeClock: def __init__(self) -> None: self.now = 0
def __call__(self) -> int: return self.now
def advance(self, ms: int) -> None: self.now += ms
# One load in progress. Followers wait on the event, then read the result or the error.class _Call: def __init__(self) -> None: self.done = threading.Event() self.price = 0 self.error: Exception | None = None
# The decorator. Callers still see a PriceService; the cache is invisible to them.class CachingPriceService: def __init__(self, inner: PriceService, ttl_ms: int, max_entries: int, clock: Callable[[], int]): self._inner, self._ttl_ms, self._max, self._clock = inner, ttl_ms, max_entries, 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. self._lock = threading.Lock() # sku -> (price, expires_at), oldest use first. Bounded, because an unbounded cache # is a memory leak with a hit rate. self._entries: OrderedDict[str, tuple[int, int]] = OrderedDict() # 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. self._in_flight: dict[str, _Call] = {} self.requests = self.loads = self.failures = self.evictions = 0
def price(self, sku: str) -> int: with self._lock: self.requests += 1 entry = self._entries.get(sku) if entry is not None: price, expires_at = entry if self._clock() < expires_at: self._entries.move_to_end(sku) # now the most recently used return price del self._entries[sku] # expired: never serve it, load again call = self._in_flight.get(sku) leader = call is None if call is None: call = _Call() self._in_flight[sku] = call self.loads += 1 if not leader: call.done.wait() # a follower: wait for the leader's answer if call.error is not None: raise call.error # same error the leader got return call.price try: price = self._inner.price(sku) # outside the lock except Exception as e: # 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.) with self._lock: del self._in_flight[sku] self.failures += 1 call.error = e call.done.set() raise with self._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. self._entries[sku] = (price, self._clock() + self._ttl_ms) del self._in_flight[sku] while len(self._entries) > self._max: self._entries.popitem(last=False) self.evictions += 1 call.price = price call.done.set() return price
def keys(self) -> list[str]: with self._lock: return list(self._entries)
def metrics(self) -> str: with self._lock: return f"requests={self.requests} loads={self.loads} failures={self.failures} evictions={self.evictions}"
if __name__ == "__main__": api = PricingApi() clock = FakeClock() cache = CachingPriceService(api, ttl_ms=60_000, max_entries=2, clock=clock) prices: PriceService = cache # what callers get: the same interface as the api
# 1. A cold key under load: 100 requests released at the same moment. go = threading.Event() results: list[int] = [] results_lock = threading.Lock()
def request() -> None: go.wait() p = prices.price("SKU-1") with results_lock: results.append(p)
threads = [threading.Thread(target=request) for _ in range(100)] for t in threads: t.start() go.set() for t in threads: t.join() print(f"100 concurrent requests, backend calls: {api.calls}") # 100 concurrent requests, backend calls: 1 print(f"callers that got 1999: {results.count(1999)}") # callers that got 1999: 100
# 2. TTL: fresh for 60 s, then loaded again. clock.advance(30_000) prices.price("SKU-1") print(f"after 30 s, backend calls: {api.calls}") # after 30 s, backend calls: 1 clock.advance(31_000) prices.price("SKU-1") print(f"after 61 s, backend calls: {api.calls}") # after 61 s, backend calls: 2
# 3. A failure is passed on, not remembered. api.down.add("SKU-9") try: prices.price("SKU-9") except Unavailable as e: print(f"SKU-9: {e}") # SKU-9: pricing api unavailable api.down.discard("SKU-9") print(f"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") print(f"cached: {', '.join(cache.keys())}") # cached: SKU-9, SKU-2 print(cache.metrics()) # requests=105 loads=5 failures=1 evictions=1using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Linq;using System.Threading;using System.Threading.Tasks;
public interface IPriceService{ int Price(string sku);}
public sealed class UnavailableException : Exception{ public UnavailableException() : base("pricing api unavailable") { }}
// The slow upstream. Each call takes 100 ms; calls are counted so a stampede shows up.public sealed class PricingApi : IPriceService{ private int _calls; public int Calls => Volatile.Read(ref _calls); public ConcurrentDictionary<string, bool> Down { get; } = new(); private readonly Dictionary<string, int> _prices = new() { ["SKU-1"] = 1999, ["SKU-2"] = 4999, ["SKU-9"] = 2499 };
public int Price(string sku) { Interlocked.Increment(ref _calls); Thread.Sleep(100); if (Down.ContainsKey(sku)) throw new UnavailableException(); return _prices[sku]; }}
// Time is injected so TTL can be tested without waiting a minute.public sealed class FakeClock{ private long _now; public long Now() => Interlocked.Read(ref _now); public void Advance(long ms) => Interlocked.Add(ref _now, ms);}
// The decorator. Callers still see an IPriceService; the cache is invisible to them.public sealed class CachingPriceService : IPriceService{ private sealed record Entry(string Sku, int Price, long ExpiresAt);
private readonly IPriceService _inner; private readonly long _ttlMs; private readonly int _maxEntries; private readonly Func<long> _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 readonly object _lock = new(); // The list's first node is the most recently used. Bounded, because an unbounded cache // is a memory leak with a hit rate. private readonly LinkedList<Entry> _lru = new(); private readonly Dictionary<string, LinkedListNode<Entry>> _entries = new(); // 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 readonly Dictionary<string, TaskCompletionSource<int>> _inFlight = new(); private long _requests, _loads, _failures, _evictions;
public CachingPriceService(IPriceService inner, long ttlMs, int maxEntries, Func<long> clock) { _inner = inner; _ttlMs = ttlMs; _maxEntries = maxEntries; _clock = clock; }
public int Price(string sku) { TaskCompletionSource<int>? call; bool leader = false; lock (_lock) { _requests++; if (_entries.TryGetValue(sku, out var node)) { if (_clock() < node.Value.ExpiresAt) { _lru.Remove(node); _lru.AddFirst(node); // now the most recently used return node.Value.Price; } _lru.Remove(node); // expired: never serve it, load again _entries.Remove(sku); } if (!_inFlight.TryGetValue(sku, out call)) { call = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously); _inFlight[sku] = call; _loads++; leader = true; } } // A follower: wait for the leader's answer. GetResult rethrows the leader's error. if (!leader) return call.Task.GetAwaiter().GetResult();
int price; try { price = _inner.Price(sku); // outside the lock } catch (Exception e) { // 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.) lock (_lock) { _inFlight.Remove(sku); _failures++; } call.SetException(e); throw; } lock (_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[sku] = _lru.AddFirst(new Entry(sku, price, _clock() + _ttlMs)); _inFlight.Remove(sku); while (_lru.Count > _maxEntries) { var oldest = _lru.Last!; _lru.RemoveLast(); _entries.Remove(oldest.Value.Sku); _evictions++; } } call.SetResult(price); return price; }
// Cached keys, least recently used first. public IReadOnlyList<string> Keys() { lock (_lock) return _lru.Reverse().Select(e => e.Sku).ToList(); }
public string Metrics() { lock (_lock) return $"requests={_requests} loads={_loads} failures={_failures} evictions={_evictions}"; }}
public static class Program{ public static void Main() { var api = new PricingApi(); var clock = new FakeClock(); var cache = new CachingPriceService(api, 60_000, 2, clock.Now); IPriceService prices = cache; // what callers get: the same interface as the api
// 1. A cold key under load: 100 requests released at the same moment. Plain threads, // because 100 blocked thread-pool tasks would make the pool grow slowly. using var go = new ManualResetEventSlim(false); var results = new int[100]; var threads = Enumerable.Range(0, 100).Select(i => new Thread(() => { go.Wait(); results[i] = prices.Price("SKU-1"); })).ToList(); threads.ForEach(t => t.Start()); go.Set(); threads.ForEach(t => t.Join()); Console.WriteLine($"100 concurrent requests, backend calls: {api.Calls}"); // 100 concurrent requests, backend calls: 1 Console.WriteLine($"callers that got 1999: {results.Count(p => p == 1999)}"); // callers that got 1999: 100
// 2. TTL: fresh for 60 s, then loaded again. clock.Advance(30_000); prices.Price("SKU-1"); Console.WriteLine($"after 30 s, backend calls: {api.Calls}"); // after 30 s, backend calls: 1 clock.Advance(31_000); prices.Price("SKU-1"); Console.WriteLine($"after 61 s, backend calls: {api.Calls}"); // after 61 s, backend calls: 2
// 3. A failure is passed on, not remembered. api.Down["SKU-9"] = true; try { prices.Price("SKU-9"); } catch (UnavailableException e) { Console.WriteLine($"SKU-9: {e.Message}"); // SKU-9: pricing api unavailable } api.Down.TryRemove("SKU-9", out _); Console.WriteLine($"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"); Console.WriteLine($"cached: {string.Join(", ", cache.Keys())}"); // cached: SKU-9, SKU-2 Console.WriteLine(cache.Metrics()); // requests=105 loads=5 failures=1 evictions=1 }}package main
import ( "container/list" "errors" "fmt" "strings" "sync" "sync/atomic" "time")
type PriceService interface { Price(sku string) (int, error)}
var ErrUnavailable = errors.New("pricing api unavailable")
// PricingApi is the slow upstream. Each call takes 100 ms; calls are counted so a// stampede shows up.type PricingApi struct { Calls atomic.Int32 mu sync.Mutex down map[string]bool prices map[string]int}
func NewPricingApi() *PricingApi { return &PricingApi{down: map[string]bool{}, prices: map[string]int{"SKU-1": 1999, "SKU-2": 4999, "SKU-9": 2499}}}
func (a *PricingApi) SetDown(sku string, down bool) { a.mu.Lock() defer a.mu.Unlock() a.down[sku] = down}
func (a *PricingApi) Price(sku string) (int, error) { a.Calls.Add(1) time.Sleep(100 * time.Millisecond) a.mu.Lock() defer a.mu.Unlock() if a.down[sku] { return 0, ErrUnavailable } return a.prices[sku], nil}
// FakeClock is injected so TTL can be tested without waiting a minute.type FakeClock struct{ now atomic.Int64 }
func (c *FakeClock) Now() int64 { return c.now.Load() }func (c *FakeClock) Advance(ms int64) { c.now.Add(ms) }
type entry struct { sku string price int expiresAt int64}
// call is one load in progress. Followers wait for done, then read price or err.type call struct { done chan struct{} price int err error}
// CachingPriceService is the decorator. Callers still see a PriceService; the cache is// invisible to them.type CachingPriceService struct { inner PriceService ttlMs int64 maxEntries int clock func() int64 // One mutex 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. mu sync.Mutex // Front of the list is the most recently used. Bounded, because an unbounded cache // is a memory leak with a hit rate. lru *list.List entries map[string]*list.Element // 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. // (golang.org/x/sync/singleflight packages this part.) inFlight map[string]*call requests, loads, failures, evictions int}
func NewCachingPriceService(inner PriceService, ttlMs int64, maxEntries int, clock func() int64) *CachingPriceService { return &CachingPriceService{inner: inner, ttlMs: ttlMs, maxEntries: maxEntries, clock: clock, lru: list.New(), entries: map[string]*list.Element{}, inFlight: map[string]*call{}}}
func (c *CachingPriceService) Price(sku string) (int, error) { c.mu.Lock() c.requests++ if el, ok := c.entries[sku]; ok { e := el.Value.(*entry) if c.clock() < e.expiresAt { c.lru.MoveToFront(el) // now the most recently used c.mu.Unlock() return e.price, nil } c.lru.Remove(el) // expired: never serve it, load again delete(c.entries, sku) } if cl, ok := c.inFlight[sku]; ok { c.mu.Unlock() <-cl.done // a follower: wait for the leader's answer, or its error return cl.price, cl.err } cl := &call{done: make(chan struct{})} c.inFlight[sku] = cl c.loads++ c.mu.Unlock()
price, err := c.inner.Price(sku) // outside the lock
c.mu.Lock() delete(c.inFlight, sku) if err != nil { // 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.) c.failures++ } else { // Store and remove from in-flight in one step: a request that arrives now sees // either the in-flight call or the fresh entry, never neither. c.entries[sku] = c.lru.PushFront(&entry{sku, price, c.clock() + c.ttlMs}) for c.lru.Len() > c.maxEntries { oldest := c.lru.Back() c.lru.Remove(oldest) delete(c.entries, oldest.Value.(*entry).sku) c.evictions++ } } c.mu.Unlock() cl.price, cl.err = price, err close(cl.done) return price, err}
// Keys lists cached keys, least recently used first.func (c *CachingPriceService) Keys() []string { c.mu.Lock() defer c.mu.Unlock() keys := []string{} for el := c.lru.Back(); el != nil; el = el.Prev() { keys = append(keys, el.Value.(*entry).sku) } return keys}
func (c *CachingPriceService) Metrics() string { c.mu.Lock() defer c.mu.Unlock() return fmt.Sprintf("requests=%d loads=%d failures=%d evictions=%d", c.requests, c.loads, c.failures, c.evictions)}
func main() { api := NewPricingApi() clock := &FakeClock{} cache := NewCachingPriceService(api, 60_000, 2, clock.Now) var prices PriceService = cache // what callers get: the same interface as the api
// 1. A cold key under load: 100 requests released at the same moment. start := make(chan struct{}) results := make([]int, 100) var wg sync.WaitGroup for i := range results { wg.Add(1) go func() { defer wg.Done() <-start results[i], _ = prices.Price("SKU-1") }() } close(start) wg.Wait() got1999 := 0 for _, p := range results { if p == 1999 { got1999++ } } fmt.Println("100 concurrent requests, backend calls:", api.Calls.Load()) // 100 concurrent requests, backend calls: 1 fmt.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") fmt.Println("after 30 s, backend calls:", api.Calls.Load()) // after 30 s, backend calls: 1 clock.Advance(31_000) prices.Price("SKU-1") fmt.Println("after 61 s, backend calls:", api.Calls.Load()) // after 61 s, backend calls: 2
// 3. A failure is passed on, not remembered. api.SetDown("SKU-9", true) if _, err := prices.Price("SKU-9"); err != nil { fmt.Println("SKU-9:", err) // SKU-9: pricing api unavailable } api.SetDown("SKU-9", false) p, _ := prices.Price("SKU-9") fmt.Println("SKU-9 after recovery:", p) // SKU-9 after recovery: 2499
// 4. Bounded: a third key evicts the least recently used one (SKU-1). prices.Price("SKU-2") fmt.Println("cached:", strings.Join(cache.Keys(), ", ")) // cached: SKU-9, SKU-2 fmt.Println(cache.Metrics()) // requests=105 loads=5 failures=1 evictions=1}interface PriceService { price(sku: string): Promise<number>;}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// The slow upstream. Each call takes 100 ms; calls are counted so a stampede shows up.class PricingApi implements PriceService { calls = 0; readonly down = new Set<string>(); private readonly prices = new Map([ ['SKU-1', 1999], ['SKU-2', 4999], ['SKU-9', 2499], ]);
async price(sku: string): Promise<number> { this.calls++; await sleep(100); if (this.down.has(sku)) throw new Error('pricing api unavailable'); return this.prices.get(sku)!; }}
// Time is injected so TTL can be tested without waiting a minute.class FakeClock { now = 0; advance(ms: number): void { this.now += ms; }}
type Entry = { price: number; expiresAt: number };
// The decorator. Callers still see a PriceService; the cache is invisible to them.class CachingPriceService implements PriceService { // Map keeps insertion order; a hit deletes and re-inserts, so the first key is the // least recently used. Bounded, because an unbounded cache is a memory leak with a hit rate. private readonly entries = new Map<string, Entry>(); // Single-flight: at most one backend call per key at a time. JavaScript has one thread, // but price() awaits the backend, and every request that arrives during that await // would also miss. Sharing the promise makes them all wait for the same call. private readonly inFlight = new Map<string, Promise<number>>(); private requests = 0; private loads = 0; private failures = 0; private evictions = 0;
constructor( private readonly inner: PriceService, private readonly ttlMs: number, private readonly maxEntries: number, private readonly clock: () => number, ) {}
price(sku: string): Promise<number> { this.requests++; const e = this.entries.get(sku); if (e) { this.entries.delete(sku); if (this.clock() < e.expiresAt) { this.entries.set(sku, e); // now the most recently used return Promise.resolve(e.price); } // expired: never serve it, load again } const pending = this.inFlight.get(sku); if (pending) return pending; // a follower: wait for the leader's answer, or its error this.loads++; const load = this.load(sku); this.inFlight.set(sku, load); return load; }
private async load(sku: string): Promise<number> { let price: number; try { price = await this.inner.price(sku); } catch (err) { // 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.) this.inFlight.delete(sku); this.failures++; throw err; } // Store and remove from in-flight in the same synchronous step: a request that // arrives next sees the fresh entry. this.entries.set(sku, { price, expiresAt: this.clock() + this.ttlMs }); this.inFlight.delete(sku); while (this.entries.size > this.maxEntries) { this.entries.delete(this.entries.keys().next().value!); this.evictions++; } return price; }
keys(): string[] { return [...this.entries.keys()]; }
metrics(): string { return `requests=${this.requests} loads=${this.loads} failures=${this.failures} evictions=${this.evictions}`; }}
const api = new PricingApi();const clock = new FakeClock();const cache = new CachingPriceService(api, 60_000, 2, () => clock.now);const prices: PriceService = cache; // what callers get: the same interface as the api
// 1. A cold key under load: 100 requests at the same moment.const results = await Promise.all(Array.from({ length: 100 }, () => prices.price('SKU-1')));console.log(`100 concurrent requests, backend calls: ${api.calls}`); // 100 concurrent requests, backend calls: 1console.log(`callers that got 1999: ${results.filter((p) => p === 1999).length}`); // callers that got 1999: 100
// 2. TTL: fresh for 60 s, then loaded again.clock.advance(30_000);await prices.price('SKU-1');console.log(`after 30 s, backend calls: ${api.calls}`); // after 30 s, backend calls: 1clock.advance(31_000);await prices.price('SKU-1');console.log(`after 61 s, backend calls: ${api.calls}`); // after 61 s, backend calls: 2
// 3. A failure is passed on, not remembered.api.down.add('SKU-9');try { await prices.price('SKU-9');} catch (e) { console.log(`SKU-9: ${(e as Error).message}`); // SKU-9: pricing api unavailable}api.down.delete('SKU-9');console.log(`SKU-9 after recovery: ${await prices.price('SKU-9')}`); // SKU-9 after recovery: 2499
// 4. Bounded: a third key evicts the least recently used one (SKU-1).await prices.price('SKU-2');console.log(`cached: ${cache.keys().join(', ')}`); // cached: SKU-9, SKU-2console.log(cache.metrics()); // requests=105 loads=5 failures=1 evictions=1
export {};What to notice
Section titled “What to notice”- 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 aTaskCompletionSource. Python uses a small object with athreading.Event. Go uses a channel that is closed when the load is done (the same idea asgolang.org/x/sync/singleflight). TypeScript has one thread, butpriceawaits 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 == apiis false.cache instanceof PricingApiis 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 orequalsacross 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.
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.
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.
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.
Tradeoffs
Section titled “Tradeoffs”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.
In each language
Section titled “In each language”| 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.
Commonly confused with
Section titled “Commonly confused with”| 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. |
Where it shows up
Section titled “Where it shows up”On this site:
- Logging framework:
AsyncAppenderwraps any appender with a bounded queue and a writer thread. - LRU cache:
SynchronizedCacheadds locking to anyCachewithout touching it. - Parking lot: pricing decorators add fees or discounts around a base pricing policy.
- Strategy:
FreeOverwraps 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.
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- 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
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.