Loading learning content...
Once objects exist, the next architectural challenge emerges: how do we compose them into larger, more capable structures? Individual objects—no matter how well-designed—rarely solve complex problems in isolation. Real systems require objects to work together, wrap each other, present unified interfaces, and form hierarchies.
Consider the composition challenges you encounter daily:
These scenarios reveal that composition is as fundamental a design concern as creation. Structural patterns provide proven solutions for assembling objects and classes into larger, flexible architectures.
This page explores Structural Design Patterns—the second category in the Gang of Four's taxonomy. You'll understand how these patterns use composition, aggregation, and inheritance to build complex structures from simpler parts. By the end, you'll recognize structural problems in your designs and know which pattern provides the appropriate solution.
Structural patterns are fundamentally about how objects connect. They address the static structure of object-oriented systems—how classes and objects are organized into larger wholes.
The composition challenge:
Object-oriented languages provide two primary mechanisms for building larger structures:
While inheritance is powerful, it creates tight coupling between classes. The Gang of Four famously advised: "Favor composition over inheritance." Structural patterns embody this principle, achieving flexibility through object composition rather than class inheritance.
What structural patterns accomplish:
Structural patterns use composition to create systems where:
Each structural pattern represents a specific way of combining objects to solve a recurring composition problem.
The Gang of Four identified seven structural patterns, each addressing a specific aspect of object and class composition. These patterns share a focus on how things connect rather than how they're created or how they communicate.
| Pattern | Primary Problem Solved | Key Mechanism |
|---|---|---|
| Adapter | Incompatible interfaces must work together | Wrapper translates between interfaces |
| Bridge | Abstraction and implementation vary independently | Separate hierarchies connected at runtime |
| Composite | Treat individual and composite objects uniformly | Tree structure with common interface |
| Decorator | Add responsibilities dynamically | Wrapping with same interface, added behavior |
| Facade | Simplify complex subsystem access | Unified interface over multiple components |
| Flyweight | Share common state among many objects | Intrinsic state separated from extrinsic |
| Proxy | Control access to another object | Substitute with same interface, added control |
What unifies structural patterns:
Composition-Based Solutions — All use object composition over inheritance
Interface Preservation — Many maintain the same interface while changing implementation
Flexibility Through Indirection — Insert an intermediary that enables adaptation or control
Static Structure Focus — Address how classes and objects are organized, not runtime behavior flow
These patterns often work together. A Facade might use Adapters internally. A Proxy might decorate an object. Understanding each pattern's focus helps you recognize when your design calls for structural solutions.
The Problem:
You have existing code that works with a specific interface, but you need to use a class with an incompatible interface. Maybe it's a third-party library, a legacy system, or code from another team. Modifying either side isn't desirable—you want them to work together without changes.
The Solution:
The Adapter pattern converts one interface to another that clients expect. It allows classes with incompatible interfaces to collaborate through an intermediary that translates requests.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
// Target interface - what your application expectsinterface PaymentGateway { processPayment(amount: number, currency: string): Promise<PaymentResult>; refund(transactionId: string, amount: number): Promise<RefundResult>; getTransactionStatus(transactionId: string): Promise<TransactionStatus>;} // Existing service with incompatible interfaceclass LegacyPaymentProcessor { // Different method names and signatures executeTransaction(amountInCents: number, currencyCode: string): string { console.log(`Processing ${amountInCents} cents in ${currencyCode}`); return "TXN-" + Date.now(); } reverseTransaction(txnId: string, refundCents: number): boolean { console.log(`Refunding ${refundCents} cents for ${txnId}`); return true; } checkStatus(txnId: string): { state: string; timestamp: Date } { return { state: "COMPLETED", timestamp: new Date() }; }} // Third-party payment SDK with yet another interfaceclass StripeSDK { createCharge(options: { amount: number; currency: string; description?: string }): { id: string; status: string } { return { id: `ch_${Date.now()}`, status: "succeeded" }; } createRefund(chargeId: string, amount?: number): { id: string } { return { id: `re_${Date.now()}` }; }} // Adapter for legacy system - Object Adapter (uses composition)class LegacyPaymentAdapter implements PaymentGateway { private legacyProcessor: LegacyPaymentProcessor; constructor(processor: LegacyPaymentProcessor) { this.legacyProcessor = processor; } async processPayment(amount: number, currency: string): Promise<PaymentResult> { // Translate: dollars to cents, adapt return type const amountInCents = Math.round(amount * 100); const txnId = this.legacyProcessor.executeTransaction(amountInCents, currency); return { success: true, transactionId: txnId, amount, currency }; } async refund(transactionId: string, amount: number): Promise<RefundResult> { const amountInCents = Math.round(amount * 100); const success = this.legacyProcessor.reverseTransaction(transactionId, amountInCents); return { success, originalTransactionId: transactionId }; } async getTransactionStatus(transactionId: string): Promise<TransactionStatus> { const status = this.legacyProcessor.checkStatus(transactionId); // Translate status formats return { transactionId, status: this.mapStatus(status.state), updatedAt: status.timestamp }; } private mapStatus(legacyState: string): "pending" | "completed" | "failed" { const mapping: Record<string, "pending" | "completed" | "failed"> = { "PENDING": "pending", "COMPLETED": "completed", "ERROR": "failed" }; return mapping[legacyState] || "pending"; }} // Adapter for Stripe SDKclass StripePaymentAdapter implements PaymentGateway { private stripe: StripeSDK; private transactionCache = new Map<string, { chargeId: string }>(); constructor(stripe: StripeSDK) { this.stripe = stripe; } async processPayment(amount: number, currency: string): Promise<PaymentResult> { const charge = this.stripe.createCharge({ amount: Math.round(amount * 100), currency: currency.toLowerCase() }); this.transactionCache.set(charge.id, { chargeId: charge.id }); return { success: charge.status === "succeeded", transactionId: charge.id, amount, currency }; } async refund(transactionId: string, amount: number): Promise<RefundResult> { const refund = this.stripe.createRefund(transactionId, Math.round(amount * 100)); return { success: true, originalTransactionId: transactionId }; } async getTransactionStatus(transactionId: string): Promise<TransactionStatus> { // Stripe SDK would have its own status check return { transactionId, status: "completed", updatedAt: new Date() }; }} // Client code works with the unified interfaceclass CheckoutService { constructor(private paymentGateway: PaymentGateway) {} async processOrder(orderId: string, amount: number): Promise<void> { const result = await this.paymentGateway.processPayment(amount, "USD"); if (result.success) { console.log(`Order ${orderId} paid via transaction ${result.transactionId}`); } }} // Same client code works with any adapted payment systemconst legacyAdapter = new LegacyPaymentAdapter(new LegacyPaymentProcessor());const stripeAdapter = new StripePaymentAdapter(new StripeSDK()); const checkoutWithLegacy = new CheckoutService(legacyAdapter);const checkoutWithStripe = new CheckoutService(stripeAdapter);There are two forms: Object Adapter uses composition (holds reference to adaptee)—this is more flexible and commonly used. Class Adapter uses multiple inheritance (extends adaptee, implements target)—possible in languages like C++, but creates tighter coupling. In most modern OOP, Object Adapter is preferred.
The Problem:
You need to add responsibilities to individual objects dynamically, without affecting other objects of the same class. Subclassing isn't practical because:
The Solution:
The Decorator pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality. Each decorator wraps the original object, adding its behavior before or after delegating to the wrapped object.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
// Component interfaceinterface DataSource { writeData(data: string): void; readData(): string;} // Concrete component - the core functionalityclass FileDataSource implements DataSource { private filename: string; private content: string = ""; constructor(filename: string) { this.filename = filename; } writeData(data: string): void { console.log(`Writing to file: ${this.filename}`); this.content = data; } readData(): string { console.log(`Reading from file: ${this.filename}`); return this.content; }} // Base decorator - implements same interface, wraps componentabstract class DataSourceDecorator implements DataSource { protected wrappee: DataSource; constructor(source: DataSource) { this.wrappee = source; } writeData(data: string): void { this.wrappee.writeData(data); } readData(): string { return this.wrappee.readData(); }} // Concrete decorator - adds encryptionclass EncryptionDecorator extends DataSourceDecorator { private encryptionKey: string; constructor(source: DataSource, key: string) { super(source); this.encryptionKey = key; } writeData(data: string): void { // Encrypt before writing const encrypted = this.encrypt(data); console.log("Encrypting data..."); super.writeData(encrypted); } readData(): string { // Decrypt after reading const encrypted = super.readData(); console.log("Decrypting data..."); return this.decrypt(encrypted); } private encrypt(data: string): string { // Simplified encryption for demonstration return Buffer.from(data).toString("base64"); } private decrypt(data: string): string { return Buffer.from(data, "base64").toString("utf8"); }} // Concrete decorator - adds compressionclass CompressionDecorator extends DataSourceDecorator { writeData(data: string): void { // Compress before writing const compressed = this.compress(data); console.log(`Compressing data (${data.length} -> ${compressed.length} chars)...`); super.writeData(compressed); } readData(): string { // Decompress after reading const compressed = super.readData(); console.log("Decompressing data..."); return this.decompress(compressed); } private compress(data: string): string { // Simplified compression simulation return `COMPRESSED[${data}]`; } private decompress(data: string): string { return data.replace(/^COMPRESSED\[(.*)\]$/, "$1"); }} // Concrete decorator - adds loggingclass LoggingDecorator extends DataSourceDecorator { writeData(data: string): void { console.log(`[LOG] Writing ${data.length} characters at ${new Date().toISOString()}`); super.writeData(data); console.log("[LOG] Write operation completed"); } readData(): string { console.log(`[LOG] Read operation started at ${new Date().toISOString()}`); const result = super.readData(); console.log(`[LOG] Read ${result.length} characters`); return result; }} // Client can compose decorators in any combinationlet source: DataSource = new FileDataSource("user-data.txt"); // Add encryptionsource = new EncryptionDecorator(source, "secret-key-123"); // Add compression (on top of encryption)source = new CompressionDecorator(source); // Add logging (outermost layer)source = new LoggingDecorator(source); // Data flows through all decoratorssource.writeData("Sensitive user information");// Output:// [LOG] Writing 28 characters at 2024-01-15T10:30:00.000Z// Compressing data (28 -> 38 chars)...// Encrypting data...// Writing to file: user-data.txt// [LOG] Write operation completed const data = source.readData();// Output:// [LOG] Read operation started at 2024-01-15T10:30:01.000Z// Reading from file: user-data.txt// Decrypting data...// Decompressing data...// [LOG] Read 28 charactersDecorators are everywhere: Java's I/O streams (BufferedInputStream wrapping FileInputStream), middleware in web frameworks (authentication, logging, compression), React's Higher-Order Components, TypeScript/Python decorators (@deprecated, @logged), and browser's fetch() with interceptors. Any time you see wrapping with the same interface, you're likely seeing Decorator.
Decorator vs. Inheritance:
| Aspect | Inheritance | Decorator |
|---|---|---|
| When decided | Compile time | Runtime |
| Combinations | Class explosion | Composable wrappers |
| Granularity | All instances | Individual objects |
| Flexibility | Static | Dynamic add/remove |
| Transparency | Type changes | Same interface preserved |
The Problem:
You have a complex subsystem with many classes and intricate interactions. Clients must understand numerous components, their relationships, and the correct sequence of operations. This complexity leaks into client code, creating tight coupling and steep learning curves.
The Solution:
The Facade pattern provides a unified, higher-level interface to a complex subsystem. It makes the subsystem easier to use by providing a simpler API that handles the common use cases, while still allowing access to the underlying complexity when needed.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
// Complex video conversion subsystemclass VideoFile { constructor(public filename: string) {} getCodec(): string { return this.filename.endsWith(".mp4") ? "h264" : "unknown"; }} class CodecFactory { static extract(file: VideoFile): Codec { const type = file.getCodec(); if (type === "h264") return new H264Codec(); if (type === "vp9") return new VP9Codec(); throw new Error(`Unsupported codec: ${type}`); }} interface Codec { decode(data: Buffer): VideoStream; encode(stream: VideoStream): Buffer;} class H264Codec implements Codec { decode(data: Buffer): VideoStream { return new VideoStream(); } encode(stream: VideoStream): Buffer { return Buffer.from([]); }} class VP9Codec implements Codec { decode(data: Buffer): VideoStream { return new VideoStream(); } encode(stream: VideoStream): Buffer { return Buffer.from([]); }} class VideoStream { frames: Frame[] = [];} class Frame { data: Uint8Array = new Uint8Array();} class BitrateCalculator { calculate(stream: VideoStream, targetQuality: string): number { const qualityMultipliers: Record<string, number> = { "low": 0.5, "medium": 1.0, "high": 2.0, "ultra": 4.0 }; return 5000 * (qualityMultipliers[targetQuality] || 1.0); }} class AudioMixer { extractAudio(file: VideoFile): AudioStream { return new AudioStream(); } mixAudio(audio: AudioStream, volume: number): AudioStream { return audio; }} class AudioStream { samples: Float32Array = new Float32Array();} class VideoProcessor { process(stream: VideoStream, bitrate: number): VideoStream { console.log(`Processing video at ${bitrate} kbps`); return stream; }} class OutputFormatter { format(video: VideoStream, audio: AudioStream, container: string): Buffer { console.log(`Formatting output as ${container}`); return Buffer.from([]); }} // FACADE - simplifies the complex subsystemclass VideoConversionFacade { private codecFactory: typeof CodecFactory = CodecFactory; private bitrateCalc: BitrateCalculator = new BitrateCalculator(); private audioMixer: AudioMixer = new AudioMixer(); private processor: VideoProcessor = new VideoProcessor(); private formatter: OutputFormatter = new OutputFormatter(); /** * Convert video file to specified format with quality preset * Encapsulates all the complexity of the conversion process */ convertVideo( inputFile: string, outputFormat: string, quality: "low" | "medium" | "high" | "ultra" = "medium" ): Buffer { console.log(`Starting conversion of ${inputFile} to ${outputFormat}...`); // Load and decode input const file = new VideoFile(inputFile); const codec = this.codecFactory.extract(file); const videoStream = codec.decode(Buffer.from([])); // Process audio const audioStream = this.audioMixer.extractAudio(file); const mixedAudio = this.audioMixer.mixAudio(audioStream, 1.0); // Calculate optimal bitrate for quality const bitrate = this.bitrateCalc.calculate(videoStream, quality); // Process video const processedVideo = this.processor.process(videoStream, bitrate); // Format output const result = this.formatter.format(processedVideo, mixedAudio, outputFormat); console.log("Conversion complete!"); return result; } /** * Quick convert with sensible defaults */ quickConvertToMP4(inputFile: string): Buffer { return this.convertVideo(inputFile, "mp4", "medium"); } /** * Get video metadata without full conversion */ getVideoInfo(inputFile: string): VideoInfo { const file = new VideoFile(inputFile); return { filename: inputFile, codec: file.getCodec(), // ... additional metadata extraction }; }} interface VideoInfo { filename: string; codec: string;} // Client code - dramatically simplified!const converter = new VideoConversionFacade(); // Common operation: one simple call instead of managing 8+ subsystem classesconst result = converter.convertVideo("home-movie.avi", "mp4", "high"); // Even simpler for the most common caseconst quickResult = converter.quickConvertToMP4("vacation.mov");Both wrap other code, but with different purposes: Adapter makes an incompatible interface compatible (translation). Facade simplifies a complex interface (reduction). Adapter is about compatibility; Facade is about convenience. A Facade might use Adapters internally when wrapping heterogeneous subsystems.
The Problem:
You have a tree structure where objects can contain other objects of the same type. Clients must distinguish between leaf nodes and container nodes, leading to type-checking code scattered throughout. You want clients to treat individual objects and compositions uniformly.
The Solution:
The Composite pattern composes objects into tree structures and lets clients treat individual objects and compositions uniformly. Both leaves and composites implement the same interface, so client code can work with any node without knowing whether it's a leaf or a branch.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
// Component interface - common to leaves and compositesinterface UIComponent { render(depth?: number): string; getWidth(): number; getHeight(): number; // Optional: child management can be in interface or composite only add?(component: UIComponent): void; remove?(component: UIComponent): void;} // Leaf components - no childrenclass Button implements UIComponent { constructor( private label: string, private width: number = 100, private height: number = 40 ) {} render(depth = 0): string { const indent = " ".repeat(depth); return `${indent}<Button>${this.label}</Button>`; } getWidth(): number { return this.width; } getHeight(): number { return this.height; }} class TextInput implements UIComponent { constructor( private placeholder: string, private width: number = 200, private height: number = 40 ) {} render(depth = 0): string { const indent = " ".repeat(depth); return `${indent}<TextInput placeholder="${this.placeholder}" />`; } getWidth(): number { return this.width; } getHeight(): number { return this.height; }} class Image implements UIComponent { constructor( private src: string, private width: number, private height: number ) {} render(depth = 0): string { const indent = " ".repeat(depth); return `${indent}<Image src="${this.src}" />`; } getWidth(): number { return this.width; } getHeight(): number { return this.height; }} // Composite - contains other components (leaves or other composites)class Container implements UIComponent { private children: UIComponent[] = []; private direction: "horizontal" | "vertical"; private gap: number; constructor( private name: string, direction: "horizontal" | "vertical" = "vertical", gap: number = 10 ) { this.direction = direction; this.gap = gap; } add(component: UIComponent): void { this.children.push(component); } remove(component: UIComponent): void { const index = this.children.indexOf(component); if (index !== -1) { this.children.splice(index, 1); } } render(depth = 0): string { const indent = " ".repeat(depth); const childrenRendered = this.children .map(child => child.render(depth + 1)) .join("\n"); return `${indent}<${this.name} direction="${this.direction}">${childrenRendered}${indent}</${this.name}>`; } // Aggregate calculations - recursive composition getWidth(): number { if (this.children.length === 0) return 0; if (this.direction === "horizontal") { // Sum widths plus gaps return this.children.reduce( (sum, child) => sum + child.getWidth(), 0 ) + (this.children.length - 1) * this.gap; } else { // Max width return Math.max(...this.children.map(c => c.getWidth())); } } getHeight(): number { if (this.children.length === 0) return 0; if (this.direction === "vertical") { // Sum heights plus gaps return this.children.reduce( (sum, child) => sum + child.getHeight(), 0 ) + (this.children.length - 1) * this.gap; } else { // Max height return Math.max(...this.children.map(c => c.getHeight())); } }} // Build a complex UI tree uniformlyconst loginForm = new Container("Form", "vertical", 15); const headerRow = new Container("Header", "horizontal", 10);headerRow.add(new Image("/logo.png", 50, 50)); const inputGroup = new Container("InputGroup", "vertical", 10);inputGroup.add(new TextInput("Username"));inputGroup.add(new TextInput("Password")); const buttonRow = new Container("ButtonRow", "horizontal", 10);buttonRow.add(new Button("Cancel", 80, 40));buttonRow.add(new Button("Login", 100, 40)); loginForm.add(headerRow);loginForm.add(inputGroup);loginForm.add(buttonRow); // Client treats entire tree uniformlyconsole.log(loginForm.render());console.log(`Total size: ${loginForm.getWidth()}x${loginForm.getHeight()}`); // Same operations work on any componentfunction measureComponent(component: UIComponent): string { return `Width: ${component.getWidth()}, Height: ${component.getHeight()}`;} // Works for leaves and composites alikeconsole.log(measureComponent(new Button("Solo Button")));console.log(measureComponent(loginForm));Composite is fundamental in: (1) UI frameworks (React components, DOM tree, Swing containers), (2) File systems (files and directories), (3) Organization hierarchies (employees and departments), (4) Graphics (shapes containing shapes), (5) Document structures (sections, paragraphs, characters). Any hierarchical 'part-whole' relationship benefits from Composite.
The Problem:
You need to control access to an object—perhaps to add lazy initialization, access control, logging, caching, or remote access semantics. You want this control without modifying the original object or changing how clients use it.
The Solution:
The Proxy pattern provides a surrogate or placeholder for another object to control access to it. The proxy has the same interface as the real object, so clients use it transparently, while the proxy intercepts requests and adds its control logic.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
// Subject interfaceinterface Database { query(sql: string): Promise<QueryResult>; execute(sql: string): Promise<number>; getConnection(): Connection;} interface QueryResult { rows: Record<string, unknown>[]; rowCount: number;} interface Connection { id: string; isActive: boolean;} // Real subject - the actual database implementationclass PostgresDatabase implements Database { private connectionId: string; constructor(connectionString: string) { console.log(`Connecting to database: ${connectionString}`); this.connectionId = `conn_${Date.now()}`; // Expensive connection establishment } async query(sql: string): Promise<QueryResult> { console.log(`Executing query: ${sql}`); // Real database query return { rows: [], rowCount: 0 }; } async execute(sql: string): Promise<number> { console.log(`Executing statement: ${sql}`); return 1; } getConnection(): Connection { return { id: this.connectionId, isActive: true }; }} // Virtual Proxy - lazy initializationclass LazyDatabaseProxy implements Database { private realDatabase: PostgresDatabase | null = null; private connectionString: string; constructor(connectionString: string) { // Don't connect yet - just store config this.connectionString = connectionString; console.log("Database proxy created (not connected yet)"); } private ensureConnected(): PostgresDatabase { if (!this.realDatabase) { console.log("First use - establishing connection..."); this.realDatabase = new PostgresDatabase(this.connectionString); } return this.realDatabase; } async query(sql: string): Promise<QueryResult> { return this.ensureConnected().query(sql); } async execute(sql: string): Promise<number> { return this.ensureConnected().execute(sql); } getConnection(): Connection { return this.ensureConnected().getConnection(); }} // Protection Proxy - access controlclass SecureDatabaseProxy implements Database { private database: Database; private userRole: "admin" | "user" | "readonly"; constructor(database: Database, userRole: "admin" | "user" | "readonly") { this.database = database; this.userRole = userRole; } async query(sql: string): Promise<QueryResult> { // All roles can query this.logAccess("QUERY", sql); return this.database.query(sql); } async execute(sql: string): Promise<number> { // Check permissions for write operations if (this.userRole === "readonly") { throw new Error("Access denied: readonly users cannot execute statements"); } // Check for dangerous operations const normalized = sql.toUpperCase(); if (normalized.includes("DROP") || normalized.includes("TRUNCATE")) { if (this.userRole !== "admin") { throw new Error("Access denied: only admins can perform destructive operations"); } } this.logAccess("EXECUTE", sql); return this.database.execute(sql); } getConnection(): Connection { return this.database.getConnection(); } private logAccess(operation: string, sql: string): void { console.log(`[${this.userRole}] ${operation}: ${sql.substring(0, 50)}...`); }} // Caching Proxy - result cachingclass CachingDatabaseProxy implements Database { private database: Database; private cache: Map<string, { result: QueryResult; timestamp: number }> = new Map(); private cacheTTL: number = 60000; // 1 minute constructor(database: Database, cacheTTL?: number) { this.database = database; if (cacheTTL) this.cacheTTL = cacheTTL; } async query(sql: string): Promise<QueryResult> { // Check cache for SELECT queries if (sql.trim().toUpperCase().startsWith("SELECT")) { const cached = this.cache.get(sql); if (cached && Date.now() - cached.timestamp < this.cacheTTL) { console.log("Cache hit!"); return cached.result; } } console.log("Cache miss - querying database"); const result = await this.database.query(sql); // Cache SELECT results if (sql.trim().toUpperCase().startsWith("SELECT")) { this.cache.set(sql, { result, timestamp: Date.now() }); } return result; } async execute(sql: string): Promise<number> { // Invalidate cache on writes this.cache.clear(); return this.database.execute(sql); } getConnection(): Connection { return this.database.getConnection(); }} // Compose proxies for layered behaviorlet db: Database = new LazyDatabaseProxy("postgres://localhost/mydb");db = new CachingDatabaseProxy(db, 30000);db = new SecureDatabaseProxy(db, "user"); // Client uses it like a normal databaseawait db.query("SELECT * FROM users"); // Lazy init + cache missawait db.query("SELECT * FROM users"); // Cache hitawait db.execute("INSERT INTO logs VALUES (...)"); // Cache cleared| Proxy Type | Purpose | Example Use Case |
|---|---|---|
| Virtual Proxy | Lazy initialization of expensive objects | Database connections, large images |
| Protection Proxy | Access control based on permissions | Role-based API access |
| Remote Proxy | Local representative for remote object | RPC, web service clients |
| Caching Proxy | Store results to avoid repeated work | API response caching |
| Logging Proxy | Record access for debugging/auditing | Request logging, metrics |
The remaining two structural patterns address more specialized concerns:
Bridge Pattern:
Bridge separates an abstraction from its implementation, allowing both to vary independently. Use Bridge when:
// Abstraction
abstract class RemoteControl {
constructor(protected device: Device) {}
togglePower(): void { /* ... */ }
}
// Implementor
interface Device {
enable(): void;
disable(): void;
}
// Refined abstractions and concrete implementors vary independently
class AdvancedRemote extends RemoteControl { /* ... */ }
class TVDevice implements Device { /* ... */ }
class RadioDevice implements Device { /* ... */ }
Flyweight Pattern:
Flyweight minimizes memory by sharing common parts of state between multiple objects. Use Flyweight when:
// Flyweight with intrinsic state (shared)
class TreeType {
constructor(
public name: string,
public color: string,
public texture: Texture // Heavy resource - shared!
) {}
}
// Context with extrinsic state (unique per instance)
class Tree {
constructor(
public x: number,
public y: number,
public type: TreeType // Reference to shared flyweight
) {}
}
// Factory ensures sharing
class TreeFactory {
private types: Map<string, TreeType> = new Map();
getTreeType(name: string, color: string, texture: Texture): TreeType {
const key = `${name}_${color}`;
if (!this.types.has(key)) {
this.types.set(key, new TreeType(name, color, texture));
}
return this.types.get(key)!;
}
}
// A forest with millions of trees shares just a few TreeType instances
Bridge is valuable when you're building cross-platform systems or dealing with multiple orthogonal dimensions of variation. Flyweight becomes critical when you're rendering thousands of similar objects (game entities, document characters, map icons). Both are more specialized than Adapter, Decorator, Facade, Composite, and Proxy—learn those first, then dive deeper into Bridge and Flyweight when their specific problems arise.
Structural patterns address the second fundamental challenge in object-oriented design: how do we compose objects into larger, flexible structures? By favoring composition over inheritance, these patterns create systems that adapt to change without the rigidity of class hierarchies.
You now understand Structural Patterns—the second category of the Gang of Four taxonomy. Next, we'll explore Behavioral Patterns, which govern how objects cooperate and distribute responsibilities. Where structural patterns manage how things connect, behavioral patterns manage how things communicate and collaborate.