lesson

Pattern Selection Guide

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:

typescript
1// Problem: Need different rate calculations for different shipment types
2// What's changing? The rate calculation algorithm
3// Source of complexity? Multiple calculation methods with different logic
4// Future needs? Adding new calculation methods without modifying existing code
5
6// 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...
2
3├─ Create objects?
4│ ├─ Complex construction process? → Builder
5│ ├─ Create family of related objects? → Abstract Factory
6│ ├─ Delegate creation to subclasses? → Factory Method
7│ ├─ Ensure single instance? → Singleton
8│ └─ Clone existing objects? → Prototype
9
10├─ Compose objects or adapt interfaces?
11│ ├─ Make incompatible interfaces work? → Adapter
12│ ├─ Add responsibilities dynamically? → Decorator
13│ ├─ Simplify complex subsystem? → Facade
14│ ├─ Treat objects uniformly in tree structure? → Composite
15│ ├─ Control access to object? → Proxy
16│ └─ Separate abstraction from implementation? → Bridge
17
18└─ Define object behavior or interaction?
19 ├─ Switch algorithm at runtime? → Strategy
20 ├─ Notify multiple objects of changes? → Observer
21 ├─ Encapsulate requests as objects? → Command
22 ├─ Change behavior based on state? → State
23 ├─ Chain request handlers? → Chain of Responsibility
24 ├─ Coordinate object interactions? → Mediator
25 ├─ Add operations to structure without modifying? → Visitor
26 ├─ Access elements sequentially? → Iterator
27 └─ 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
typescript
1// Use Factory Method when creation logic varies by subtype
2interface Carrier {
3 ship(package: Package): TrackingNumber;
4}
5
6abstract class CarrierFactory {
7 abstract createCarrier(): Carrier;
8
9 shipPackage(pkg: Package): TrackingNumber {
10 const carrier = this.createCarrier();
11 return carrier.ship(pkg);
12 }
13}
14
15class 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
typescript
1// Use Builder when construction is complex with many options
2class ShipmentBuilder {
3 private shipment: Partial<Shipment> = {};
4
5 setOrigin(address: Address): this {
6 this.shipment.origin = address;
7 return this;
8 }
9
10 setDestination(address: Address): this {
11 this.shipment.destination = address;
12 return this;
13 }
14
15 // Many more optional settings...
16
17 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.

typescript
1// Use Singleton sparingly - often for configuration or logging
2class ConfigurationManager {
3 private static instance: ConfigurationManager;
4 private config: AppConfig;
5
6 private constructor() {
7 this.config = this.loadConfig();
8 }
9
10 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
typescript
1// Use Decorator when you need to add features dynamically
2interface Shipment {
3 getCost(): number;
4}
5
6class BasicShipment implements Shipment {
7 getCost(): number {
8 return 10.00;
9 }
10}
11
12class InsuredShipment implements Shipment {
13 constructor(private shipment: Shipment) {}
14
15 getCost(): number {
16 return this.shipment.getCost() + 5.00; // Add insurance cost
17 }
18}
19
20// Can stack decorators
21const 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
typescript
1// Use Composite when you have tree structures
2interface ShippingItem {
3 getWeight(): number;
4}
5
6class Package implements ShippingItem {
7 constructor(private weight: number) {}
8
9 getWeight(): number {
10 return this.weight;
11 }
12}
13
14class Container implements ShippingItem {
15 private items: ShippingItem[] = [];
16
17 add(item: ShippingItem): void {
18 this.items.push(item);
19 }
20
21 getWeight(): number {
22 return this.items.reduce((sum, item) => sum + item.getWeight(), 0);
23 }
24}
25
26// Treat packages and containers uniformly
27function 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
typescript
1// Use Facade to simplify complex subsystems
2class ShippingFacade {
3 private addressValidator: AddressValidator;
4 private rateCalculator: RateCalculator;
5 private labelGenerator: LabelGenerator;
6 private trackingSystem: TrackingSystem;
7
8 // Simple interface hiding complexity
9 createShipment(origin: Address, destination: Address, pkg: Package): ShipmentResult {
10 this.addressValidator.validate(origin);
11 this.addressValidator.validate(destination);
12
13 const rate = this.rateCalculator.calculate(origin, destination, pkg);
14 const label = this.labelGenerator.generate(origin, destination);
15 const tracking = this.trackingSystem.register(label.id);
16
17 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
typescript
1// Use Strategy when you have interchangeable algorithms
2interface RateStrategy {
3 calculate(shipment: Shipment): number;
4}
5
6class RateCalculator {
7 constructor(private strategy: RateStrategy) {}
8
9 setStrategy(strategy: RateStrategy): void {
10 this.strategy = strategy;
11 }
12
13 calculate(shipment: Shipment): number {
14 return this.strategy.calculate(shipment);
15 }
16}
17
18// Easy to add new strategies
19class 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
typescript
1// Use Observer when objects need to react to changes
2class PackageTracker {
3 private observers: Set<TrackingObserver> = new Set();
4
5 attach(observer: TrackingObserver): void {
6 this.observers.add(observer);
7 }
8
9 updateStatus(tracking: string, status: Status): void {
10 // Notify all observers
11 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
typescript
1// Use State when behavior changes based on state
2interface ShipmentState {
3 ship(context: ShipmentContext): void;
4 deliver(context: ShipmentContext): void;
5}
6
7class PendingState implements ShipmentState {
8 ship(context: ShipmentContext): void {
9 console.log('Shipping package...');
10 context.setState(new InTransitState());
11 }
12
13 deliver(context: ShipmentContext): void {
14 throw new Error('Cannot deliver pending shipment');
15 }
16}
17
18class InTransitState implements ShipmentState {
19 ship(context: ShipmentContext): void {
20 throw new Error('Already shipped');
21 }
22
23 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

typescript
1// BAD: Unnecessary Factory for one type
2class CarrierFactory {
3 createCarrier(): Carrier {
4 return new FedExCarrier(); // Always returns same type!
5 }
6}
7
8// GOOD: Direct instantiation
9const carrier = new FedExCarrier();

2. No Variation Expected

typescript
1// BAD: Strategy with one algorithm
2class RateCalculator {
3 constructor(private strategy: RateStrategy) {}
4 // Only one strategy ever used
5}
6
7// GOOD: Simple function
8function calculateRate(weight: number): number {
9 return weight * 0.5;
10}

3. Pattern Adds Complexity Without Benefit

typescript
1// BAD: Over-engineered for simple case
2class SimpleBuilder {
3 private value: string = '';
4
5 setValue(v: string): this {
6 this.value = v;
7 return this;
8 }
9
10 build(): string {
11 return this.value;
12 }
13}
14
15// GOOD: Direct assignment
16const value = 'simple string';

Red Flags

Signs you might be overusing patterns:

  1. Can't explain why you're using the pattern in one sentence
  2. More pattern code than business logic
  3. Team confusion - others can't understand the design
  4. No flexibility gained - pattern doesn't actually help with future changes
  5. 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

typescript
1// Code smell: Large conditional for algorithm selection
2function 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 conditions
11}

Step 2: Apply Pattern Incrementally

typescript
1// Refactor 1: Extract methods
2function 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}
7
8// Refactor 2: Apply Strategy pattern
9interface RateStrategy {
10 calculate(weight: number): number;
11}
12
13class StandardRateStrategy implements RateStrategy {
14 calculate(weight: number): number {
15 return weight * 0.5;
16 }
17}
18
19// 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

typescript
1// Start simple
2function calculateRate(weight: number): number {
3 return weight * 0.5;
4}
5
6// 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

typescript
1// Use Observer from start - clear need for multiple notification channels
2class PackageTracker {
3 private observers: Set<NotificationObserver> = new Set();
4
5 attach(observer: NotificationObserver): void {
6 this.observers.add(observer);
7 }
8
9 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

typescript
1// Start simple
2class Shipment {
3 constructor(
4 public origin: Address,
5 public destination: Address
6 ) {}
7}
8
9// 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:

  1. Identify the core problem before choosing a pattern
  2. Match the problem to pattern category (Creational, Structural, Behavioral)
  3. Use decision flowchart to narrow down options
  4. Validate the choice against selection checklist
  5. Start simple and refactor to patterns when needed
  6. Avoid patterns when simpler solutions work
  7. 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.