The Strategy Pattern in Ruby
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.