The Strategy Pattern in Ruby

· 8 min read · Updated April 1, 2026 · intermediate
ruby strategy-pattern design-patterns solid guides

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.

This pattern shows up everywhere in 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.

In this guide you will learn how the strategy pattern works in Ruby, how to build a clean strategy interface, implement concrete strategies, wire everything up through a context object, choose strategies at runtime, and use 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

This works, but it has problems. 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.

The strategy pattern fixes this by extracting each discount algorithm into its own class with a shared interface.

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. 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

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
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, which works seamlessly with the pattern:

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

  def apply_discount(price)
    @strategy.call(price)
  end
end
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:

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
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:

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:

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:

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
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
combo = CompositeStrategy.new(LoyaltyDiscount.new, TaxStrategy.new)
combo.apply(100)  # Apply loyalty discount, then tax

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.