Design Patterns & LLD
Structural Patterns: Adapter, Decorator, Facade, Proxy and Composite
The intent of each pattern, how to tell them apart, and the JavaScript Proxy object.
On this page
In one line: structural patterns are about how objects are joined together. An adapter makes a wrong-shaped object fit, a decorator adds behaviour by wrapping, a facade hides a messy group of objects behind one simple door, a proxy stands in for another object to control access, and a composite lets one thing and a group of things be used the same way.
Lesson 1 was about creating objects. This lesson is about connecting them. These five patterns look alike on the page, because each one holds another object. They differ in intent, and the interviewer will usually ask you to tell them apart. So for each pattern, remember the intent first and the code second.
Lesson 0 (SOLID and the routine) explains the interface and composition ideas used here. Lesson 1 (creational patterns) explains dependency injection, which we use to pass the inner object in.
What I ran / checked. I ran every TypeScript block with Node 22.22.0 (node --experimental-transform-types; top-level await works because the files are ES modules) and pasted the real output. All files also pass tsc --strict (TypeScript 7.0.2). Pattern names and intents are general knowledge of the classic 1994 catalogue; I quote no text. I could not open one popular pattern guide on GitHub from this sandbox. The Proxy object behaviour was run, not only read. Bridge and Flyweight are only named in a table, with no code.
Words used in this lesson
- A structural pattern is a pattern about how classes and objects are combined into larger structures.
- A wrapper is an object that holds another object and forwards calls to it. Adapter, decorator and proxy are all wrappers.
- The target interface is the interface the caller wants to use. The adaptee is the existing object with the wrong shape.
- A legacy API is an old or third-party interface you cannot or should not change.
- A cross-cutting concern is a need that cuts across many classes: logging, caching, retries, timing, permissions.
- A subsystem is a group of classes that work together for one job (inventory, payments, shipping).
- A leaf is an object with no children. A node that has children is a composite.
- A tree is a structure where each item has one parent and may have many children.
- Lazy means "do it only when it is first needed".
- A trap (in JavaScript
Proxy) is a function that intercepts an operation such asgetorset. (Not the same as an interview trap.)
The five at a glance
| Pattern | Intent in one line | Interface of the wrapper | Typical sign |
|---|---|---|---|
| Adapter | Make an existing thing fit the interface I want | Different from the inner one | "Their API is shaped wrong" |
| Decorator | Add behaviour without changing the class | Same as the inner one | "Add logging / caching / retry" |
| Facade | One simple entry to a complex group | New, simpler interface | "Callers must know ten steps" |
| Proxy | Control access to the real object | Same as the inner one | "Lazy load / permissions / remote" |
| Composite | Treat one and many the same | Same for leaf and group | "Tree of parts" |
Adapter changes the interface. Decorator and proxy keep it. Facade makes a new, smaller one. The trick: decorator adds behaviour, proxy controls access, and both look the same in code. Say the intent out loud.
Adapter: fix the shape
You have an old library with callbacks and Fahrenheit. Your app wants promises and Celsius. You cannot edit the library. Write a small class that speaks your interface on one side and theirs on the other.
// Third-party / legacy code we cannot change: callbacks, odd names, Fahrenheit.class LegacyWeather { fetchTempF(city: string, cb: (err: Error | null, f?: number) => void) { setTimeout(() => (city === "nowhere" ? cb(new Error("unknown city")) : cb(null, 68)), 1); }}// The interface our app wants: promises, Celsius.interface WeatherService { celsius(city: string): Promise<number>; }class LegacyWeatherAdapter implements WeatherService { constructor(private legacy: LegacyWeather) {} celsius(city: string): Promise<number> { return new Promise((resolve, reject) => { this.legacy.fetchTempF(city, (err, f) => (err ? reject(err) : resolve(((f! - 32) * 5) / 9))); }); }}const service: WeatherService = new LegacyWeatherAdapter(new LegacyWeather());console.log(await service.celsius("Paris"));await service.celsius("nowhere").catch(e => console.log("error:", e.message));Output:
20error: unknown cityLegacyWeatherAdapter implements WeatherService, which is what the app knows. Inside, it calls the old callback method, turns it into a promise, and converts 68 F to 20 C. If the old library changes, only the adapter changes. If you switch libraries, you write a new adapter and nothing else moves. That is the dependency inversion rule from Lesson 0 in action.
Adapter or a plain function? For one conversion, a function celsiusFrom(legacy) is enough. Use a class when you want to implement a shared interface, keep state, or have more than one method to adapt.
Decorator: add behaviour by wrapping
Logging, caching and retries are needed in many places. Putting them inside each class spreads one idea over many files and breaks the single responsibility rule. A decorator wraps an object that has the same interface and adds one thing before or after the call.
interface Fetcher { get(key: string): Promise<string>; }let calls = 0;const base: Fetcher = { // fails on its first two calls async get(key) { calls++; if (calls <= 2) throw new Error("timeout"); return "value-of-" + key; },};class WithRetry implements Fetcher { constructor(private inner: Fetcher, private times: number) {} async get(key: string): Promise<string> { for (let i = 1; ; i++) { try { return await this.inner.get(key); } catch (e) { if (i >= this.times) throw e; console.log(` retry ${i}`); } } }}class WithCache implements Fetcher { private seen = new Map<string, string>(); constructor(private inner: Fetcher) {} async get(key: string): Promise<string> { if (this.seen.has(key)) return this.seen.get(key)!; const v = await this.inner.get(key); this.seen.set(key, v); return v; }}class WithLog implements Fetcher { constructor(private inner: Fetcher, private name: string) {} async get(key: string): Promise<string> { console.log(`${this.name}: get ${key}`); return this.inner.get(key); }}// Wrap from the inside out. Same interface at every layer.const fetcher: Fetcher = new WithLog(new WithCache(new WithRetry(base, 3)), "app");console.log(await fetcher.get("a"));console.log(await fetcher.get("a"));console.log("base calls:", calls);Output:
app: get a retry 1 retry 2value-of-aapp: get avalue-of-abase calls: 3Read the output from the top. The call goes WithLog first, then WithCache (miss), then WithRetry, then the base. The base fails twice, WithRetry tries again twice (retry 1, retry 2), and the third call works. The second get("a") stops at WithCache and never reaches the base: base calls: 3, not 4. Because all layers share the Fetcher interface, you can stack them in any order, and the order matters. Put WithCache outside WithRetry, as here, to cache the final answer. Put it inside, and each retry attempt would consult the cache.
Two notes. First, "decorator" the pattern is not the same as the TypeScript or JavaScript @decorator syntax, although that syntax can be used to build it. Second, higher-order functions are decorators too: withRetry(fn) returns a new function with the same signature. Express middleware (Lesson 4) is a close cousin.
Facade: one simple door
A caller who wants to "place an order" should not have to know that you reserve stock, charge the card, schedule the shipment, and undo the reservation if payment fails. A facade is a class with a small, friendly API that hides that sequence.
// Three subsystems, each with its own small API.class Inventory { private stock = new Map([["pen", 2]]); reserve(sku: string, qty: number) { const have = this.stock.get(sku) ?? 0; if (have < qty) throw new Error("out of stock: " + sku); this.stock.set(sku, have - qty); } release(sku: string, qty: number) { this.stock.set(sku, (this.stock.get(sku) ?? 0) + qty); }}class Payments { charge(amount: number) { if (amount > 100) throw new Error("card declined"); return "pay-1"; } }class Shipping { schedule(sku: string) { return "ship-" + sku; } }// Facade: one simple method for callers, the order of steps is hidden here.class OrderFacade { constructor(private inv: Inventory, private pay: Payments, private ship: Shipping) {} placeOrder(sku: string, qty: number, amount: number): string { this.inv.reserve(sku, qty); try { const payId = this.pay.charge(amount); return `${payId}/${this.ship.schedule(sku)}`; } catch (e) { this.inv.release(sku, qty); // undo the reservation throw e; } }}const shop = new OrderFacade(new Inventory(), new Payments(), new Shipping());console.log(shop.placeOrder("pen", 1, 5));for (const [qty, amount] of [[5, 5], [1, 500], [1, 5]]) { try { console.log(shop.placeOrder("pen", qty, amount)); } catch (e) { console.log("failed:", (e as Error).message); }}Output:
pay-1/ship-penfailed: out of stock: penfailed: card declinedpay-1/ship-penThe first order works. The second asks for 5 pens when there are fewer, so it fails at the first step. The third fails at payment, and the facade releases the reservation. The proof is the fourth order: it succeeds, which would not happen if the stock had stayed reserved. The caller wrote one line each time and knew nothing about the three subsystems.
Facade is a convenience, not a wall. The subsystems are still available to code that needs more control. Also watch for a facade that grows into a god object that does everything (an anti-pattern, see Lesson 4). A facade should only coordinate; the real work stays in the subsystems.
Proxy: a stand-in with a guard
A proxy has the same interface as the real object and controls access to it. The classic kinds:
| Kind | What it does | Example |
|---|---|---|
| Virtual (lazy) | Creates the real object only when first needed | Load a big report on first read |
| Protection | Checks permission first | Only some users may read |
| Remote | Hides that the real object is on another machine | An RPC client stub |
| Caching / logging | Remembers results or records calls | A cache in front of a service |
// 1. The classic Proxy: same interface, controls access to the real object.interface Report { read(user: string): string; }class RealReport implements Report { constructor() { console.log("(expensive load)"); } read() { return "secret numbers"; }}class ReportProxy implements Report { // lazy + protection proxy private real?: RealReport; constructor(private allowed: string[]) {} read(user: string) { if (!this.allowed.includes(user)) return "denied"; this.real ??= new RealReport(); // created only on first allowed use return this.real.read(); }}const proxy = new ReportProxy(["ann"]);console.log(proxy.read("bob"));console.log(proxy.read("ann"));console.log(proxy.read("ann"));// 2. JavaScript's built-in Proxy object: intercept get and set on any object.const log: string[] = [];const user = new Proxy({ name: "Ann", age: 30 } as Record<string, unknown>, { get(target, prop, receiver) { log.push("get " + String(prop)); return Reflect.get(target, prop, receiver); }, set(target, prop, value, receiver) { if (prop === "age" && (typeof value !== "number" || value < 0)) throw new TypeError("age must be >= 0"); return Reflect.set(target, prop, value, receiver); },});user.age = 31;try { user.age = -5; } catch (e) { console.log((e as Error).message); }console.log(user.name, user.age, log);Output:
denied(expensive load)secret numberssecret numbersage must be >= 0Ann 31 [ 'get name', 'get age' ]The first half is the classic pattern. bob is refused and the expensive object is never built (no (expensive load) yet). ann triggers the load once, and the second read reuses it. The second half uses the language's own Proxy object. It intercepts get and set on a plain object: the set trap refused -5, and the get trap recorded the two reads. Use Reflect.get and Reflect.set inside the traps to keep the default behaviour for everything else.
Where the language Proxy object shows up in the real world: change tracking, validation, and "reactive" data in front-end libraries (I only know this as general knowledge; I did not open any library for this lesson). Its cost is that traps run on every access, so do not put one on a hot path without measuring.
Decorator versus proxy. The code can look the same. The decorator is chosen by the caller, to add a feature (retry, log). The proxy is usually in charge of the life or access of the real object: it may decide to never create it at all, as bob showed.
Composite: one and many look the same
A folder holds files and other folders. A menu holds items and sub-menus. A shape group holds shapes and other groups. If the caller must check "is this a file or a folder?" everywhere, the code fills with ifs. With a composite, leaf and group share one interface, and the group simply passes the call on to its children.
// Composite: files and folders share one interface, so callers treat them alike.interface FsNode { name: string; size(): number; print(indent?: string): string[]; }class File implements FsNode { constructor(readonly name: string, private bytes: number) {} size() { return this.bytes; } print(indent = "") { return [`${indent}${this.name} (${this.bytes})`]; }}class Folder implements FsNode { private children: FsNode[] = []; constructor(readonly name: string) {} add(...n: FsNode[]) { this.children.push(...n); return this; } size(): number { return this.children.reduce((sum, c) => sum + c.size(), 0); } print(indent = ""): string[] { return [`${indent}${this.name}/ (${this.size()})`, ...this.children.flatMap(c => c.print(indent + " "))]; }}const root = new Folder("src").add( new File("index.ts", 120), new Folder("lib").add(new File("a.ts", 300), new File("b.ts", 80), new Folder("empty")),);console.log(root.print().join("\n"));console.log("total:", root.size());Output:
src/ (500) index.ts (120) lib/ (380) a.ts (300) b.ts (80) empty/ (0)total: 500size() on src adds up the sizes of all children, even the nested folder, and empty has size 0. print() calls itself on each child with more indent. The caller code (root.size()) is the same for one file or the whole tree. The recursion is the point: a composite is a tree, and tree questions (size, search, print) become a method on the node.
Trade-off: the shared interface may carry methods that make no sense for a leaf (add on a file). Here I kept add on Folder only, so a File cannot be asked to add children. That is safer, but then a caller holding a FsNode cannot add without a type check. Say this choice out loud in an interview.
Two more you may meet
| Pattern | One line | When it comes up |
|---|---|---|
| Bridge | Split a class into two parts (what it does and how) that change on their own, joined by composition | Shapes with many renderers; notifications with many channels |
| Flyweight | Share the common part of many small objects to save memory | Characters in a text editor; map tiles |
I do not show code for these because they are rare in interviews, and I have not run an example for them here.
Choosing quickly
| Your need | Pattern |
|---|---|
| "Their interface is wrong for us" | Adapter |
| "I want logging, caching, retry, timing around this" | Decorator |
| "Callers do five calls in a fixed order" | Facade |
| "Create it late, or check permission first" | Proxy |
| "It is a tree: part and whole" | Composite |
Interview questions
What is the difference between Adapter and Decorator? (Must know)
An adapter changes the interface. It lets code written for interface A use an object that has interface B. A decorator keeps the interface and adds behaviour. I say it this way: adapter is about shape, decorator is about extra behaviour. In my example the adapter turned callbacks and Fahrenheit into promises and Celsius, and the decorators added retry, cache and logging to a Fetcher without changing its shape.
What is the difference between Decorator and Proxy? (Senior signal)
Both keep the same interface and hold the inner object. The intent differs. A decorator adds a feature chosen by the caller, and the inner object always exists. A proxy controls access to the real object, so it may delay creating it, refuse the call, or hide that the object is remote. If the wrapper decides whether the real object is even touched, it is a proxy.
When would you use a Facade? (Must know)
When callers need a task done and today they must know a fixed sequence of calls across several classes. The facade holds that sequence, including the clean-up on failure (releasing stock when payment fails). It also gives one place to change if the subsystems change. I keep the subsystems public for advanced users and I keep the facade thin, so it does not become a god object.
Follow-up: "Is a facade the same as an API gateway?" The idea is similar (one simple front for many parts), but a gateway is a network service, and a facade is a class in one program.
How would you add logging and caching to a service without editing it? (Must know)
I define an interface for the service, then write a WithLog and a WithCache class that implement the same interface and wrap an inner one. I compose them in the composition root: new WithLog(new WithCache(real)). Order matters, so I choose it on purpose and explain it. This keeps each class to one job.
Does the order of stacked decorators matter? (Senior signal)
Yes. In my example, WithCache outside WithRetry caches the final successful value. With WithCache inside, every retry would check the cache first, which is wasted work. Logging outside everything logs each logical call, and logging inside logs every attempt. I pick the order from the question "at which level do I want this effect?".
Give an example of Composite. (Must know)
A file system. File and Folder both implement size() and print(). A folder passes the call to each child and adds up the results. The caller does not check types. Other examples: a menu with sub-menus, a UI component tree, an organisation chart, or a group of shapes in a drawing tool.
What is a virtual proxy? (Must know)
A proxy that creates the expensive real object only on first use. In my example the real report was built only when an allowed user first read it. Typical uses are big files, images and database-backed objects. The caller sees the same interface and does not know the object may not exist yet.
What is the JavaScript Proxy object, and how does it relate to the pattern? (Senior signal)
It is a built-in object that wraps a target and lets you define traps (get, set, has, deleteProperty and more). It is the language's own tool for building a proxy. I used a set trap to refuse a negative age and a get trap to record reads. The cost is that every access runs your code, so I measure before putting it on a hot path. Also Proxy works on plain objects with no interface at all, which the classic pattern does not.
Where would you use an Adapter in a real project? (Must know)
Wherever an outside library or old service has a shape I do not want in my business code: a payment SDK with its own types, an old callback API, a vendor's response format. I define my own small interface first (PaymentGateway) and write one adapter per vendor. Business code never imports the vendor. A vendor change touches one file.
A composite interface has add() on every node. What is the problem? (Trap)
A leaf like File cannot add children, so either add throws on a leaf (which breaks the Liskov rule from Lesson 0) or it does nothing silently. The safer design puts add only on the composite, at the cost of a type check when a caller holds the general node type. I choose the safe version and say the trade-off.
How do these patterns relate to the SOLID principles? (Senior signal)
Adapter and facade support dependency inversion: business code depends on my interface, not a vendor's. Decorator supports open/closed: new behaviour is a new wrapper, not an edit. Decorator also supports single responsibility, because retry, cache and log are separate classes. Composite supports Liskov substitution if leaf and group truly keep the same promise.
When would you not use any of these? (Trap)
When there is one call site and the "wrapper" only forwards calls. A class that wraps another and adds nothing is noise. I also avoid stacking four decorators when a three-line try/catch would do. I add the pattern when the second or third use appears, or when a vendor boundary makes the change cheap later.
Key takeaways
- Structural patterns join objects. Adapter, decorator and proxy are wrappers; they differ in intent.
- Adapter changes the interface. Decorator keeps it and adds a feature. Proxy keeps it and controls access. Facade makes a new, smaller one. Composite gives leaf and group the same interface.
- Stacked decorators work because they share one interface. Their order changes the result.
- A facade coordinates; it should not become a god object.
- The JavaScript
Proxyobject is a language tool for building proxies, with a cost on every access. - Use a pattern when a real change or a vendor boundary asks for it, not before.
What's next
Next: Behavioural patterns: Strategy, Observer, Command, State and Chain of Responsibility, Lesson 3. Practise: wrap a function you own with a withTiming decorator that prints how long it took, then stack it with a withRetry.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Errors, Proxy, Reflect, Copying and Immutability · JavaScript Part 2
Part of the study guide
Related series