The Strategy Pattern: Plug-and-Play Code Architecture

The Strategy Pattern: Plug-and-Play Code Architecture
Photo by Hassan Pasha / Unsplash

Imagine navigating to a new destination using a map app. You enter your destination, and the app asks a fundamental question: How do you want to get there?

You can drive, walk, bike, or take public transit. The destination remains the same, but the underlying route calculation algorithm changes completely depending on your selection.

In software engineering, this concept is called the Strategy Pattern. It is a behavioral design pattern that allows you to define a family of algorithms, encapsulate each one inside its own class, and make them interchangeable at runtime.

What Problem Does It Solve?

When building software, you often need to perform a task in multiple ways based on user choice, configuration, or business logic.

Without a design pattern, beginner code often relies on giant if/else or switch statements:

// The brittle approach without Strategy
class PaymentProcessor {
  processPayment(method: string, amount: number) {
    if (method === "credit_card") {
      // 50 lines of Credit Card validation and API logic
    } else if (method === "paypal") {
      // 40 lines of PayPal authentication and payment logic
    } else if (method === "crypto") {
      // 60 lines of Web3 wallet integration
    } else {
      throw new Error("Unsupported payment method");
    }
  }
}

This approach creates severe maintainability issues:

  • Monolithic Classes: The host class grows uncontrollably as new options are added.
  • High Coupling: Editing PayPal logic requires modifying the exact same file that handles Credit Cards, introducing the risk of breaking unrelated payment flows.
  • Rigid Control Flow: Changing behaviors dynamically at runtime becomes messy and error-prone.

The Strategy Pattern fixes this by delegating the algorithm execution to separate, dedicated objects.

Pattern Architecture

The Strategy Pattern consists of three core components:

  1. Strategy Interface: Defines a common contract that all concrete algorithms must follow.
  2. Concrete Strategies: Individual classes that implement the Strategy Interface with specific logic.
  3. Context: The client-facing object that holds a reference to a Strategy and delegates the work to it.
Mermaid Rendered
classDiagram
    class PaymentStrategy {
        <<interface>>
        +pay(amount: number) boolean
    }
    class CreditCardStrategy {
        -cardNumber: string
        +pay(amount: number) boolean
    }
    class PayPalStrategy {
        -email: string
        +pay(amount: number) boolean
    }
    class PaymentProcessor {
        -strategy: PaymentStrategy
        +setStrategy(strategy: PaymentStrategy) void
        +checkout(amount: number) boolean
    }

    PaymentStrategy <|.. CreditCardStrategy
    PaymentStrategy <|.. PayPalStrategy
    PaymentProcessor --> PaymentStrategy

Implementing the Strategy Pattern in TypeScript

Let's build a clean, type-safe implementation of a payment system using TypeScript.

1. Define the Strategy Interface

// 1. The Strategy Interface
interface PaymentStrategy {
  pay(amount: number): boolean;
}

2. Implement Concrete Strategies

// 2. Concrete Strategy A: Credit Card
class CreditCardStrategy implements PaymentStrategy {
  constructor(private cardNumber: string, private cvv: string) {}

  pay(amount: number): boolean {
    const maskedCard = this.cardNumber.slice(-4);
    console.log(`Processing $${amount} charge via Credit Card (**** **** **** ${maskedCard}).`);
    // Real integration logic (e.g., Stripe API call) would live here
    return true;
  }
}

// Concrete Strategy B: PayPal
class PayPalStrategy implements PaymentStrategy {
  constructor(private email: string) {}

  pay(amount: number): boolean {
    console.log(`Processing $${amount} payment via PayPal account: ${this.email}`);
    // Real integration logic (e.g., PayPal SDK call) would live here
    return true;
  }
}

3. Build the Context Class

// 3. The Context
class PaymentProcessor {
  private strategy: PaymentStrategy;

  constructor(initialStrategy: PaymentStrategy) {
    this.strategy = initialStrategy;
  }

  // Allows dynamic switching of strategies at runtime
  public setStrategy(newStrategy: PaymentStrategy): void {
    this.strategy = newStrategy;
  }

  public checkout(amount: number): boolean {
    if (amount <= 0) {
      throw new Error("Checkout amount must be greater than zero.");
    }
    
    // Delegate execution to the active strategy
    return this.strategy.pay(amount);
  }
}

4. Executing the Code

// Client Usage
const cartTotal = 149.99;

// Customer chooses Credit Card at checkout
const cardStrategy = new CreditCardStrategy("4111222233334444", "123");
const processor = new PaymentProcessor(cardStrategy);
processor.checkout(cartTotal);

// Customer changes their mind and switches to PayPal
const paypalStrategy = new PayPalStrategy("alex@example.com");
processor.setStrategy(paypalStrategy);
processor.checkout(cartTotal);

Why It Supercharges Unit Testing

Testing conditional logic inside a monolithic class is painful because you must set up the entire environment for every conditional branch. The Strategy Pattern changes this completely by enabling isolated testing.

1. Isolated Unit Tests for Each Strategy

Because each strategy lives in its own file and class, you can test specific business logic without initializing the PaymentProcessor:

test("CreditCardStrategy processes payments correctly", () => {
  const cardStrategy = new CreditCardStrategy("4111222233334444", "123");
  const result = cardStrategy.pay(100);
  expect(result).toBe(true);
});

2. Seamless Mocking in Context Tests

When testing the PaymentProcessor context, you don't want real API network calls running in your test suite. You can easily inject a lightweight mock strategy:

class MockPaymentStrategy implements PaymentStrategy {
  public wasCalled = false;

  pay(amount: number): boolean {
    this.wasCalled = true;
    return true;
  }
}

test("PaymentProcessor delegates payment execution to strategy", () => {
  const mockStrategy = new MockPaymentStrategy();
  const processor = new PaymentProcessor(mockStrategy);

  processor.checkout(50);

  expect(mockStrategy.wasCalled).toBe(true);
});

Long-Term Maintainability and Evolution

The Strategy Pattern is a direct application of the Open/Closed Principle—one of the foundational SOLID principles of object-oriented design:

Software entities should be open for extension, but closed for modification.

When your application expands six months later and your team needs to support Apple Pay:

  1. You create one new file: ApplePayStrategy.ts implementing PaymentStrategy.
  2. You pass an instance of ApplePayStrategy into PaymentProcessor.

Zero existing code in PaymentProcessor, CreditCardStrategy, or PayPalStrategy is modified.

By eliminating the need to modify proven code, you avoid regression bugs, pull request review headaches, and testing bottlenecks—keeping your codebase lean and adaptable as your application grows.