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

Strategy pattern: a Design Patterns guide for developers

Swap algorithms without changing the caller.

Expona Admin8 min read

Your pricing engine started with a single formula, but now you're handling standard rates, airport surcharges, hourly minimums and corporate discounts all in one sprawling conditional. The Strategy pattern: a Design Patterns guide for developers solves this by encapsulating each algorithm as a separate class, letting you swap implementations without touching the caller. We'll show you how to clean up real-world scenarios like fare calculation, payment processing and shipping logic.

One interface, many implementations

At its core, the Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The key insight is that you create a common interface that all strategies implement, then client code depends only on that interface, not on concrete implementations.

Let's say you're building a ride-hailing platform. You need different pricing strategies depending on the trip type. Instead of a cascading if-else or switch statement, you define a single contract:

typescript
interface PricingStrategy {
  quote(trip: Trip): number;
  applyDiscount?(discount: number): number;
}

class StandardPricing implements PricingStrategy {
  quote(trip: Trip): number {
    const baseRate = 2.5;
    const perMile = 1.25;
    const perMinute = 0.45;
    return baseRate + (trip.distance * perMile) + (trip.duration * perMinute);
  }
}

class AirportFlatRate implements PricingStrategy {
  quote(trip: Trip): number {
    // Fixed rate to/from airport
    return trip.destination.isAirport ? 45 : 0;
  }
}

class HourlyPricing implements PricingStrategy {
  quote(trip: Trip): number {
    const hourlyRate = 55;
    return Math.ceil(trip.duration / 60) * hourlyRate;
  }
}

Each strategy owns its own calculation logic. New strategies can be added without modifying existing code. The interface is the contract—as long as a class implements quote(), it can be used anywhere a PricingStrategy is expected.

Choose at runtime by config or input

The real power emerges when you select which strategy to use based on runtime data. This decision point—choosing the algorithm at runtime—is what separates Strategy from inheritance or static polymorphism.

In practice, you build a registry and look up the appropriate strategy based on the incoming request:

typescript
class PricingEngine {
  private strategies: Record<string, PricingStrategy> = {
    standard: new StandardPricing(),
    airport: new AirportFlatRate(),
    hourly: new HourlyPricing(),
  };

  calculateFare(trip: Trip): number {
    const strategy = this.strategies[trip.tier];
    
    if (!strategy) {
      throw new Error(`Unknown pricing tier: ${trip.tier}`);
    }
    
    return strategy.quote(trip);
  }
}

// Usage
const engine = new PricingEngine();
const fare = engine.calculateFare({
  tier: 'airport',
  distance: 8,
  duration: 25,
  destination: { isAirport: true },
});

The registry pattern (a simple Record or Map) lets you add, remove or override strategies without changing the engine. You can even load strategies from configuration, a database, or a feature flag service.

Some teams inject the registry at instantiation time, making it testable and flexible:

typescript
class PricingEngine {
  constructor(private strategies: Record<string, PricingStrategy>) {}

  calculateFare(trip: Trip): number {
    const strategy = this.strategies[trip.tier];
    if (!strategy) throw new Error(`Unknown tier: ${trip.tier}`);
    return strategy.quote(trip);
  }
}

// In tests
const testStrategies = {
  standard: new MockPricingStrategy(),
};
const engine = new PricingEngine(testStrategies);

This inversion of control makes your code testable and your dependencies explicit.

Perfect for pricing, shipping and discounts

Three domains benefit most from Strategy:

Pricing models. E-commerce sites charge differently based on volume, membership tier, region or time of purchase. Each pricing rule becomes a strategy, and the cart or checkout engine picks the right one.

typescript
interface DiscountStrategy {
  apply(cartTotal: number, items: CartItem[]): number;
}

class PercentageDiscount implements DiscountStrategy {
  constructor(private percent: number) {}
  apply(cartTotal: number): number {
    return cartTotal * (1 - this.percent / 100);
  }
}

class MinimumPurchaseDiscount implements DiscountStrategy {
  constructor(private minimum: number, private discount: number) {}
  apply(cartTotal: number): number {
    return cartTotal >= this.minimum ? cartTotal - this.discount : cartTotal;
  }
}

class FreeShippingDiscount implements DiscountStrategy {
  constructor(private shippingCost: number) {}
  apply(cartTotal: number): number {
    return cartTotal - this.shippingCost;
  }
}

Shipping logic. Standard ground, express, overnight and international each have different rate cards, packing rules and lead times. A shipping service uses the Strategy pattern to encapsulate each carrier's logic:

