Structural Patterns¶
Structural patterns deal with object composition and relationships, providing ways to assemble objects and classes into larger structures while keeping those structures flexible and efficient.
Analogy
Structural patterns solve the same problem architects face: how do you connect things that weren't designed to fit together, and how do you organize complexity without welding everything permanently? Your travel adapter bridges incompatible plugs and outlets (Adapter). A universal remote controls any TV brand through a shared protocol (Bridge). A file system nests folders inside folders, treating files and directories uniformly (Composite). A coffee order layers espresso → milk → syrup → whipped cream, each adding cost and behavior (Decorator). A hotel concierge hides three phone numbers, two websites, and an app behind "plan me an evening" (Facade). A word processor shares one font glyph across 10,000 letter 'e's (Flyweight). A debit card checks your balance, logs the transaction, and blocks fraud before touching your actual money (Proxy).
Creational Patterns
Object creation mechanisms — abstracting instantiation to make systems independent of how objects are created.
Behavioral Patterns
Object communication and responsibility distribution — making complex control flows easier to understand.
Adapter¶
Converts the interface of a class into another interface that clients expect. Use when you want to use an existing class but its interface doesn't match what you need — commonly seen when integrating third-party libraries or legacy code.
Analogy
Like a travel power adapter — your US laptop plug and the EU outlet weren't designed for each other, but the adapter translates one interface to another without rewiring either side. This is exactly why Android's RecyclerView.Adapter exists: it translates your data's shape into something the UI framework can render.
When to Use¶
- You need to integrate a legacy system or third-party library whose interface doesn't match yours — e.g., wrapping an old payment gateway to fit a new processing interface
- You're migrating systems gradually and need backward compatibility — e.g., old auth system → new OAuth interface, adapting one module at a time
- You need API version compatibility — e.g., making v2 API calls work with code written for v1
Trade-offs¶
- ✅ Reuse existing code without modification — the adaptee stays untouched
- ✅ Single Responsibility — translation logic isolated in one class
- ⚠️ Adds a layer of indirection — one more class to trace through. Acceptable for integrations you don't control; unnecessary if you can modify the source directly.
- ⚠️ Can mask underlying API differences that matter — e.g., different error semantics, async vs sync. In production, ensure the adapter handles edge cases (timeouts, error mapping) rather than just forwarding calls.
Key Insight
Adapter is reactive: you already have something and need to fit it in.
Fitness Test
"Am I trying to make an existing class work with an interface it wasn't designed for — and I can't change the original?"
- Yes → ✅ Adapter fits
- No, I can modify the source → ❌ Just change it directly
flowchart LR
A[Client] --> B[Adapter]
B --> C[Adaptee]
B -.->|implements| D["Target Interface"]
// Existing third-party XML service
class XmlParser {
parseXml(xml: string): object {
return { format: "xml", data: xml };
}
}
// Your system expects JSON
interface JsonParser {
parse(data: string): object;
}
// Adapter wraps the XML parser to match the JSON interface
class XmlToJsonAdapter implements JsonParser {
constructor(private xmlParser: XmlParser) {}
parse(data: string): object {
const xmlResult = this.xmlParser.parseXml(data);
return { ...xmlResult, format: "json" };
}
}
const adapter: JsonParser = new XmlToJsonAdapter(new XmlParser());
console.log(adapter.parse("<data>hello</data>"));
Bridge¶
Decouples an abstraction from its implementation so both can vary independently. Use when you want to avoid a combinatorial explosion of subclasses — e.g., shapes × rendering engines, or notifications × channels.
Analogy
Like a universal TV remote — the remote (abstraction) and the TV (implementation) evolve independently. Samsung releases a new TV? The remote still works. Buy a fancier remote? It controls the same TV. The "bridge" is the infrared protocol they both agree on. JDBC works this way: your code (abstraction) talks to any database (implementation) through the driver (bridge).
When to Use¶
- You're designing a system where both abstraction and implementation will vary independently — e.g., notification types × delivery channels, shapes × rendering engines
- You want to avoid a combinatorial explosion of subclasses — e.g., instead of
UrgentEmail,UrgentSms,RegularEmail,RegularSms, use Bridge - You need to swap implementations at runtime — e.g., switching between REST, gRPC, and SOAP for the same service interface
Trade-offs¶
- ✅ Eliminates class explosion — M abstractions × N implementations = M + N classes instead of M × N
- ✅ Both dimensions evolve independently — add new renderers without touching shapes, and vice versa
- ⚠️ Increased complexity upfront for a problem that may not materialize. Justified when you can already see both dimensions varying (e.g., multi-platform + multi-device); premature if only one axis changes.
- ⚠️ Can make debugging harder — two inheritance hierarchies to trace. In production, clear naming conventions (e.g.,
XNotification+YSender) keep the split obvious.
Key Insight
Bridge is proactive: you design your system so abstractions and implementations can evolve independently.
Fitness Test
"Do I have two independent dimensions that both need to vary — and would combining them create a class explosion?"
- Yes → ✅ Bridge fits
- No, only one dimension varies → ❌ Simpler inheritance or Strategy is enough
flowchart TB
A[Notification] -->|has| B["MessageSender"]
C[UrgentNotification] -.->|extends| A
D[EmailSender] -.->|implements| B
E[SmsSender] -.->|implements| B
// Implementation
interface MessageSender {
send(message: string): void;
}
class EmailSender implements MessageSender {
send(message: string): void {
console.log(`Email: ${message}`);
}
}
class SmsSender implements MessageSender {
send(message: string): void {
console.log(`SMS: ${message}`);
}
}
// Abstraction
class Notification {
constructor(private sender: MessageSender) {}
notify(message: string): void {
this.sender.send(message);
}
}
class UrgentNotification extends Notification {
notify(message: string): void {
super.notify(`[URGENT] ${message}`);
}
}
// Any notification type can use any sender
new UrgentNotification(new SmsSender()).notify("Server is down");
// SMS: [URGENT] Server is down
Composite¶
Composes objects into tree structures to represent part-whole hierarchies, letting clients treat individual objects and compositions uniformly. Use when your data naturally forms a tree — e.g., file systems, UI component trees, or organizational charts.
Analogy
Like a file system — a folder can contain files and other folders, and when you ask "how big is this folder?" it recursively sums everything inside. You treat a single file and a deeply nested directory with the exact same interface. React's component tree works identically: a component can contain other components, and rendering the root renders everything below it.
When to Use¶
- Your data forms a tree structure — e.g., file systems (files + directories), UI component trees, organizational charts
- You need to treat individual objects and groups uniformly — e.g.,
getSize()works the same on a file and a directory - Recursive operations should propagate through the tree — e.g., rendering a React component renders all its children automatically
Trade-offs¶
- ✅ Uniform interface — client code doesn't distinguish between leaf and composite
- ✅ Adding new component types is easy — just implement the common interface
- ⚠️ Can make it hard to restrict what a composite can contain — e.g., a file shouldn't have children, but the interface allows
add(). In production, throw runtime errors for invalid operations rather than exposing them on the interface. - ⚠️ Type safety is weakened — everything looks the same through the shared interface. Acceptable in tree-structured data; problematic when you need different operations per node type (consider Visitor instead).
Fitness Test
"Do I need to treat a single item and a group of items with the exact same interface?"
- Yes → ✅ Composite fits
- No, groups and individuals behave differently → ❌ Use separate classes
flowchart TB
A["FileSystemNode"] --> B[File]
A --> C[Directory]
C -->|contains| A
interface FileSystemNode {
getName(): string;
getSize(): number;
}
class File implements FileSystemNode {
constructor(private name: string, private size: number) {}
getName(): string { return this.name; }
getSize(): number { return this.size; }
}
class Directory implements FileSystemNode {
private children: FileSystemNode[] = [];
constructor(private name: string) {}
add(node: FileSystemNode): void {
this.children.push(node);
}
getName(): string { return this.name; }
getSize(): number {
return this.children.reduce((sum, child) => sum + child.getSize(), 0);
}
}
const root = new Directory("src");
root.add(new File("index.ts", 150));
const components = new Directory("components");
components.add(new File("App.tsx", 300));
components.add(new File("Header.tsx", 100));
root.add(components);
console.log(root.getSize()); // 550
Decorator¶
Attaches additional behavior to an object dynamically without modifying its structure. Use when you need to add responsibilities to objects at runtime — e.g., logging, caching, compression, or encryption layers.
Analogy
Like a coffee order at a café — you start with a base espresso, then layer on milk (latte), add vanilla syrup, top with whipped cream. Each addition wraps the previous drink, adds its cost and behavior, without modifying the espresso itself. This is exactly how Node.js middleware stacks work: each layer wraps the request, adds behavior (logging, auth, compression), and passes it through.
When to Use¶
- You need to add behavior dynamically without subclassing — e.g., layering logging, auth, and compression onto HTTP handlers in Express.js
- Responsibilities should be stackable and removable — e.g.,
FileSource → Encrypt → CompressvsFileSource → Compress - Class explosion from combining behaviors in subclasses — e.g.,
LoggingCachingAuthHandlervs stacking three independent decorators
Trade-offs¶
- ✅ Mix and match behaviors at runtime — more flexible than inheritance
- ✅ Single Responsibility — each decorator does one thing
- ⚠️ Many small objects — a deeply decorated stack can be hard to debug. In production, log the decorator chain at initialization to make the stack visible.
- ⚠️ Order-dependent —
Encrypt → Compress≠Compress → Encrypt. Document the intended stacking order; in middleware frameworks (Express, Laravel), pipeline order is explicit.
Fitness Test
"Do I need to add or remove behavior at runtime, and might I stack multiple behaviors on the same object?"
- Yes → ✅ Decorator fits
- No, behavior is fixed at compile time → ❌ Inheritance is simpler
flowchart LR
A[Client] --> B[CompressionDecorator]
B -->|wraps| C[EncryptionDecorator]
C -->|wraps| D[FileDataSource]
B -.->|implements| E[DataSource]
C -.->|implements| E
D -.->|implements| E
interface DataSource {
read(): string;
write(data: string): void;
}
class FileDataSource implements DataSource {
private data = "";
read(): string { return this.data; }
write(data: string): void { this.data = data; }
}
class EncryptionDecorator implements DataSource {
constructor(private source: DataSource) {}
read(): string {
return atob(this.source.read()); // decrypt
}
write(data: string): void {
this.source.write(btoa(data)); // encrypt
}
}
class CompressionDecorator implements DataSource {
constructor(private source: DataSource) {}
read(): string {
return `[decompressed] ${this.source.read()}`;
}
write(data: string): void {
this.source.write(`[compressed] ${data}`);
}
}
// Stack decorators: file → encryption → compression
let source: DataSource = new FileDataSource();
source = new EncryptionDecorator(source);
source = new CompressionDecorator(source);
source.write("secret data");
console.log(source.read());
Facade¶
Provides a simplified interface to a complex subsystem. Use when you want to hide complexity behind a single, easy-to-use API — e.g., a video conversion library, an order processing system, or a compiler pipeline.
Analogy
Like a hotel concierge — you say "plan me a nice evening" and they handle calling the restaurant, booking theater tickets, and arranging a taxi. You don't need three phone numbers, two websites, and an app. AWS Elastic Beanstalk is a facade: one command deploys your app, hiding load balancers, auto-scaling groups, and EC2 instances behind a simple interface.
When to Use¶
- A subsystem is complex and clients only need a simplified interface — e.g., a
Computer.start()method hiding CPU, memory, and disk initialization - You want to decouple client code from subsystem internals — e.g., an e-commerce
placeOrder()hiding inventory, payment, and shipping - You need a unified API for a group of related services — e.g., a
BookTripfacade calling airline, hotel, and car rental APIs
Trade-offs¶
- ✅ Simplifies usage — one method instead of coordinating multiple subsystems
- ✅ Subsystems can evolve independently — only the facade needs updating
- ⚠️ Can become a "god object" if too many responsibilities are funneled through it. In production, keep facades thin — they coordinate, not implement. Split into multiple facades if the surface area grows.
- ⚠️ Hides power-user functionality — some clients may need the full subsystem API. Expose the facade as the default but don't prevent direct subsystem access for advanced use cases.
Fitness Test
"Am I repeatedly writing the same sequence of subsystem calls in multiple places?"
- Yes → ✅ Facade fits
- No, only one caller uses the subsystem → ❌ Inline the calls
flowchart LR
A[Client] --> B[Computer]
B --> C[CPU]
B --> D[Memory]
B --> E[HardDrive]
// Complex subsystem classes
class CPU {
freeze(): string { return "CPU frozen"; }
execute(): string { return "CPU executing"; }
}
class Memory {
load(address: number, data: string): string {
return `Memory loaded '${data}' at ${address}`;
}
}
class HardDrive {
read(sector: number, size: number): string {
return `HardDrive read sector ${sector}, size ${size}`;
}
}
// Facade
class Computer {
private cpu = new CPU();
private memory = new Memory();
private hardDrive = new HardDrive();
start(): void {
console.log(this.cpu.freeze());
console.log(this.hardDrive.read(0, 1024));
console.log(this.memory.load(0, "bootloader"));
console.log(this.cpu.execute());
}
}
const computer = new Computer();
computer.start(); // Simple interface, complex internals
Flyweight¶
Shares common state across multiple objects to minimize memory usage. Use when you need to create a very large number of similar objects — e.g., characters in a text editor, particles in a game, or map markers.
Analogy
Like how a word processor stores fonts — if you type 10,000 letter 'e's, it doesn't store 10,000 copies of the font glyph. Every 'e' shares one glyph definition and only stores its unique position and size. Game engines render forests the same way: 10,000 trees share one mesh/texture (intrinsic state) and only store individual positions (extrinsic state).
When to Use¶
- You need a huge number of similar objects and memory is a constraint — e.g., 10,000 trees in a game world sharing one mesh/texture
- Objects can be split into shared (intrinsic) and unique (extrinsic) state — e.g., font glyphs shared across characters, each with unique position
- You're seeing memory pressure from object duplication — e.g., 500 map markers each duplicating identical icon data
Trade-offs¶
- ✅ Dramatic memory savings — thousands of objects share a few flyweight instances
- ✅ Cheaper object creation — factory returns existing instances instead of allocating new ones
- ⚠️ Trading memory for CPU — computing extrinsic state each time instead of storing it. Acceptable when memory is the bottleneck (mobile, embedded, game engines); less valuable when memory is abundant.
- ⚠️ Shared state must be immutable — any mutation affects all users. In production, make flyweight objects frozen/readonly and enforce immutability at the type level.
Fitness Test
"Am I creating thousands of similar objects that share most of their data, and is memory a concern?"
- Yes → ✅ Flyweight fits
- No, only a few objects → ❌ The sharing overhead isn't worth it
flowchart LR
A[Tree 1] --> D["TreeType (shared)"]
B[Tree 2] --> D
C[Tree 3] --> D
E[Tree N] --> D
// Shared state (intrinsic)
class TreeType {
constructor(
public name: string,
public color: string,
public texture: string
) {}
draw(x: number, y: number): void {
console.log(`Drawing ${this.name} at (${x}, ${y})`);
}
}
// Flyweight factory
class TreeFactory {
private static types = new Map<string, TreeType>();
static getTreeType(name: string, color: string, texture: string): TreeType {
const key = `${name}-${color}-${texture}`;
if (!this.types.has(key)) {
this.types.set(key, new TreeType(name, color, texture));
}
return this.types.get(key)!;
}
}
// Unique state (extrinsic) — only stores coordinates + shared reference
class Tree {
constructor(
private x: number,
private y: number,
private type: TreeType
) {}
draw(): void { this.type.draw(this.x, this.y); }
}
// 1000 trees but only a few TreeType objects in memory
const oak = TreeFactory.getTreeType("Oak", "green", "rough");
const trees = Array.from({ length: 1000 }, (_, i) =>
new Tree(Math.random() * 500, Math.random() * 500, oak)
);
Proxy¶
Provides a surrogate or placeholder for another object to control access to it. Use for lazy initialization, access control, logging, caching, or when the real object is expensive to create or lives remotely.
Analogy
Like a debit card for your bank account — it presents the same "pay" interface as cash, but behind the scenes it checks your balance, logs the transaction, and can block suspicious activity. You're not interacting with your money directly; you're going through a controlled intermediary. This is why ORMs use lazy-loading proxies: the proxy stands in for the real database object and only fetches it when you actually access its data.
When to Use¶
- You need lazy initialization of an expensive object — e.g., ORM lazy-loading related models only when accessed (Laravel's Eloquent relationships)
- You need access control before forwarding requests — e.g., authorization proxy checking user roles before allowing API calls
- You want to add cross-cutting concerns transparently — e.g., caching, logging, or metrics without changing the real object
Trade-offs¶
- ✅ Transparent to the client — same interface as the real object
- ✅ Separates cross-cutting concerns — logging, caching, auth stay out of business logic
- ⚠️ Added latency from indirection — each call goes through the proxy first. Negligible for most use cases; caching proxies actually reduce latency overall.
- ⚠️ Can mask the real object's behavior — e.g., a caching proxy may return stale data. In production, configure TTLs and cache invalidation strategies; make caching behavior explicit in naming (e.g.,
CachedUserService).
Fitness Test
"Do I need to control access to this object — lazy load it, check permissions, cache it, or log calls — without the client knowing?"
- Yes → ✅ Proxy fits
- No, direct access is fine → ❌ Skip the indirection
flowchart LR
A[Client] --> B[LazyImageProxy]
B -->|delegates| C[HighResImage]
B -.->|implements| D[Image]
C -.->|implements| D
interface Image {
display(): void;
}
class HighResImage implements Image {
constructor(private filename: string) {
this.loadFromDisk(); // expensive operation
}
private loadFromDisk(): void {
console.log(`Loading ${this.filename} from disk...`);
}
display(): void {
console.log(`Displaying ${this.filename}`);
}
}
class LazyImageProxy implements Image {
private realImage: HighResImage | null = null;
constructor(private filename: string) {}
display(): void {
if (!this.realImage) {
this.realImage = new HighResImage(this.filename);
}
this.realImage.display();
}
}
const image = new LazyImageProxy("photo.jpg");
// Image is NOT loaded yet
image.display(); // Now it loads and displays
image.display(); // Uses cached instance — no reload