Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 004 / 100

Open/Closed with the strategy pattern in Design Patterns

Add behavior without editing working code.

Expona Admin7 min read

The smell — A switch statement that grows with every new payment method

I've seen this pattern more times than I'd like to admit: you inherit a codebase with a shipping calculator that has ballooned into a massive switch statement. Every time a new market opens or a partner integrates, someone adds another case. The function becomes a graveyard of conditions, each one slightly different, all of them fragile.

Here's what it typically looks like in production code:

typescript
class ShippingCalculator {
  calculate(order: Order, country: string): number {
    switch (country) {
      case 'US':
        return order.weight * 0.5 + 5;
      case 'UK':
        return order.weight * 0.6 + 3;
      case 'JP':
        return order.weight * 1.2 + 15;
      case 'AU':
        return order.weight * 0.8 + 20;
      default:
        throw new Error(`Unsupported country: ${country}`);
    }
  }
}

The problem isn't the switch statement itself—it's what happens next. A new market request comes in. You modify the function, add a case, and deploy. Your tests pass. Three months later, someone touches the US logic and accidentally breaks the UK logic. You're testing the whole function every time you add one line. This violates the Open/Closed principle: the class is closed for modification but open for extension, yet here you're constantly modifying it.

When you apply the Open/Closed with the strategy pattern, you flip this completely. You open your design for extension—adding new behavior—while keeping existing code closed for modification. Each shipping rate becomes its own class, implementing the same interface. No more touching the calculator.

Extract a strategy — Each variation implements the same interface

The first move is to identify what varies. In our shipping example, the calculation logic varies by country. Everything else—the order, the result type, the intent—stays the same. That's your signal to extract a strategy.

A strategy is a simple contract: an interface that all implementations must follow. In TypeScript, it looks clean:

typescript
interface ShippingRate {
  supports(country: string): boolean;
  calculate(order: Order): number;
}

The supports method tells us whether this strategy handles the given country. The calculate method does the actual work. Now each region gets its own class:

typescript
class LocalRate implements ShippingRate {
  supports(country: string): boolean {
    return country === 'US';
  }

  calculate(order: Order): number {
    return order.weight * 0.5 + 5;
  }
}

class EuropeanRate implements ShippingRate {
  supports(country: string): boolean {
    return ['UK', 'DE', 'FR'].includes(country);
  }

  calculate(order: Order): number {
    return order.weight * 0.6 + 3;
  }
}

class AsiaPacificRate implements ShippingRate {
  supports(country: string): boolean {
    return ['JP', 'AU', 'SG'].includes(country);
  }

  calculate(order: Order): number {
    return order.weight * 1.2 + 15;
  }
}

Notice what we've done: each class has a single reason to change. The local rate logic is isolated. The European logic is isolated. When you need to tweak the weight multiplier for Australia, you touch exactly one class. Tests for that class run in milliseconds. You don't risk breaking Japan.

The interface is the contract—the guarantee that every strategy can answer "do you handle this country?" and "if you do, what's the cost?" Callers don't care how the calculation happens. They just know they'll get a number back.

Select at runtime — A registry picks the right strategy

Now we need to pick the right strategy when an order comes in. Instead of a switch statement, we use a registry pattern—a simple map or array that holds all available strategies.

typescript
class ShippingCalculator {
  private strategies: ShippingRate[];

  constructor(strategies: ShippingRate[]) {
    this.strategies = strategies;
  }

  calculate(order: Order, country: string): number {
    const strategy = this.strategies.find(s => s.supports(country));
    
    if (!strategy) {
      throw new Error(`No shipping strategy for country: ${country}`);
    }

    return strategy.calculate(order);
  }
}

The calculator doesn't know or care about the specifics of any strategy. It delegates to the registry: "Who handles this country?" One of the strategies raises their hand, and the calculator calls their calculate method. That's it.

In a real system, you'd wire this up in your dependency injection container or factory:

typescript
const strategies = [
  new LocalRate(),
  new EuropeanRate(),
  new AsiaPacificRate(),
];

const calculator = new ShippingCalculator(strategies);

const cost = calculator.calculate(order, 'JP');

At runtime, the right strategy is selected without any conditional logic in the calculator itself. The selection is data-driven: a list of strategies. You can even load this list from a database or configuration file, making your system flexible without recompilation.

New behavior = new class — Existing classes stay untouched and tested

Here's where the Open/Closed principle clicks into place. A month from now, you need to support Brazil. You don't touch ShippingCalculator. You don't modify any existing strategy. You write one new class:

typescript
class BrazilianRate implements ShippingRate {
  supports(country: string): boolean {
    return country === 'BR';
  }

  calculate(order: Order): number {
    // Brazil has special tax rules and distance-based tiers
    const base = order.weight * 0.7;
    const tax = base * 0.15;
    return base + tax + 25;
  }
}

Then you add it to the registry:

typescript
const strategies = [
  new LocalRate(),
  new EuropeanRate(),
  new AsiaPacificRate(),
  new BrazilianRate(), // One line added
];

That's it. Your ShippingCalculator is closed for modification—its code hasn't changed. But it's open for extension—new behavior is added without touching tested, production code. Every existing test passes because nothing in the existing classes moved. You test BrazilianRate in isolation, ship it, and move on.

This is the real win. In a platform handling multiple payment methods, shipping regions, or discount strategies, the Open/Closed with the strategy pattern turns extension from a high-risk operation into a routine task. You're not hunting for side effects. You're not running the entire test suite and holding your breath.

Common mistakes

Mistake 1: Putting selection logic in the strategy itself. Sometimes teams try to make strategies smart about whether they should run. They end up with complex conditions scattered across multiple classes. Keep selection simple and centralized—in the registry or calculator. Strategies should focus only on their calculation, nothing else.

Mistake 2: Strategies with no clear contract. If your interface is vague—too many optional methods, confusing parameters—developers will cut corners and put logic in the wrong place. Invest time in the interface. Make it crystal clear what a strategy must do. Type it strictly. A good interface prevents bad implementations.

Mistake 3: Over-engineering the registry. Some teams build elaborate strategy loaders with reflection, annotations, or configuration files before they need them. Start simple: a list of instances, a loop, a match. Complexity can come later if you hit a real constraint. A for-loop that finds the right strategy is clearer than a framework you added "for flexibility."

Mistake 4: Forgetting to test strategies in isolation. The beauty of this pattern is testability. Each strategy should have its own unit test file, testing its logic with known inputs and expected outputs. Don't just test the calculator. If you test a strategy in isolation and it passes, you know that behavior works. Period.

Key takeaways

  • A growing switch statement is a code smell. It violates the Open/Closed principle because you modify the same class every time you add behavior.
  • Extract each variation into its own class implementing a shared interface. This isolates logic and makes testing fast and focused.
  • Use a registry (list, map, or set) to hold strategies and select at runtime. The caller doesn't know about the variations—it just delegates to the registry.
  • Adding new behavior means writing a new class, not modifying existing ones. Existing tests pass without re-running the entire suite.
  • Keep the strategy interface minimal and clear. The interface is your contract—make it worth signing.
  • This pattern is especially valuable in platforms with multiple variants: payment processors, shipping providers, discount types, or notification channels. Each variation gets its own testable, replaceable class.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles