Loading learning content...
Experienced engineers often select patterns through intuition—a pattern 'feels right' for a given problem. But intuition is unreliable for beginners and dangerous for critical systems. What we need is a systematic decision framework that transforms pattern selection into a repeatable, teachable process.
This page presents a comprehensive decision tree for structural pattern selection. By asking a structured sequence of questions about your problem domain, you can navigate to the appropriate pattern—or determine that no pattern is needed. This isn't about replacing wisdom with rules; it's about building the foundation upon which wisdom eventually develops.
By the end of this page, you will be able to systematically analyze any structural design problem and navigate to the appropriate pattern (or non-pattern solution). You'll internalize a questioning discipline that Principal Engineers use—often unconsciously—when facing design challenges.
Before diving into the decision tree, we need to establish the foundational questions that guide pattern selection. These meta-questions help you frame the problem correctly:
Question Zero: Do I Need a Pattern at All?
This is the most important question, and it's often skipped. Patterns add complexity. If a simple, straightforward solution exists, use it. Ask:
Question One: What Type of Problem Am I Facing?
Structural patterns address specific problem types:
Question Two: What Are the Key Forces?
Forces are the constraints and requirements that shape your solution:
Seasoned engineers always ask: 'What problem am I actually solving?' before considering patterns. Half of pattern misuse comes from solving the wrong problem. Spend time understanding the real issue before jumping to solutions.
The following decision tree guides you from problem recognition to pattern selection. Start at the root and follow the branches based on your specific situation.
Using the Decision Tree:
When you identify an interface mismatch—existing code that doesn't fit your system's expectations—you need to determine the scope and nature of the mismatch.
12345678910111213141516171819202122232425262728293031
// SCENARIO: Using a third-party XML parser in a system expecting JSON-like interface // Third-party library (cannot modify)class LegacyXMLParser { parseXML(xmlString: string): XMLDocument { /* ... */ } getNodeValue(doc: XMLDocument, xpath: string): string { /* ... */ }} // Your system expects this interfaceinterface DataParser { parse(input: string): Record<string, unknown>;} // SOLUTION: Adapter translates interfaceclass XMLParserAdapter implements DataParser { private xmlParser = new LegacyXMLParser(); parse(input: string): Record<string, unknown> { const doc = this.xmlParser.parseXML(input); // Convert XML nodes to JSON-like structure return this.convertToRecord(doc); } private convertToRecord(doc: XMLDocument): Record<string, unknown> { // Translation logic here return {}; }} // Now LegacyXMLParser can be used wherever DataParser is expectedconst parser: DataParser = new XMLParserAdapter();When you need to add behavior or capabilities to objects, the decision between Decorator, Proxy, and simpler alternatives depends on subtle but critical factors.
| Question | If Yes... | If No... |
|---|---|---|
| Do you need to add new responsibilities/behavior? | Decorator candidate | Continue to next question |
| Must the enhancement be removable at runtime? | Strong Decorator indicator | Consider inheritance/mixins |
| Should enhancements be stackable/combinable? | Decorator (designed for chaining) | May not need full pattern |
| Is the 'enhancement' actually access control? | Proxy (caching, lazy loading, security) | Not Proxy |
| Does the enhancement manage the object's lifecycle? | Proxy (creation, destruction) | Decorator (behavior only) |
| Should clients be unaware of the enhancement? | Proxy (invisible indirection) | Decorator (visible composition) |
Decorator isn't always better than inheritance. Use inheritance when: • The behavior additions are fixed and won't be combined dynamically • You need compile-time type safety for specific combinations • Performance is critical (Decorator adds call overhead)
Use Decorator when: • Behaviors need to be mixed and matched at runtime • The number of combinations would cause class explosion with inheritance • You need to add/remove behaviors without affecting other objects
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// QUESTION: Need to add encryption, compression, and buffering to streams// ANALYSIS: // - Behaviors should be combinable (encryption + compression? just buffering?)// - Should be removable/changeable at runtime// - Behaviors stack naturally (first buffer, then compress, then encrypt)// DECISION: Decorator ✓ interface Stream { write(data: Buffer): void; read(size: number): Buffer;} class FileStream implements Stream { write(data: Buffer): void { /* write to file */ } read(size: number): Buffer { /* read from file */ return Buffer.alloc(0); }} // Base decoratorabstract class StreamDecorator implements Stream { constructor(protected wrapped: Stream) {} abstract write(data: Buffer): void; abstract read(size: number): Buffer;} class BufferedStream extends StreamDecorator { private buffer: Buffer[] = []; write(data: Buffer): void { this.buffer.push(data); if (this.buffer.length >= 10) this.flush(); } read(size: number): Buffer { return this.wrapped.read(size); } private flush(): void { this.wrapped.write(Buffer.concat(this.buffer)); this.buffer = []; }} class EncryptedStream extends StreamDecorator { write(data: Buffer): void { const encrypted = this.encrypt(data); this.wrapped.write(encrypted); } read(size: number): Buffer { return this.decrypt(this.wrapped.read(size)); } private encrypt(data: Buffer): Buffer { /* encryption */ return data; } private decrypt(data: Buffer): Buffer { /* decryption */ return data; }} // Flexible composition at runtimelet stream: Stream = new FileStream();stream = new BufferedStream(stream); // Add bufferingstream = new EncryptedStream(stream); // Add encryption on top// Now writes are buffered, then encryptedWhen building larger structures from smaller components, you must distinguish between true part-whole hierarchies (Composite) and simpler containment relationships.
Bridge and Flyweight are the most specialized structural patterns. They solve specific problems that arise less frequently, but when those problems appear, these patterns are often the only elegant solutions.
Bridge is appropriate when you have two independent dimensions of variation that would otherwise cause class explosion. The classic signal: you're about to create classes named like RedCircle, BlueCircle, RedSquare, BlueSquare... This Cartesian product pattern screams for Bridge.
Flyweight is appropriate when you have many objects with significant shareable state. The classic signal: memory profiling shows thousands of similar objects consuming excessive memory. Before applying Flyweight, verify that intrinsic (shareable) and extrinsic (context-specific) state can be cleanly separated.
Let's apply the decision tree to real scenarios, demonstrating the systematic questioning process.
Problem: Your e-commerce system needs to support multiple payment gateways (Stripe, PayPal, Square). Each has a different API. You want to add new gateways without modifying existing code.
Decision Tree Navigation:
Confirmation: Each payment gateway adapter implements a common PaymentProcessor interface, translating calls to the specific gateway's API.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
interface PaymentProcessor { charge(amount: number, currency: string, token: string): PaymentResult; refund(transactionId: string, amount: number): RefundResult;} // Stripe Adapterclass StripeAdapter implements PaymentProcessor { private stripe: StripeSDK; charge(amount: number, currency: string, token: string): PaymentResult { // Translate to Stripe's API const stripeCharge = this.stripe.charges.create({ amount: amount * 100, // Stripe uses cents currency: currency.toLowerCase(), source: token, }); return this.mapToPaymentResult(stripeCharge); } refund(transactionId: string, amount: number): RefundResult { const refund = this.stripe.refunds.create({ charge: transactionId, amount: amount * 100, }); return this.mapToRefundResult(refund); }} // PayPal Adapterclass PayPalAdapter implements PaymentProcessor { private paypal: PayPalSDK; charge(amount: number, currency: string, token: string): PaymentResult { // Translate to PayPal's API (different structure) const payment = this.paypal.payment.execute({ payerId: token, transactions: [{ amount: { total: amount.toFixed(2), currency } }], }); return this.mapToPaymentResult(payment); } // ... refund implementation} // Usage: Gateway selection is now configuration, not code changeconst processor: PaymentProcessor = config.gateway === 'stripe' ? new StripeAdapter() : new PayPalAdapter();The decision tree transforms pattern selection from guesswork to systematic analysis. Let's consolidate the key decision points:
What's Next:
In practice, complex systems rarely need just one pattern. The next page explores Pattern Combinations—how structural patterns work together, which patterns naturally complement each other, and how to compose multiple patterns without creating an unmaintainable mess.
You now have a systematic decision tree for structural pattern selection. With practice, these questions become automatic—part of your engineering intuition. The tree isn't a replacement for understanding; it's a scaffold that supports understanding as it develops.