Strategy Pattern Guide: How to Replace Growing Conditionals With Swappable Behavior
Dev
Last updated on

Strategy Pattern Guide: How to Replace Growing Conditionals With Swappable Behavior


Real applications often perform “the same kind of task” in several different ways. A checkout can pay by card, points, or bank transfer. A notification flow can send by email, SMS, or push. A pricing engine can choose discount rules based on customer type or campaign logic.

The first implementation often starts with one simple if statement. Then another rule arrives, then another edge case, and eventually one service becomes a wall of branching. That is usually the moment the strategy pattern starts making sense.

In this guide, we will cover:

  • what the strategy pattern actually is
  • how to tell the difference between healthy branching and branching that wants a new structure
  • a practical refactor from a large conditional to replaceable strategies
  • when strategy fits well and when it is unnecessary ceremony

The short version is this: the strategy pattern keeps a stable context while moving changing behavior into separate implementations that can be swapped cleanly.

What is the strategy pattern?

The strategy pattern represents one role with multiple interchangeable implementations.

For example:

  • payment strategy
  • discount strategy
  • shipping fee strategy
  • retry strategy

Each implementation handles the same general job in a different way, while the higher-level code depends on the common role rather than one specific implementation.

That is the core idea:

  • keep the use case stable
  • move the varying behavior behind an interface
  • choose the implementation at runtime or wiring time

So strategy is really about controlled variation.

When do you need a strategy?

You usually need a strategy when one piece of behavior keeps varying while the surrounding workflow stays mostly the same.

Common signals:

  • one service keeps growing more if/else or switch branches
  • new behavior variants arrive regularly
  • the same behavior family appears in different parts of the codebase
  • tests have to cover many branches inside one large class
  • a feature needs to choose behavior dynamically by configuration, user type, or environment

If those signals keep showing up, strategy often gives the code a better shape.

Problem example: a checkout service full of branching

Imagine a checkout flow that supports several payment methods:

class CheckoutService {
  checkout(amount: number, paymentMethod: string): void {
    if (paymentMethod === 'card') {
      console.log(`charge card: ${amount}`);
      return;
    }

    if (paymentMethod === 'points') {
      console.log(`deduct points: ${amount}`);
      return;
    }

    if (paymentMethod === 'bank-transfer') {
      console.log(`create transfer request: ${amount}`);
      return;
    }

    throw new Error('unsupported payment method');
  }
}

At first, this is perfectly understandable. The issue appears when real requirements keep arriving:

  • cards need fraud checks
  • points need balance validation
  • bank transfers need settlement instructions
  • some methods are unavailable in certain countries

That single checkout method starts carrying:

  • payment selection
  • payment execution
  • payment-specific validation
  • payment-specific failure handling

At that point, the method is no longer one workflow with one responsibility. It is becoming a container for several behaviors that happen to live in the same place.

Refactor: move the varying behavior into strategies

Now define the role explicitly:

interface PaymentStrategy {
  pay(amount: number): void;
}

class CardPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`charge card: ${amount}`);
  }
}

class PointsPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`deduct points: ${amount}`);
  }
}

class BankTransferPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`create transfer request: ${amount}`);
  }
}

class CheckoutService {
  constructor(private paymentStrategy: PaymentStrategy) {}

  checkout(amount: number): void {
    this.paymentStrategy.pay(amount);
  }
}

Now the responsibilities are cleaner:

  • CheckoutService owns the checkout flow
  • each strategy owns one payment behavior
  • adding a new payment method does not force more branching into the same method

That does not remove all decisions from the system. It simply moves the variation into dedicated objects instead of letting it sprawl through one growing conditional.

Who chooses the strategy?

This is one of the most useful practical questions.

Usually, a higher-level part of the system chooses the strategy:

  • a factory
  • a composition root
  • dependency injection wiring
  • a runtime selector based on config or request data

That means the service using the strategy usually does not need to know how the specific implementation was selected. It only needs the contract.

For example:

function createPaymentStrategy(paymentMethod: string): PaymentStrategy {
  if (paymentMethod === 'card') return new CardPayment();
  if (paymentMethod === 'points') return new PointsPayment();
  if (paymentMethod === 'bank-transfer') return new BankTransferPayment();

  throw new Error('unsupported payment method');
}

Notice what changed. The branching may still exist somewhere, but it is now concentrated in one selection point instead of leaking through every business workflow that uses the payment behavior.

Why strategy helps more than “just use if/else”

Not every conditional is a problem. The strategy pattern helps when variation is becoming a first-class design concern.

It helps because:

  • each behavior is isolated
  • tests can focus on one policy at a time
  • stable code stops changing every time a variant is added
  • the higher-level workflow can read in role language

That matters more than the pattern name itself. The real improvement is that the design starts reflecting “a family of behaviors” instead of “one method with many branches.”

When strategy fits especially well

Strategy is a strong fit when:

  • the surrounding workflow is stable
  • the varying behavior has a clear shared contract
  • new variants are expected over time
  • runtime replacement is useful
  • behavior differences deserve their own tests and responsibilities

That is why strategy appears naturally in places like:

  • payment methods
  • discount rules
  • serialization policies
  • sorting logic
  • notification channels
  • retry or backoff policies

When strategy is too much

The strategy pattern can become overkill when:

  • there are only two tiny, stable branches
  • the behavior is unlikely to change
  • the abstraction adds more indirection than clarity
  • the surrounding code does not actually benefit from replacement

Patterns are useful only when the shape of the problem justifies them. If the variation is trivial and unlikely to grow, a small conditional may still be the clearest option.

Common mistakes

1. Replacing every if/else with strategy

Not every branch deserves a full abstraction. Strategy is for meaningful behavioral variation, not for every small boolean.

2. Letting the context still know too much about each strategy

If the so-called strategy interface keeps leaking method-specific details or type checks back into the context, the boundary is weak.

3. Creating strategies without a stable shared role

If the implementations do not really behave under one common contract, the abstraction becomes forced.

4. Forgetting where the selection logic should live

Strategy reduces branching in the use case, but selection still needs a clear home such as a factory or composition layer.

Quick checklist before using strategy

Before introducing the strategy pattern, ask:

  • is the surrounding workflow mostly stable while one behavior keeps varying?
  • do I expect more variants to appear?
  • would separate tests for each variant be valuable?
  • would the context read more clearly if it depended on a role instead of concrete branches?

If the answer is mostly yes, strategy is probably a good fit.

FAQ

Q. Is the strategy pattern the same as polymorphism?

Not exactly. Strategy is a structural pattern that commonly uses polymorphism to swap implementations behind one shared contract.

Q. Does strategy remove all conditionals from the codebase?

No. It usually relocates them to the selection point so they do not keep spreading through business workflows.

Q. How is strategy different from decorator?

Strategy replaces one behavior with another. Decorator keeps the same core behavior and adds layers around it.

Start Here

Continue with the core guides that pull steady search traffic.

Sponsored