lesson

Combining Design Patterns

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:

typescript
1// Factory creates objects
2const shipmentFactory = new ShipmentFactory();
3
4// Strategy handles rate calculation
5const rateCalculator = new RateCalculator(new ZoneBasedStrategy());
6
7// Observer manages notifications
8const tracker = new PackageTracker();
9tracker.attach(new EmailNotificationObserver());

2. Enhanced Flexibility

Combining patterns provides multiple extension points:

typescript
1// 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

typescript
1class CarrierFactorySingleton {
2 private static instance: CarrierFactorySingleton;
3 private carriers: Map<string, CarrierFactory> = new Map();
4
5 private constructor() {
6 // Private constructor prevents direct instantiation
7 this.initializeCarriers();
8 }
9
10 static getInstance(): CarrierFactorySingleton {
11 if (!CarrierFactorySingleton.instance) {
12 CarrierFactorySingleton.instance = new CarrierFactorySingleton();
13 }
14 return CarrierFactorySingleton.instance;
15 }
16
17 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 }
24
25 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}
31
32// Usage
33const 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

typescript
1interface RateStrategy {
2 calculate(shipment: Shipment): number;
3}
4
5class RateStrategyFactory {
6 createStrategy(shipment: Shipment): RateStrategy {
7 // Select strategy based on shipment characteristics
8 if (shipment.isInternational) {
9 return new InternationalRateStrategy();
10 }
11
12 if (shipment.weight > 150) {
13 return new FreightRateStrategy();
14 }
15
16 const volume = shipment.length * shipment.width * shipment.height;
17 if (volume > 1728) { // 12x12x12 inches
18 return new DimensionalRateStrategy();
19 }
20
21 return new StandardRateStrategy();
22 }
23}
24
25// Usage
26const 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

typescript
1interface ShippingItem {
2 getWeight(): number;
3 getCost(): number;
4 getDescription(): string;
5}
6
7// Composite: Container can hold multiple items
8class ShippingContainer implements ShippingItem {
9 private items: ShippingItem[] = [];
10
11 add(item: ShippingItem): void {
12 this.items.push(item);
13 }
14
15 getWeight(): number {
16 return this.items.reduce((sum, item) => sum + item.getWeight(), 0);
17 }
18
19 getCost(): number {
20 return this.items.reduce((sum, item) => sum + item.getCost(), 0);
21 }
22
23 getDescription(): string {
24 return `Container with ${this.items.length} items`;
25 }
26}
27
28// Decorator: Adds tracking to any ShippingItem
29class TrackedShipment implements ShippingItem {
30 constructor(
31 private item: ShippingItem,
32 private trackingNumber: string
33 ) {}
34
35 getWeight(): number {
36 return this.item.getWeight();
37 }
38
39 getCost(): number {
40 return this.item.getCost() + 2.99; // Tracking fee
41 }
42
43 getDescription(): string {
44 return `${this.item.getDescription()} [Tracking: ${this.trackingNumber}]`;
45 }
46}
47
48// Decorator: Adds insurance to any ShippingItem
49class InsuredShipment implements ShippingItem {
50 constructor(
51 private item: ShippingItem,
52 private insuranceValue: number
53 ) {}
54
55 getWeight(): number {
56 return this.item.getWeight();
57 }
58
59 getCost(): number {
60 const insuranceFee = this.insuranceValue * 0.01; // 1% of value
61 return this.item.getCost() + insuranceFee;
62 }
63
64 getDescription(): string {
65 return `${this.item.getDescription()} [Insured: $${this.insuranceValue}]`;
66 }
67}
68
69// Usage: Combine patterns
70const container = new ShippingContainer();
71container.add(new Package('Item 1', 5, 20));
72container.add(new Package('Item 2', 3, 15));
73
74// Add tracking and insurance to entire container
75const trackedContainer = new TrackedShipment(container, 'TRK123');
76const insuredTrackedContainer = new InsuredShipment(trackedContainer, 1000);
77
78console.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

typescript
1// Observer interface
2interface TrackingObserver {
3 update(event: TrackingEvent): void;
4}
5
6// Mediator coordinates observers
7class NotificationMediator {
8 private observers: Map<string, Set<TrackingObserver>> = new Map();
9
10 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 }
16
17 notify(eventType: string, event: TrackingEvent): void {
18 // Coordinate notification order and dependencies
19 if (eventType === 'delivered') {
20 // First, update database
21 this.notifyGroup('database', event);
22 // Then, notify customer
23 this.notifyGroup('customer', event);
24 // Finally, notify analytics
25 this.notifyGroup('analytics', event);
26 } else {
27 // For other events, notify all simultaneously
28 this.observers.get(eventType)?.forEach(observer => {
29 observer.update(event);
30 });
31 }
32 }
33
34 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":

typescript
1// BAD: Over-engineered for simple shipping label
2class ShippingLabelFactorySingleton {
3 // This is overkill if you only have one label type!
4}
5
6// GOOD: Simple and direct
7function 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:

typescript
1// BAD: Too many concerns in one class
2class ShipmentManager {
3 // Factory + Strategy + Observer + Singleton = Complexity!
4}
5
6// GOOD: Separate concerns
7class ShipmentFactory { }
8class RateCalculator { }
9class TrackingNotifier { }

3. YAGNI (You Aren't Gonna Need It)

Only add patterns when you need them:

typescript
1// BAD: Adding Strategy when there's only one algorithm
2class RateCalculatorWithOneStrategy {
3 // Why use Strategy if you only have one way to calculate?
4}
5
6// GOOD: Simple function
7function calculateRate(shipment: Shipment): number {
8 return shipment.weight * 0.5;
9}

Signs of Pattern Overuse

  1. Excessive indirection: Following the code requires jumping through many layers
  2. Low code-to-pattern ratio: More pattern infrastructure than business logic
  3. Difficult to explain: Can't explain why a pattern is needed in simple terms
  4. 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
typescript
1// Clean separation of concerns
2const carrier = carrierFactory.create('fedex'); // Factory
3const rate = rateCalculator.calculate(shipment); // Strategy
4const tracked = new TrackedShipment(shipment, 'TRK123'); // Decorator
5tracker.attach(emailObserver); // Observer
6const api = new ShippingFacade(); // Facade

Each pattern has a clear purpose and they work together harmoniously.

Key Takeaways

  1. Combine patterns to solve complex, multi-faceted problems
  2. Each pattern should have a clear, distinct responsibility
  3. Common combinations include Factory+Singleton, Strategy+Factory, Decorator+Composite
  4. Avoid overuse - use patterns only when they add clear value
  5. Simplicity first - start simple and add patterns as needs arise
  6. 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.