typescript
interface ShippingStrategy {
  calculateCost(weight: number, distance: number): number;
  estimatedDays(): number;
}

class GroundShipping implements ShippingStrategy {
  calculateCost(weight: number, distance: number): number {
    return weight * 0.5 + distance * 0.01;
  }
  estimatedDays(): number {
    return 5;
  }
}

class ExpressShipping implements ShippingStrategy {
  calculateCost(weight: number, distance: number): number {
    return weight * 1.5 + distance * 0.05;
  }
  estimatedDays(): number {
    return 2;
  }
}

Payment processing. Credit card, PayPal, Apple Pay, cryptocurrency and bank transfers each need different validation and settlement flows. Wrap each in a strategy and route accordingly:

typescript
interface PaymentStrategy {
  validate(details: PaymentDetails): Promise<boolean>;
  process(amount: number, details: PaymentDetails): Promise<TransactionId>;
}

class CreditCardPayment implements PaymentStrategy {
  async validate(details: PaymentDetails): Promise<boolean> {
    return /^\d{16}$/.test(details.cardNumber);
  }
  async process(amount: number, details: PaymentDetails): Promise<TransactionId> {
    // Call Stripe, Square, or your processor
    return await this.processor.charge(amount, details);
  }
}

Replaces growing switch statements

Before Strategy, you end up with massive conditional blocks that grow every time you add a new rule. This is a code smell:

typescript
// Before: Hard to test, hard to extend, violates Open/Closed Principle
function calculateFare(trip: Trip): number {
  switch (trip.tier) {
    case 'standard':
      return 2.5 + trip.distance * 1.25 + trip.duration * 0.45;
    case 'airport':
      return trip.destination.isAirport ? 45 : 0;
    case 'hourly':
      return Math.ceil(trip.duration / 60) * 55;
    case 'corporate':
      return (2.5 + trip.distance * 1.25) * 0.85; // 15% discount
    case 'vip':
      return Math.max(15, 2.5 + trip.distance * 1.0);
    default:
      throw new Error(`Unknown tier`);
  }
}

Every new tier requires editing this function and adding another case. You can't reuse logic easily, and testing each branch means running through the same function. The Strategy pattern eliminates this:

typescript
// After: Easy to test, easy to extend, Open/Closed Principle
const strategies: Record<string, PricingStrategy> = {
  standard: new StandardPricing(),
  airport: new AirportFlatRate(),
  hourly: new HourlyPricing(),
  corporate: new DiscountedPricing(new StandardPricing(), 0.15),
  vip: new MinimumFarePricing(new StandardPricing(), 15),
};

function calculateFare(trip: Trip): number {
  return strategies[trip.tier].quote(trip);
}

Now you can test each strategy in isolation, add new ones without touching existing code, and even compose strategies (wrap one pricing strategy in another to apply a discount or minimum).

Common mistakes

  • Over-stratifying simple cases. Not every if-else needs Strategy. If you have two options and they're unlikely to change, a simple conditional is fine. Reach for Strategy when you have three or more algorithms, or when you expect new ones frequently.
  • Bloated strategy constructors. Don't pass the entire domain object (Trip, Order, etc.) to every strategy. Pass only the data they need. This keeps strategies focused and testable.
  • Forgetting to validate the selection. If a strategy key comes from user input or config, always verify it exists in your registry before using it. A missing key should raise a clear error, not silently fail or crash.
  • Mixing strategy selection with business logic. The code that chooses which strategy should be separate from the code that uses it. If your selection logic gets complex, move it to a separate factory or policy class.
  • Not versioning or managing strategy lifecycle. In production, you may need to deprecate a strategy or roll out a new one gradually. Plan for this from the start—use feature flags, versioning or gradual rollout mechanisms.

Key takeaways

  • Strategy encapsulates a family of interchangeable algorithms behind a common interface, eliminating bloated conditionals.
  • Choose which strategy to use at runtime based on config, user input or business rules—not at compile time.
  • Ideal for pricing, shipping, discounts and payment processing where different rules apply in different contexts.
  • Each strategy is independently testable, making your test suite cleaner and more focused.
  • Registry or factory patterns typically manage strategy instances, keeping selection logic decoupled from usage.
  • Compose strategies for cross-cutting concerns: wrap a base pricing strategy in a discount wrapper, or a flat-rate wrapper in a per-minute override.
  • Don't overuse Strategy for trivial cases; it adds indirection that's only worth it when you have multiple algorithms or expect frequent changes.
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