Pattern Selection Guide
Choosing the right design pattern is as important as implementing it correctly. This lesson provides a systematic approach to pattern selection and guidelines for when to use (or avoid) patterns.
The Pattern Selection Process
1. Identify the Core Problem
Before selecting a pattern, clearly identify what problem you're trying to solve.
Ask yourself:
- What is changing or needs to be flexible?
- What is the source of complexity?
- What might need to be extended or modified in the future?
Example Problem Analysis:
typescript1// Problem: Need different rate calculations for different shipment types2// What's changing? The rate calculation algorithm3// Source of complexity? Multiple calculation methods with different logic4// Future needs? Adding new calculation methods without modifying existing code56// Solution indicator: Strategy Pattern
2. Match Problem to Pattern Category
Design patterns fall into three categories based on the problems they solve:
Creational Patterns - Object creation flexibility
- Use when: You need control over how objects are created
- Examples: Factory, Builder, Singleton, Prototype
Structural Patterns - Object composition and relationships
- Use when: You need to compose objects or adapt interfaces
- Examples: Adapter, Decorator, Facade, Composite
Behavioral Patterns - Object interaction and responsibility
- Use when: You need to define how objects communicate or behave
- Examples: Strategy, Observer, Command, State
3. Use the Pattern Decision Flowchart
1START: Do you need to...23├─ Create objects?4│ ├─ Complex construction process? → Builder5│ ├─ Create family of related objects? → Abstract Factory6│ ├─ Delegate creation to subclasses? → Factory Method7│ ├─ Ensure single instance? → Singleton8│ └─ Clone existing objects? → Prototype9│10├─ Compose objects or adapt interfaces?11│ ├─ Make incompatible interfaces work? → Adapter12│ ├─ Add responsibilities dynamically? → Decorator13│ ├─ Simplify complex subsystem? → Facade14│ ├─ Treat objects uniformly in tree structure? → Composite15│ ├─ Control access to object? → Proxy16│ └─ Separate abstraction from implementation? → Bridge17│18└─ Define object behavior or interaction?19 ├─ Switch algorithm at runtime? → Strategy20 ├─ Notify multiple objects of changes? → Observer21 ├─ Encapsulate requests as objects? → Command22 ├─ Change behavior based on state? → State23 ├─ Chain request handlers? → Chain of Responsibility24 ├─ Coordinate object interactions? → Mediator25 ├─ Add operations to structure without modifying? → Visitor26 ├─ Access elements sequentially? → Iterator27 └─ Define grammar and interpret? → Interpreter
Pattern Selection by Problem Type
Problem: Creating Objects
When to use Factory Method
Indicators:
- Need to delegate object creation to subclasses
- Don't know exact class until runtime
- Want to encapsulate object creation logic
typescript1// Use Factory Method when creation logic varies by subtype2interface Carrier {3 ship(package: Package): TrackingNumber;4}56abstract class CarrierFactory {7 abstract createCarrier(): Carrier;89 shipPackage(pkg: Package): TrackingNumber {10 const carrier = this.createCarrier();11 return carrier.ship(pkg);12 }13}1415class FedExFactory extends CarrierFactory {16 createCarrier(): Carrier {17 return new FedExCarrier();18 }19}
When to use Builder
Indicators:
- Object has many optional parameters
- Construction requires multiple steps
- Want to create different representations
- Need validation during construction
typescript1// Use Builder when construction is complex with many options2class ShipmentBuilder {3 private shipment: Partial<Shipment> = {};45 setOrigin(address: Address): this {6 this.shipment.origin = address;7 return this;8 }910 setDestination(address: Address): this {11 this.shipment.destination = address;12 return this;13 }1415 // Many more optional settings...1617 build(): Shipment {18 this.validate();19 return this.shipment as Shipment;20 }21}
When to use Singleton
Indicators:
- Exactly one instance needed globally
- Need controlled access to single instance
- Instance should be lazy-initialized
Warning: Singleton is often overused. Consider dependency injection instead.
typescript1// Use Singleton sparingly - often for configuration or logging2class ConfigurationManager {3 private static instance: ConfigurationManager;4 private config: AppConfig;56 private constructor() {7 this.config = this.loadConfig();8 }910 static getInstance(): ConfigurationManager {11 if (!ConfigurationManager.instance) {12 ConfigurationManager.instance = new ConfigurationManager();13 }14 return ConfigurationManager.instance;15 }16}
Problem: Composing Objects
When to use Decorator
Indicators:
- Need to add responsibilities to individual objects
- Want to add features dynamically
- Inheritance would create too many subclasses
- Need to stack multiple enhancements
typescript1// Use Decorator when you need to add features dynamically2interface Shipment {3 getCost(): number;4}56class BasicShipment implements Shipment {7 getCost(): number {8 return 10.00;9 }10}1112class InsuredShipment implements Shipment {13 constructor(private shipment: Shipment) {}1415 getCost(): number {16 return this.shipment.getCost() + 5.00; // Add insurance cost17 }18}1920// Can stack decorators21const shipment = new InsuredShipment(22 new TrackedShipment(23 new BasicShipment()24 )25);
When to use Composite
Indicators:
- Objects form tree structure
- Want to treat individual and composite objects uniformly
- Need part-whole hierarchy
typescript1// Use Composite when you have tree structures2interface ShippingItem {3 getWeight(): number;4}56class Package implements ShippingItem {7 constructor(private weight: number) {}89 getWeight(): number {10 return this.weight;11 }12}1314class Container implements ShippingItem {15 private items: ShippingItem[] = [];1617 add(item: ShippingItem): void {18 this.items.push(item);19 }2021 getWeight(): number {22 return this.items.reduce((sum, item) => sum + item.getWeight(), 0);23 }24}2526// Treat packages and containers uniformly27function calculateShippingCost(item: ShippingItem): number {28 return item.getWeight() * 0.5; // Works for both!29}
When to use Facade
Indicators:
- Complex subsystem with many classes
- Want simple interface to complex system
- Need to reduce coupling to subsystem
- Want to layer your system
typescript1// Use Facade to simplify complex subsystems2class ShippingFacade {3 private addressValidator: AddressValidator;4 private rateCalculator: RateCalculator;5 private labelGenerator: LabelGenerator;6 private trackingSystem: TrackingSystem;78 // Simple interface hiding complexity9 createShipment(origin: Address, destination: Address, pkg: Package): ShipmentResult {10 this.addressValidator.validate(origin);11 this.addressValidator.validate(destination);1213 const rate = this.rateCalculator.calculate(origin, destination, pkg);14 const label = this.labelGenerator.generate(origin, destination);15 const tracking = this.trackingSystem.register(label.id);1617 return { rate, label, tracking };18 }19}
Problem: Defining Behavior
When to use Strategy
Indicators:
- Multiple algorithms for same operation
- Need to switch algorithms at runtime
- Want to eliminate conditional statements
- Algorithms are independent of clients
typescript1// Use Strategy when you have interchangeable algorithms2interface RateStrategy {3 calculate(shipment: Shipment): number;4}56class RateCalculator {7 constructor(private strategy: RateStrategy) {}89 setStrategy(strategy: RateStrategy): void {10 this.strategy = strategy;11 }1213 calculate(shipment: Shipment): number {14 return this.strategy.calculate(shipment);15 }16}1718// Easy to add new strategies19class WeightBasedStrategy implements RateStrategy {20 calculate(shipment: Shipment): number {21 return shipment.weight * 0.5;22 }23}
When to use Observer
Indicators:
- One-to-many dependency between objects
- Changes in one object should notify others
- Don't know number of dependents in advance
- Need loose coupling between subject and observers
typescript1// Use Observer when objects need to react to changes2class PackageTracker {3 private observers: Set<TrackingObserver> = new Set();45 attach(observer: TrackingObserver): void {6 this.observers.add(observer);7 }89 updateStatus(tracking: string, status: Status): void {10 // Notify all observers11 this.observers.forEach(observer => {12 observer.update({ tracking, status });13 });14 }15}
When to use State
Indicators:
- Object behavior depends on state
- Large conditional statements based on state
- State transitions are well-defined
- Each state has different behavior
typescript1// Use State when behavior changes based on state2interface ShipmentState {3 ship(context: ShipmentContext): void;4 deliver(context: ShipmentContext): void;5}67class PendingState implements ShipmentState {8 ship(context: ShipmentContext): void {9 console.log('Shipping package...');10 context.setState(new InTransitState());11 }1213 deliver(context: ShipmentContext): void {14 throw new Error('Cannot deliver pending shipment');15 }16}1718class InTransitState implements ShipmentState {19 ship(context: ShipmentContext): void {20 throw new Error('Already shipped');21 }2223 deliver(context: ShipmentContext): void {24 console.log('Delivering package...');25 context.setState(new DeliveredState());26 }27}
When NOT to Use Patterns
Anti-Pattern: Premature Pattern Application
Don't use patterns when:
1. Simple Solution Suffices
typescript1// BAD: Unnecessary Factory for one type2class CarrierFactory {3 createCarrier(): Carrier {4 return new FedExCarrier(); // Always returns same type!5 }6}78// GOOD: Direct instantiation9const carrier = new FedExCarrier();
2. No Variation Expected
typescript1// BAD: Strategy with one algorithm2class RateCalculator {3 constructor(private strategy: RateStrategy) {}4 // Only one strategy ever used5}67// GOOD: Simple function8function calculateRate(weight: number): number {9 return weight * 0.5;10}
3. Pattern Adds Complexity Without Benefit
typescript1// BAD: Over-engineered for simple case2class SimpleBuilder {3 private value: string = '';45 setValue(v: string): this {6 this.value = v;7 return this;8 }910 build(): string {11 return this.value;12 }13}1415// GOOD: Direct assignment16const value = 'simple string';
Red Flags
Signs you might be overusing patterns:
- Can't explain why you're using the pattern in one sentence
- More pattern code than business logic
- Team confusion - others can't understand the design
- No flexibility gained - pattern doesn't actually help with future changes
- Testing is harder - patterns make testing more complex, not easier
Refactoring to Patterns
Don't start with patterns. Start simple and refactor when needed.
Step 1: Identify Code Smells
typescript1// Code smell: Large conditional for algorithm selection2function calculateRate(type: string, weight: number): number {3 if (type === 'standard') {4 return weight * 0.5;5 } else if (type === 'express') {6 return weight * 1.5;7 } else if (type === 'overnight') {8 return weight * 3.0;9 }10 // ... more conditions11}
Step 2: Apply Pattern Incrementally
typescript1// Refactor 1: Extract methods2function calculateRate(type: string, weight: number): number {3 if (type === 'standard') return calculateStandardRate(weight);4 if (type === 'express') return calculateExpressRate(weight);5 if (type === 'overnight') return calculateOvernightRate(weight);6}78// Refactor 2: Apply Strategy pattern9interface RateStrategy {10 calculate(weight: number): number;11}1213class StandardRateStrategy implements RateStrategy {14 calculate(weight: number): number {15 return weight * 0.5;16 }17}1819// Now easy to add new strategies without modifying existing code
Step 3: Validate Improvement
Ask:
- Is the code more maintainable?
- Is it easier to test?
- Is it easier to extend?
- Can teammates understand it?
If not, reconsider the pattern.
Pattern Selection Checklist
Before implementing a pattern, verify:
- Problem is clear - Can articulate the specific problem being solved
- Pattern fits - Pattern directly addresses the problem
- Simpler alternatives considered - No simpler solution exists
- Team understands - Team familiar with the pattern or willing to learn
- Benefits outweigh costs - Added complexity is justified
- Testable - Pattern improves (or doesn't hurt) testability
- Future-proof - Pattern helps with anticipated changes
Real-World Decision Examples
Example 1: Rate Calculation
Scenario: Need to calculate shipping rates
Analysis:
- Current: One calculation method
- Future: Might add more methods
Decision: Start simple, use Strategy if/when more methods are added
typescript1// Start simple2function calculateRate(weight: number): number {3 return weight * 0.5;4}56// Refactor to Strategy when second method is needed
Example 2: Notification System
Scenario: Send email when package is delivered
Analysis:
- Current: Email only
- Future: Will add SMS, push notifications
Decision: Use Observer pattern now - multiple observers are known requirement
typescript1// Use Observer from start - clear need for multiple notification channels2class PackageTracker {3 private observers: Set<NotificationObserver> = new Set();45 attach(observer: NotificationObserver): void {6 this.observers.add(observer);7 }89 notifyDelivery(tracking: string): void {10 this.observers.forEach(obs => obs.notify(tracking));11 }12}
Example 3: Shipment Creation
Scenario: Create shipment objects
Analysis:
- Current: Simple creation with 2-3 fields
- Future: Might become more complex
Decision: Start with constructor, refactor to Builder if it gets complex
typescript1// Start simple2class Shipment {3 constructor(4 public origin: Address,5 public destination: Address6 ) {}7}89// Refactor to Builder when you reach ~5+ optional parameters
Key Principles
1. YAGNI (You Aren't Gonna Need It)
Don't add patterns for hypothetical future needs. Add them when needs are real.
2. KISS (Keep It Simple, Stupid)
Prefer the simplest solution that works. Patterns should simplify, not complicate.
3. Start Simple, Refactor When Needed
Begin with straightforward code. Apply patterns when complexity justifies them.
4. Communicate Intent
Choose patterns that make your intent clearer, not more obscure.
5. Consider Team Knowledge
Use patterns your team knows or can learn easily. Exotic patterns can hurt more than help.
Summary
Pattern selection guidelines:
- Identify the core problem before choosing a pattern
- Match the problem to pattern category (Creational, Structural, Behavioral)
- Use decision flowchart to narrow down options
- Validate the choice against selection checklist
- Start simple and refactor to patterns when needed
- Avoid patterns when simpler solutions work
- Consider team knowledge and maintainability
Remember: Patterns are tools, not goals. The goal is maintainable, understandable code that solves real problems elegantly.
In the next workshop, you'll practice combining multiple patterns to build a complete shipping system that demonstrates how patterns work together effectively.