rubyguides

Strategy Pattern in Ruby: A Design Pattern for Runtime Swaps

The strategy pattern is a behavioral design pattern that lets you define a family of algorithms, put each one in its own class, and make their objects interchangeable at runtime. The key idea is simple: instead of hardcoding which algorithm to use inside a piece of code, you delegate that decision to a separate object that implements a common interface.

You will find this pattern across well-structured Ruby codebases. Payment processors that can swap between Stripe, PayPal, or Braintree. Sort implementations that switch between quicksort, merge sort, or heap sort based on data characteristics. Authentication backends that choose between LDAP, OAuth, or SAML. In every case, the calling code does not care which concrete strategy is running; it just calls the interface.

This article walks through the strategy pattern in Ruby: building a clean strategy interface, implementing concrete strategies, wiring everything up through a context object, choosing strategies at runtime, and using blocks and procs as lightweight alternatives. You will also see how it compares to conditionals and inheritance, with practical examples throughout.

What problem does the Strategy Pattern solve?

Consider a DiscountCalculator that applies different discount rules depending on the customer type:

class DiscountCalculator
  def initialize(customer_type, price)
    @customer_type = customer_type
    @price = price
  end

  def apply_discount
    case @customer_type
    when :student
      @price * 0.80  # 20% off
    when :senior
      @price * 0.85  # 15% off
    when :loyalty
      @price * 0.75  # 25% off
    when :none
      @price
    end
  end
end

The approach works for a handful of cases, but it creates maintenance headaches as the system grows. Every time you add a new discount type you have to open this class and modify the case statement. The logic for each discount is tangled together in one place. Testing any single discount rule requires instantiating the calculator with the right arguments. And the single-responsibility principle is violated. This one class knows about every discount algorithm that has ever existed.

Extracting each discount algorithm into its own class with a shared interface solves all of these problems.

The strategy interface

A strategy interface is just a shared protocol: in Ruby, an abstract base class or module that defines the method(s) concrete strategies must implement. The interface does not need to be enforced mechanically; Ruby trusts you to follow the contract.

class DiscountStrategy
  def apply(price)
    raise NotImplementedError, "#{self.class} must implement #apply"
  end
end

This base class documents the expected interface and enforces it at runtime if someone forgets to implement #apply.

Concrete strategies

Each concrete strategy encapsulates one algorithm. The key advantage is isolation: every strategy lives in its own class, so changes to one discount rule cannot break another. This also makes unit testing straightforward because each strategy can be tested with focused examples without setting up unrelated behavior. Here are three discount strategies:

class StudentDiscount < DiscountStrategy
  def apply(price)
    price * 0.80
  end
end

class SeniorDiscount < DiscountStrategy
  def apply(price)
    price * 0.85
  end
end

class LoyaltyDiscount < DiscountStrategy
  def apply(price)
    price * 0.75
  end
end

Each class has one job and does it well. You can test, modify, or replace any of them without touching the others.

The context object

The context is the object that holds a reference to the current strategy and delegates work to it. Clients configure the context with whichever strategy they need:

class DiscountCalculator
  def initialize(strategy)
    @strategy = strategy
  end

  def apply_discount(price)
    @strategy.apply(price)
  end

  def strategy=(new_strategy)
    @strategy = new_strategy
  end
end

Clients get a clean API that hides the strategy selection entirely. Instead of passing a customer type and price, you pass a pre-configured strategy object. The context delegates the actual discount calculation to whatever strategy it holds. This indirection is the whole point of the pattern; the context becomes a thin coordinator that knows nothing about specific discount rules. Usage is clean and explicit:

calculator = DiscountCalculator.new(LoyaltyDiscount.new)
calculator.apply_discount(100)  # => 75.0
calculator.strategy = SeniorDiscount.new
calculator.apply_discount(100)  # => 85.0

The DiscountCalculator no longer knows anything about student, senior, or loyalty logic. It just holds a strategy and calls it. This is the Open/Closed Principle in action: you can introduce new discount strategies without modifying DiscountCalculator.

Choosing strategies at runtime

One of the main benefits of the strategy pattern is swapping behavior dynamically. A common pattern is to map keys to strategy classes and look them up:

class DiscountCalculator
  STRATEGIES = {
    student:  StudentDiscount,
    senior:   SeniorDiscount,
    loyalty:  LoyaltyDiscount,
    none:     ->(price) { price }  # inline fallback
  }.freeze

  def initialize(strategy_key)
    @strategy = build_strategy(strategy_key)
  end

  def apply_discount(price)
    @strategy.apply(price)
  end

  private

  def build_strategy(key)
    strategy_class = STRATEGIES.fetch(key)
    strategy_class.new
  rescue NoMethodError
    # Lambda/proc was passed directly
    strategy_class
  end
