Design Patterns: Strategy

If/else chains grow and turn fragile. Strategy isolates each algorithm behind a contract.

if/else chains grow and turn fragile. The Strategy pattern encapsulates each algorithm in its own class and lets implementations be swapped at runtime without changing the consuming code.

The problem: endless if/else

A calculator with addition, subtraction, multiplication, and division usually starts as a DefaultCalculator class: private methods per operation and a public function choosing which to invoke with a switch or if/else chain.

class DefaultCalculator {
  public calculate(parameters: BinaryOperationParameters): Result {
    const { operator, firstOperand, secondOperand } = parameters;

    switch (operator) {
      case "*":
        return firstOperand * secondOperand;
      case "+":
        return firstOperand + secondOperand;
      case "-":
        return firstOperand - secondOperand;
      case "/":
        return firstOperand / secondOperand;
      case "**":
        return firstOperand ** secondOperand;
      case "%":
        return firstOperand % secondOperand;
      default:
        throw new Error("Operator not found!");
    }
  }
}

The problem appears when the calculator must cover more binary operations between integers: percent, exponentiation, modulo, bit shifts. Each new feature changes the original implementation, raises coupling, and makes maintenance more expensive.

What is the Strategy pattern?

The pattern defines functionality through a contract (interface), implemented according to context. The interface defines the operation; concrete implementations define its execution.

Consuming code depends on the abstraction. Each strategy stays isolated in its own class. New behaviors arrive without changing existing code, aligned with the Open/Closed Principle.

Defining the contract

The first step is the strategy contract interface. For the calculator, something receiving two numbers and returning the result:

interface BinaryOperationParameters {
  firstOperand: number;
  secondOperand: number;
  operator: string;
}

type Result = number;

interface BinaryOperationStrategy {
  calculate(
    parameters: Pick<
      BinaryOperationParameters,
      "firstOperand" | "secondOperand"
    >,
  ): Result;
}

Implementing concrete strategies

Each math operation becomes a class implementing BinaryOperationStrategy and executing a single operation. Addition and division:

class Sum implements BinaryOperationStrategy {
  public calculate(
    parameters: Pick<
      BinaryOperationParameters,
      "firstOperand" | "secondOperand"
    >,
  ): Result {
    const { firstOperand, secondOperand } = parameters;
    return firstOperand + secondOperand;
  }
}

class Division implements BinaryOperationStrategy {
  public calculate(
    parameters: Pick<
      BinaryOperationParameters,
      "firstOperand" | "secondOperand"
    >,
  ): Result {
    const { firstOperand, secondOperand } = parameters;
    if (secondOperand === 0) {
      throw new Error("Division by zero is not allowed!");
    }
    return firstOperand / secondOperand;
  }
}

The Context and the Factory

To tie strategies together, a Context and a Factory (or Analyzer) come in. In ContextAnalyzer, a method evaluates the operator and returns the right Strategy:

class ContextAnalyzer {
  public getInstance(operator: string): BinaryOperationStrategy {
    switch (operator) {
      case "*":
        return new Multiplication();
      case "+":
        return new Sum();
      case "-":
        return new Subtraction();
      case "/":
        return new Division();
      case "%":
        return new Percent();
      case "**":
        return new Pow();
      default:
        throw new Error("Operator not found!");
    }
  }
}

The context receives and executes the strategy. It knows only the contract, not the concrete implementation. The old DefaultCalculator starts receiving this ContextAnalyzer by injection:

class Calculator {
  constructor(private readonly contextAnalyzer: ContextAnalyzer) {}

  /**
   * The calculate implementation in Calculator does not change per operation.
   * What grows is the ContextAnalyzer, which adds one case per new operation.
   */
  public calculate(parameters: BinaryOperationParameters): Result {
    const { operator, firstOperand, secondOperand } = parameters;
    return this.contextAnalyzer
      .getInstance(operator)
      .calculate({ firstOperand, secondOperand });
  }
}

Why use Strategy?

Strategy helps in legacy code with several business rules, each represented by an if and a long implementation. The shared part stays in the contract; each rule variation stays in its own class; a context analyzer (resolver/factory) picks the strategy.

On each request, the code evaluates the operation context and selects the implementation matching the contract.

Relation to other patterns

  • Adapter: Strategy varies behavior; Adapter isolates external dependencies behind an owned interface.
  • SOLID (OCP): Strategy is one way to apply the Open/Closed Principle.