From subclasses to wrappers
Think of a phone. A case adds protection, a pop-socket adds grip, a wallet sleeve adds card storage. Each one wraps the phone, and you still use it exactly like a phone: same screen, same buttons. You can combine them freely, take one off, and the phone itself never changed.
A decorator works the same way:
- it implements the same interface as the object it wraps, so callers can’t tell the difference,
- it holds the wrapped object (often called
innerordelegate), - each method does its extra work (log, cache, retry) and delegates the real work to the wrapped object.
Decorator attaches responsibilities dynamically by wrapping an object behind its own interface, composing behavior instead of inheriting it. It’s the backbone of cross-cutting concerns: java.io streams, OkHttp and URLSession-style middleware, Express and Koa middleware, Spring’s proxies for @Transactional and @Cacheable, SwiftUI modifiers, Kotlin’s class delegation (by).
The senior questions are about composition semantics: wrapper order changes behavior (retry around timeout vs timeout around retry), decorators apply to every method of an interface (including non-idempotent ones), delegation vs inheritance and self-use (the fragile base class problem), and identity and type checks seeing the wrapper instead of the original. The Detailed version covers each.
Before: A subclass for every combination
class APIProductSource {
func product(id: Int) throws -> Product { try api.fetchProduct(id) }
}
class CachedAPIProductSource: APIProductSource { /* overrides product(id:) with a cache */ }
class LoggingCachedAPIProductSource: CachedAPIProductSource { /* adds logging */ }
class LoggingAPIProductSource: APIProductSource { /* logging without the cache: copy-paste */ }open class ApiProductSource {
open fun product(id: Int): Product = api.fetchProduct(id)
}
open class CachedApiProductSource : ApiProductSource() { /* overrides product with a cache */ }
class LoggingCachedApiProductSource : CachedApiProductSource() { /* adds logging */ }
class LoggingApiProductSource : ApiProductSource() { /* logging without the cache: copy-paste */ }class ApiProductSource {
Product product(int id) { return api.fetchProduct(id); }
}
class CachedApiProductSource extends ApiProductSource { /* overrides product with a cache */ }
class LoggingCachedApiProductSource extends CachedApiProductSource { /* adds logging */ }
class LoggingApiProductSource extends ApiProductSource { /* logging without the cache: copy-paste */ }class ApiProductSource {
product(id: number): Promise<Product> { return api.fetchProduct(id); }
}
class CachedApiProductSource extends ApiProductSource { /* overrides product with a cache */ }
class LoggingCachedApiProductSource extends CachedApiProductSource { /* adds logging */ }
class LoggingApiProductSource extends ApiProductSource { /* logging without the cache: copy-paste */ }After: One interface, one wrapper per behavior
protocol ProductSource {
func product(id: Int) throws -> Product
}
struct APIProductSource: ProductSource {
func product(id: Int) throws -> Product { try api.fetchProduct(id) }
}
struct LoggingProductSource: ProductSource {
let inner: any ProductSource
func product(id: Int) throws -> Product {
print("Loading product \(id)")
return try inner.product(id: id) // delegate the real work
}
}
final class CachingProductSource: ProductSource {
private let inner: any ProductSource
private var cache: [Int: Product] = [:]
init(_ inner: any ProductSource) { self.inner = inner }
func product(id: Int) throws -> Product {
if let cached = cache[id] { return cached }
let product = try inner.product(id: id) // only on a miss
cache[id] = product
return product
}
}
// Combine as needed, in any order:
let source: any ProductSource = LoggingProductSource(inner: CachingProductSource(APIProductSource()))fun interface ProductSource {
fun product(id: Int): Product
}
class ApiProductSource(private val api: ShopApi) : ProductSource {
override fun product(id: Int) = api.fetchProduct(id)
}
class LoggingProductSource(private val inner: ProductSource) : ProductSource {
override fun product(id: Int): Product {
println("Loading product $id")
return inner.product(id) // delegate the real work
}
}
class CachingProductSource(private val inner: ProductSource) : ProductSource {
private val cache = HashMap<Int, Product>()
override fun product(id: Int): Product =
cache.getOrPut(id) { inner.product(id) } // only on a miss
}
// Combine as needed, in any order:
val source: ProductSource = LoggingProductSource(CachingProductSource(ApiProductSource(api)))interface ProductSource {
Product product(int id);
}
record ApiProductSource(ShopApi api) implements ProductSource {
public Product product(int id) { return api.fetchProduct(id); }
}
record LoggingProductSource(ProductSource inner) implements ProductSource {
public Product product(int id) {
System.out.println("Loading product " + id);
return inner.product(id); // delegate the real work
}
}
final class CachingProductSource implements ProductSource {
private final ProductSource inner;
private final Map<Integer, Product> cache = new HashMap<>();
CachingProductSource(ProductSource inner) { this.inner = inner; }
public Product product(int id) {
return cache.computeIfAbsent(id, inner::product); // only on a miss
}
}
// Combine as needed, in any order:
ProductSource source = new LoggingProductSource(new CachingProductSource(new ApiProductSource(api)));interface ProductSource {
product(id: number): Promise<Product>;
}
class ApiProductSource implements ProductSource {
constructor(private readonly api: ShopApi) {}
product(id: number) { return this.api.fetchProduct(id); }
}
class LoggingProductSource implements ProductSource {
constructor(private readonly inner: ProductSource) {}
product(id: number) {
console.log("Loading product " + id);
return this.inner.product(id); // delegate the real work
}
}
class CachingProductSource implements ProductSource {
private readonly cache = new Map<number, Product>();
constructor(private readonly inner: ProductSource) {}
async product(id: number): Promise<Product> {
const cached = this.cache.get(id);
if (cached) return cached;
const product = await this.inner.product(id); // only on a miss
this.cache.set(id, product);
return product;
}
}
// Combine as needed, in any order:
const source: ProductSource = new LoggingProductSource(new CachingProductSource(new ApiProductSource(api)));What changed:
- Each behavior lives in one small class, and none of them knows about the others or about the API. Three behaviors are three classes, not seven combinations.
- The caller only sees
ProductSource. A screen doesn’t know whether it got the plain API, a cached one, or a logged and cached one, which also makes tests easy: pass a fakeProductSource. - Combining is just wrapping:
Logging(Caching(Api)). Adding retries later means writing oneRetryingProductSourceand adding it to that line.
The wrapper that forgot to delegate
Tap the line with the bug.
final class CachingProductSource: ProductSource {
private let inner: any ProductSource
private var cache: [Int: Product] = [:]
init(_ inner: any ProductSource) { self.inner = inner }
func product(id: Int) throws -> Product {
if let cached = cache[id] { return cached }
let product = try product(id: id)
cache[id] = product
return product
}
}class CachingProductSource(private val inner: ProductSource) : ProductSource {
private val cache = HashMap<Int, Product>()
override fun product(id: Int): Product {
cache[id]?.let { return it }
val product = product(id)
cache[id] = product
return product
}
}class CachingProductSource implements ProductSource {
private final ProductSource inner;
private final Map<Integer, Product> cache = new HashMap<>();
CachingProductSource(ProductSource inner) { this.inner = inner; }
public Product product(int id) {
Product cached = cache.get(id);
if (cached != null) return cached;
Product product = product(id);
cache.put(id, product);
return product;
}
}class CachingProductSource implements ProductSource {
private readonly cache = new Map<number, Product>();
constructor(private readonly inner: ProductSource) {}
async product(id: number): Promise<Product> {
const cached = this.cache.get(id);
if (cached) return cached;
const product = await this.product(id);
this.cache.set(id, product);
return product;
}
}Hint: On a cache miss, which object's product(id) does this line call?
On a miss, the decorator calls its own product(id) instead of inner.product(id). That call misses the cache again and calls itself again, forever: a stack overflow on the first product that isn’t cached yet. Because the method has the same name and signature as the one it should delegate to, the compiler can’t help, and a test that only checks cached products passes.
Always delegate through the wrapped object:
let product = try inner.product(id: id)val product = inner.product(id)Product product = inner.product(id);const product = await this.inner.product(id);A habit that prevents it: name the field inner (or delegate) and read every return in a decorator asking “does this reach inner?”
Decorator, proxy, chain, aspect
- Decorator adds behavior and is composed by the client; several stack freely.
- Proxy controls access to the same interface (lazy loading, remote calls, security checks), usually one, created by infrastructure. Hibernate’s lazy entities and Spring’s
@Transactionalbeans are proxies that behave like decorators. - Chain of responsibility (OkHttp interceptors, servlet filters, Ktor plugins, Express and Koa middleware) is a decorator stack with an explicit
chain.proceed(), where any link may short-circuit. - AOP generates decorators from annotations (TypeScript’s
@decoratorsyntax is a language-level cousin that can replace a method with a wrapper), which is convenient and hides the order and the self-invocation limit (a call throughthisskips the proxy).
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Interface | protocol | interface / fun interface | interface | interface (structural: any object with the right methods fits) |
| Forwarding boilerplate | write each method | : Iface by inner | forwarding base class, or each method | each method, or a function returning a wrapper object |
| Neighbors | SwiftUI modifiers, URLProtocol | OkHttp interceptors (chain) | Spring proxies, servlet filters | Express/Koa middleware (chain), @decorator syntax (aspect) |
- Decorator is composed by the client and stacks freely; proxy controls access and is usually created by infrastructure; a chain is a decorator stack with an explicit
next/proceed(); AOP generates decorators from annotations. - The classic bug: calling
product(id)on yourself instead ofinner, which recurses until the stack overflows.
Check this part
What does a decorator do?
On a cache miss, the decorator calls
try product(id: id)instead oftry inner.product(id: id). What happens?On a cache miss, the decorator callsproduct(id)instead ofinner.product(id). What happens?On a cache miss, the decorator callsthis.product(id)instead ofthis.inner.product(id). What happens?You wrap a
CachingProductSourcein aLoggingProductSource. What doessource is CachingProductSourcereturn?You wrap aCachingProductSourcein aLoggingProductSource. What doessource instanceof CachingProductSourcereturn?What distinguishes a proxy from a decorator?