end

The build_strategy private method handles the lookup from the STRATEGIES constant and instantiates the correct class. This keeps the constructor simple (it receives a key, not a class) while the mapping stays centralized in one place. The fetch call raises a KeyError if an unknown key gets passed, which gives you a clear error message rather than a mysterious nil. When you pass the key :loyalty, the context creates a LoyaltyDiscount instance behind the scenes:

calculator = DiscountCalculator.new(:loyalty)
calculator.apply_discount(200)  # => 150.0

When a new discount type appears (say a holiday promotion), you add a new entry to STRATEGIES and a new concrete class. The rest of the system does not change.

Using blocks and procs as strategies

For simple one-liner strategies, defining a full class can feel heavyweight. Ruby lets you use procs and lambdas as lightweight strategy objects (see also /guides/ruby-blocks-procs-lambdas/ for a deeper treatment of callable objects), which works naturally with the pattern:

class DiscountCalculator
  def initialize(&block)
    @strategy = block
  end

  def apply_discount(price)
    @strategy.call(price)
  end
end

This version accepts a block instead of a strategy object. The &block syntax captures whatever block the caller passes and stores it in @strategy. When apply_discount is called, it invokes the stored proc via call. This approach is ideal when the strategy logic is short enough to express in a single expression. Calling it looks like this:

calculator = DiscountCalculator.new { |price| price * 0.90 }
calculator.apply_discount(100)  # => 90.0

For a family of related strategies, a hash of procs works well. It gives you the same key-based lookup as the class-based STRATEGIES constant, but without defining separate classes. Each value in the hash is a lambda that takes a weight and computes a shipping cost. This pattern shines when each strategy is a simple formula; the hash acts as both the registry and the implementation, keeping everything visible in one place:

class ShippingCalculator
  SHIPPING_STRATEGIES = {
    standard: ->(weight) { weight * 2.0 + 5.0 },
    express:  ->(weight) { weight * 5.0 + 15.0 },
    overnight: ->(weight) { weight * 10.0 + 30.0 }
  }.freeze

  def initialize(strategy_key)
    @calculate = SHIPPING_STRATEGIES.fetch(strategy_key)
  end

  def total(weight)
    @calculate.call(weight)
  end
end

The constructor stores the chosen lambda in @calculate, and total delegates to it with call. Because the hash uses fetch, passing an unknown key raises an immediate KeyError rather than silently producing nil. This makes debugging strategy-key typos straightforward. Here is how a client uses the shipping calculator with the express strategy:

ShippingCalculator.new(:express).total(2.5)  # => 27.5

Lambdas are stricter than procs; they enforce the number of arguments, which makes them a better fit when you want predictable behavior. Procs are more permissive, which can be useful when different strategies need different numbers of inputs.

Practical example: Payment processing

Payment processors are a canonical use case for the strategy pattern. Each payment gateway has its own API, authentication, and response format, but the checkout flow should not care which one is active:

class PaymentStrategy
  def process(amount, currency:)
    raise NotImplementedError
  end
end

class StripePayment < PaymentStrategy
  def initialize(api_key:)
    @api_key = api_key
  end

  def process(amount, currency:)
    # Stripe-specific logic
    "stripe_ch_#{SecureRandom.hex(8)}"
  end
end

class PayPalPayment < PaymentStrategy
  def initialize(client_id:, client_secret:)
    @client_id = client_id
    @client_secret = client_secret
  end

  def process(amount, currency:)
    # PayPal-specific logic
    "paypal_tx_#{SecureRandom.hex(8)}"
  end
end

The checkout context holds a strategy and calls it uniformly. The Checkout class receives a payment strategy in its constructor and stores it for later use. When pay is called, it delegates the actual payment processing to the strategy’s process method, then wraps the resulting transaction ID in a standard response hash. The checkout logic never branches on which payment provider is active; it simply calls the interface. Here is the implementation:

class Checkout
  def initialize(payment_strategy)
    @payment_strategy = payment_strategy
  end

  def pay(amount, currency: "USD")
    transaction_id = @payment_strategy.process(amount, currency: currency)
    { status: :success, transaction_id: transaction_id, amount: amount }
  end
end

Swapping payment providers is a one-line change. You instantiate the checkout with a different strategy object and the rest of the code stays exactly the same. In production, you would typically read the active payment provider from configuration or environment variables, build the corresponding strategy, and inject it into the checkout. This makes it easy to add a new payment gateway later without touching the checkout logic at all. For example, here is a checkout using Stripe:

checkout = Checkout.new(StripePayment.new(api_key: ENV["STRIPE_KEY"]))
checkout.pay(49.99, currency: "USD")

Practical example: Sorting algorithms

