Combining Design Patterns
Why Combine Patterns?
Individual design patterns are powerful, but complex software systems often require multiple patterns working together. Pattern combinations provide elegant solutions to multifaceted problems that a single pattern cannot address adequately.
The Reality of Software Design
Real-world applications rarely face problems that fit exactly one pattern's scope. Instead, you'll encounter scenarios like:
- Creating complex objects that also need to support multiple runtime behaviors
- Coordinating multiple subsystems that each use different patterns internally
- Managing state changes that trigger notifications across multiple observers
- Building hierarchical structures that need uniform treatment and decoration
When patterns work together, they amplify each other's strengths and mitigate individual weaknesses.
Benefits of Pattern Combinations
1. Separation of Concerns
Different patterns handle different responsibilities:
typescript1// Factory creates objects2const shipmentFactory = new ShipmentFactory();34// Strategy handles rate calculation5const rateCalculator = new RateCalculator(new ZoneBasedStrategy());67// Observer manages notifications8const tracker = new PackageTracker();9tracker.attach(new EmailNotificationObserver());
2. Enhanced Flexibility
Combining patterns provides multiple extension points:
typescript1// Extend through Factory (what gets created)2// Extend through Strategy (how rates are calculated)3// Extend through Observer (who gets notified)4// All independently!
3. Better Code Organization
Each pattern focuses on its specific concern, making code more maintainable and testable.
4. Solving Complex Problems
Some problems require multiple perspectives that individual patterns can't provide alone.
Common Pattern Combinations
1. Factory + Singleton
Ensures only one factory instance exists while maintaining object creation flexibility.
Use Case: Global carrier configuration factory in a logistics system
typescript1class CarrierFactorySingleton {2 private static instance: CarrierFactorySingleton;3 private carriers: Map<string, CarrierFactory> = new Map();45 private constructor() {6 // Private constructor prevents direct instantiation7 this.initializeCarriers();8 }910 static getInstance(): CarrierFactorySingleton {11 if (!CarrierFactorySingleton.instance) {12 CarrierFactorySingleton.instance = new CarrierFactorySingleton();13 }14 return CarrierFactorySingleton.instance;15 }1617 getCarrier(type: string): Carrier {18 const factory = this.carriers.get(type);19 if (!factory) {20 throw new Error(`Unknown carrier type: ${type}`);21 }22 return factory.createCarrier();23 }2425 private initializeCarriers(): void {26 this.carriers.set('fedex', new FedExFactory());27 this.carriers.set('ups', new UPSFactory());28 this.carriers.set('usps', new USPSFactory());29 }30}3132// Usage33const factoryManager = CarrierFactorySingleton.getInstance();34const carrier = factoryManager.getCarrier('fedex');
Why This Works:
- Singleton ensures centralized carrier configuration
- Factory provides flexible carrier creation
- Configuration loaded once, used everywhere
2. Strategy + Factory
Factory creates the appropriate strategy based on context.
Use Case: Selecting rate calculation strategy based on shipment type
typescript1interface RateStrategy {2 calculate(shipment: Shipment): number;3}45class RateStrategyFactory {6 createStrategy(shipment: Shipment): RateStrategy {7 // Select strategy based on shipment characteristics8 if (shipment.isInternational) {9 return new InternationalRateStrategy();10 }1112 if (shipment.weight > 150) {13 return new FreightRateStrategy();14 }1516 const volume = shipment.length * shipment.width * shipment.height;17 if (volume > 1728) { // 12x12x12 inches18 return new DimensionalRateStrategy();19 }2021 return new StandardRateStrategy();22 }23}2425// Usage26const strategyFactory = new RateStrategyFactory();27const strategy = strategyFactory.createStrategy(shipment);28const rate = strategy.calculate(shipment);
Why This Works:
- Strategy encapsulates different algorithms
- Factory encapsulates strategy selection logic
- Adding new strategies doesn't affect client code
3. Decorator + Composite
Decorators add behaviors to composite structures uniformly.
Use Case: Adding tracking and insurance to shipment containers
typescript1interface ShippingItem {2 getWeight(): number;3 getCost(): number;4 getDescription(): string;5}67// Composite: Container can hold multiple items8class ShippingContainer implements ShippingItem {9 private items: ShippingItem[] = [];1011 add(item: ShippingItem): void {12 this.items.push(item);13 }1415 getWeight(): number {16 return this.items.reduce((sum, item) => sum + item.getWeight(), 0);17 }1819 getCost(): number {20 return this.items.reduce((sum, item) => sum + item.getCost(), 0);21 }2223 getDescription(): string {24 return `Container with ${this.items.length} items`;25 }26}2728// Decorator: Adds tracking to any ShippingItem29class TrackedShipment implements ShippingItem {30 constructor(31 private item: ShippingItem,32 private trackingNumber: string33 ) {}3435 getWeight(): number {36 return this.item.getWeight();37 }3839 getCost(): number {40 return this.item.getCost() + 2.99; // Tracking fee41 }4243 getDescription(): string {44 return `${this.item.getDescription()} [Tracking: ${this.trackingNumber}]`;45 }46}4748// Decorator: Adds insurance to any ShippingItem49class InsuredShipment implements ShippingItem {50 constructor(51 private item: ShippingItem,52 private insuranceValue: number53 ) {}5455 getWeight(): number {56 return this.item.getWeight();57 }5859 getCost(): number {60 const insuranceFee = this.insuranceValue * 0.01; // 1% of value61 return this.item.getCost() + insuranceFee;62 }6364 getDescription(): string {65 return `${this.item.getDescription()} [Insured: $${this.insuranceValue}]`;66 }67}6869// Usage: Combine patterns70const container = new ShippingContainer();71container.add(new Package('Item 1', 5, 20));72container.add(new Package('Item 2', 3, 15));7374// Add tracking and insurance to entire container75const trackedContainer = new TrackedShipment(container, 'TRK123');76const insuredTrackedContainer = new InsuredShipment(trackedContainer, 1000);7778console.log(insuredTrackedContainer.getDescription());79console.log(insuredTrackedContainer.getCost());
Why This Works:
- Composite treats individual items and containers uniformly
- Decorator adds features (tracking, insurance) to any ShippingItem
- Can decorate individual items or entire containers
- Features stack naturally (tracking + insurance)
4. Observer + Mediator
Mediator coordinates communication between observers.
Use Case: Coordinating notifications across multiple tracking observers
typescript1// Observer interface2interface TrackingObserver {3 update(event: TrackingEvent): void;4}56// Mediator coordinates observers7class NotificationMediator {8 private observers: Map<string, Set<TrackingObserver>> = new Map();910 subscribe(eventType: string, observer: TrackingObserver): void {11 if (!this.observers.has(eventType)) {12 this.observers.set(eventType, new Set());13 }14 this.observers.get(eventType)!.add(observer);15 }1617 notify(eventType: string, event: TrackingEvent): void {18 // Coordinate notification order and dependencies19 if (eventType === 'delivered') {20 // First, update database21 this.notifyGroup('database', event);22 // Then, notify customer23 this.notifyGroup('customer', event);24 // Finally, notify analytics25 this.notifyGroup('analytics', event);26 } else {27 // For other events, notify all simultaneously28 this.observers.get(eventType)?.forEach(observer => {29 observer.update(event);30 });31 }32 }3334 private notifyGroup(eventType: string, event: TrackingEvent): void {35 this.observers.get(eventType)?.forEach(observer => {36 observer.update(event);37 });38 }39}
Why This Works:
- Observer defines update mechanism
- Mediator controls notification order and coordination
- Reduces coupling between observers
Avoiding Pattern Overuse
When NOT to Combine Patterns
1. Premature Optimization
Don't add patterns "just in case":
typescript1// BAD: Over-engineered for simple shipping label2class ShippingLabelFactorySingleton {3 // This is overkill if you only have one label type!4}56// GOOD: Simple and direct7function createShippingLabel(shipment: Shipment): string {8 return `From: ${shipment.origin}\nTo: ${shipment.destination}`;9}
2. Single Responsibility Violation
Too many patterns in one place often indicates design issues:
typescript1// BAD: Too many concerns in one class2class ShipmentManager {3 // Factory + Strategy + Observer + Singleton = Complexity!4}56// GOOD: Separate concerns7class ShipmentFactory { }8class RateCalculator { }9class TrackingNotifier { }
3. YAGNI (You Aren't Gonna Need It)
Only add patterns when you need them:
typescript1// BAD: Adding Strategy when there's only one algorithm2class RateCalculatorWithOneStrategy {3 // Why use Strategy if you only have one way to calculate?4}56// GOOD: Simple function7function calculateRate(shipment: Shipment): number {8 return shipment.weight * 0.5;9}
Signs of Pattern Overuse
- Excessive indirection: Following the code requires jumping through many layers
- Low code-to-pattern ratio: More pattern infrastructure than business logic
- Difficult to explain: Can't explain why a pattern is needed in simple terms
- No flexibility gained: Pattern doesn't actually make future changes easier
The Balance
Use patterns when:
- Requirements explicitly demand flexibility
- Multiple implementations exist or are planned
- The pattern simplifies complex logic
- The pattern is well-known to the team
Avoid patterns when:
- The simplest solution works
- Requirements are stable
- The team is unfamiliar with the pattern
- You're adding it "for practice"
Key Principles for Combining Patterns
1. Start Simple
Begin with the simplest solution. Add patterns only when complexity justifies them.
2. One Concern Per Pattern
Each pattern should address one specific problem. Don't force patterns to do too much.
3. Patterns Should Compose Naturally
Good pattern combinations feel natural, not forced. If combining patterns feels awkward, reconsider your design.
4. Maintain Readability
Patterns should clarify intent, not obscure it. Code should be more readable with patterns, not less.
5. Test Independently
Each pattern's implementation should be testable in isolation.
Real-World Example: Logistics Platform
A shipping platform might combine:
- Factory Method: Creating different carrier integrations
- Strategy: Different rate calculation algorithms
- Decorator: Adding insurance, tracking, signature requirements
- Observer: Notifying stakeholders of status changes
- Facade: Providing simple API to complex shipping system
typescript1// Clean separation of concerns2const carrier = carrierFactory.create('fedex'); // Factory3const rate = rateCalculator.calculate(shipment); // Strategy4const tracked = new TrackedShipment(shipment, 'TRK123'); // Decorator5tracker.attach(emailObserver); // Observer6const api = new ShippingFacade(); // Facade
Each pattern has a clear purpose and they work together harmoniously.
Key Takeaways
- Combine patterns to solve complex, multi-faceted problems
- Each pattern should have a clear, distinct responsibility
- Common combinations include Factory+Singleton, Strategy+Factory, Decorator+Composite
- Avoid overuse - use patterns only when they add clear value
- Simplicity first - start simple and add patterns as needs arise
- Natural composition - patterns should work together smoothly
In the next lesson, we'll explore specific common pattern combinations used in industry-standard architectures like MVC and event-driven systems.