Another clean example is sorting. Different algorithms have different performance characteristics depending on the input data. The strategy pattern lets you swap algorithms without changing the sorting client. QuickSort performs well on average with random data, while MergeSort guarantees O(n log n) even in the worst case. By wrapping each algorithm in its own strategy class, you can choose the best one for a given dataset without modifying the code that calls sort. Here is a strategy-based sorting implementation:

class SortStrategy
  def sort(array)
    raise NotImplementedError
  end
end

class QuickSort < SortStrategy
  def sort(array)
    return array.dup if array.length <= 1
    pivot = array.first
    rest  = array.drop(1)
    left  = rest.select { |x| x < pivot }
    right = rest.select { |x| x >= pivot }
    left.sort(pivot: pivot) + [pivot] + right.sort(pivot: pivot)
  end
end

class MergeSort < SortStrategy
  def sort(array)
    return array.dup if array.length <= 1
    mid = array.length / 2
    left  = MergeSort.new.sort(array[0...mid])
    right = MergeSort.new.sort(array[mid..])
    merge(left, right)
  end

  private

  def merge(left, right)
    result = []
    until left.empty? || right.empty?
      result << (left.first <= right.first ? left.shift : right.shift)
    end
    result + left + right
  end
end

The sorting strategies follow the same interface pattern: each implements sort(array) and returns a sorted copy of the input. QuickSort partitions around a pivot element and recursively sorts the left and right sub-arrays. MergeSort splits the array in half, recursively sorts each half, then merges the sorted halves back together. The client code does not need to know which algorithm is running behind the scenes. Here is how the DataSorter context ties them together:

class DataSorter
  def initialize(strategy)
    @strategy = strategy
  end

  def sort(data)
    @strategy.sort(data)
  end

  def strategy=(new_strategy)
    @strategy = new_strategy
  end
end

sorter = DataSorter.new(MergeSort.new)
sorter.sort([3, 1, 4, 1, 5])  # => [1, 1, 3, 4, 5]

Strategy Pattern vs conditionals vs inheritance

The natural instinct when faced with branching logic is to reach for a case statement or a chain of if/elsif. This works fine for two or three branches with simple logic. The problems start when branches multiply, each branch grows its own complexity, or you need to add new branches frequently.

Inheritance seems like an alternative: a base DiscountCalculator with subclasses for each discount type. But inheritance is a static relationship. A StudentDiscountCalculator is permanently a student discount calculator. What happens when you need a student discount that applies only above a certain price threshold? You either create a hybrid subclass or cram new parameters into the existing one. Both paths lead to class explosion.

The strategy pattern solves both problems. Each strategy is a single, focused class. New strategies add classes, not conditionals. Strategies can hold their own configuration without polluting a shared base class. And because strategies are objects passed in from the outside, you can create them conditionally, inject test doubles easily, and swap them at runtime based on user input or system configuration.

Use conditionals for simple, stable branching. Reach for the strategy pattern when the branching logic is likely to grow, when each branch is complex enough to warrant its own class, or when you need to swap algorithms at runtime.

Combining strategies

Strategies can be composed. A common pattern is a CompositeStrategy that runs multiple strategies in sequence:

class CompositeStrategy
  def initialize(*strategies)
    @strategies = strategies
  end

  def apply(price)
    @strategies.reduce(price) { |p, strategy| strategy.apply(p) }
  end
end

The CompositeStrategy accepts any number of strategy objects via the splat operator and applies them sequentially using reduce. Each strategy receives the price returned by the previous strategy, so the order of composition matters. This pattern is useful when you need to chain multiple transformations, such as applying a loyalty discount followed by tax calculation. Here is an example that combines the loyalty discount with a tax strategy:

combo = CompositeStrategy.new(LoyaltyDiscount.new, TaxStrategy.new)
combo.apply(100)  # Apply loyalty discount, then tax

Key takeaways

  • The strategy pattern moves interchangeable algorithms into separate objects.
  • A context class stays simple because it only knows how to call the current strategy.
  • You can swap strategies at runtime, which makes the pattern useful for configuration-driven behavior.
  • Small strategies can be classes, but Ruby blocks and procs are often enough for lightweight cases.
  • Use the pattern when the branching logic is growing or when you want to test algorithms independently.

The strategy pattern is especially handy when the calling code should stay ignorant of the algorithm details. That keeps business rules easier to read and reduces the chance of a giant conditional growing in the middle of a class.

See Also

  • /guides/ruby-command-pattern/, The command pattern encapsulates requests as objects, similar in spirit to strategy but focused on undoable operations.
  • /guides/ruby-decorators-pattern/, Decorators wrap objects to add behavior dynamically, often used alongside strategies in well-designed Ruby applications.
  • /guides/ruby-service-objects/, Service objects group business logic that does not fit neatly into a model or controller, and often use strategies internally.