Lesson 2.2 — Open/Closed in Practice: Discount Strategies
A second walkthrough of the Open/Closed Principle. Same teaching shape as lesson 2, but built around a discount engine in an e-commerce cart instead of payouts.
The Setup
You are working on the cart in an e-commerce Rails app. The first version of discount logic was simple: a single percentage code that took 10% off the subtotal. Then marketing wanted a flat-amount discount. Then a free-shipping promo. Now they are asking for buy-one-get-one. Each new request lands as a hotfix, and the cart total method has been growing every week.
This is the same shape of problem as the payout processors in lesson 2. The fix is the same shape too: a small registry, a uniform interface, and one class per discount strategy. Let's walk through it.
The Anti-Pattern
Here is the version of the cart that grew out of one hotfix at a time. It works. It is also the file that nobody on the team wants to touch.
class Cart
def total
subtotal = items.sum(&:line_total)
return subtotal unless coupon
case coupon.kind
when "percentage"
subtotal -= (subtotal * coupon.value / 100.0)
when "fixed"
subtotal -= coupon.value
when "free_shipping"
subtotal -= shipping_cost
when "bogo"
eligible = items.select { |i| i.product_id == coupon.eligible_product_id }
free_count = eligible.size / 2
subtotal -= eligible.first(free_count).sum(&:line_total)
end
[subtotal, 0].max
end
end
The same case coupon.kind branching appears in several other places: the order receipt email, the admin "discount usage" report, the analytics CSV export that breaks down savings by coupon type, and the thank-you page that shows the customer "You saved $X with coupon Y."
Note: Every time marketing adds a new coupon kind, the developer hunts for every case coupon.kind in the codebase and adds a new when branch. Miss one and the receipt email shows the wrong savings amount.
A Better Approach: One Class Per Strategy
Each discount kind becomes its own small class. Every class exposes the same public interface: a constructor that takes the cart and the coupon, and an amount_off method that returns the dollar value to subtract.
module Discounts
class PercentageDiscount
def initialize(cart:, coupon:)
@cart = cart
@coupon = coupon
end
def amount_off
@cart.subtotal * @coupon.value / 100.0
end
end
class FixedDiscount
def initialize(cart:, coupon:)
@cart = cart
@coupon = coupon
end
def amount_off
[@coupon.value, @cart.subtotal].min
end
end
class FreeShippingDiscount
def initialize(cart:, coupon:)
@cart = cart
@coupon = coupon
end
def amount_off
@cart.shipping_cost
end
end
class BogoDiscount
def initialize(cart:, coupon:)
@cart = cart
@coupon = coupon
end
def amount_off
eligible = @cart.items.select { |i| i.product_id == @coupon.eligible_product_id }
free_count = eligible.size / 2
eligible.first(free_count).sum(&:line_total)
end
end
end
Each class is small. Each one knows how to compute the discount for its own kind, and nothing else. The PercentageDiscount never has to think about shipping. The BogoDiscount never has to think about percentages.
The Registry
Now wire the strategies up through a small registry module. The string identifier from the database column maps to the class that implements that kind of discount.
module DiscountKind
PERCENTAGE = "PERCENTAGE"
FIXED = "FIXED"
FREE_SHIPPING = "FREE_SHIPPING"
BOGO = "BOGO"
ALL = {
PERCENTAGE => Discounts::PercentageDiscount,
FIXED => Discounts::FixedDiscount,
FREE_SHIPPING => Discounts::FreeShippingDiscount,
BOGO => Discounts::BogoDiscount
}.freeze
private_constant :ALL
def self.get(kind)
ALL[kind]
end
def self.all
ALL.keys
end
end The cart total method becomes one line, the same shape as the payout worker in lesson 2:
class Cart
def total
discount = DiscountKind.get(coupon.kind).new(cart: self, coupon: coupon).amount_off
[subtotal - discount, 0].max
end
end
No case. The cart does not know what BOGO means. It asks the registry for the right strategy and trusts the answer.
The Uniform Interface
Every discount strategy has the same public surface: it takes a cart and a coupon in the constructor, and exposes a single amount_off method. Inside, the implementations are wildly different. PercentageDiscount does a multiplication. BogoDiscount filters and slices the cart's line items. The contract is the same, the behavior is not.
# Every strategy honors this contract:
class SomeDiscount
def initialize(cart:, coupon:); end
def amount_off; end
end Important: If one of these strategies returned a different shape (a hash, a string, a negative number), the cart's total method would suddenly need branching logic again. Keeping the return type and meaning of amount_off identical across strategies is the discipline that keeps the cart simple.
Adding a New Strategy
Marketing wants a "spend $50, get $10 off" tiered threshold discount. In the original case-statement version, this would mean editing the cart total method, the receipt mailer, the discount usage report, the analytics CSV export, and the thank-you page. In the registry version, you do two things:
# 1. Create the new strategy class.
module Discounts
class ThresholdDiscount
def initialize(cart:, coupon:)
@cart = cart
@coupon = coupon
end
def amount_off
return 0 if @cart.subtotal < @coupon.threshold_cents
@coupon.value
end
end
end
# 2. Add one line to the registry.
THRESHOLD = "THRESHOLD"
ALL = {
PERCENTAGE => Discounts::PercentageDiscount,
FIXED => Discounts::FixedDiscount,
FREE_SHIPPING => Discounts::FreeShippingDiscount,
BOGO => Discounts::BogoDiscount,
THRESHOLD => Discounts::ThresholdDiscount
}.freeze That is the entire change. The cart total method does not change. The receipt mailer does not change. The CSV export does not change. New behavior comes from a new file and a new line in the registry. The existing code is closed for modification.
Supporting Principles
The same two SOLID principles support open/closed in this design.
Liskov Substitution. Any class returned from DiscountKind.get(...) must be safely usable in place of any other. The cart calls amount_off and expects a number. If a new strategy returned nil, every caller would break. The discipline is on the implementer to honor the shape of the contract, not only the method names.
# A Liskov violation in the discount engine:
class BrokenDiscount
def initialize(cart:, coupon:); end
# Returns nil when the cart is empty — silently breaks subtraction.
def amount_off
return nil if @cart.items.empty?
@cart.subtotal * 0.05
end
end
# Caller assumes a number:
[Cart.subtotal - discount.amount_off, 0].max
# NoMethodError: undefined method '-' for nil Dependency Inversion. The Cart class does not depend on PercentageDiscount or any other concrete strategy. It depends on the registry's contract. This makes testing far easier — you can swap in a fake strategy without touching the cart's code.
# In a test, replace the registry's lookup for one example:
class FakeDiscount
def initialize(cart:, coupon:); end
def amount_off; 1234; end
end
allow(DiscountKind).to receive(:get).and_return(FakeDiscount)
cart = Cart.new(items: [Item.new(price: 5000)], coupon: any_coupon)
expect(cart.total).to eq(5000 - 1234) When to Apply It
The cost of this design is indirection. A junior reading the code asks "where does the actual percentage math happen?" and has to follow the registry to find PercentageDiscount. That cost is worth paying when you have multiple variants today or one is genuinely on the roadmap.
With one discount kind, do not build the registry. With two trivially different kinds (a percentage off and a fixed amount off, both one-line calculations), an if/else in the cart total method is fine. The pattern earns its weight when the strategies have substantially different internals (BOGO and threshold discounts are nothing like a percentage discount) and when the kind identifier is referenced in many places.
Important: A common mistake is building the registry for a single class. The abstraction has nothing to abstract over. Wait until the second variant is real and on the roadmap before introducing the structure.
Practice Exercise
Pick a Rails project of yours and look for a feature that grew through hotfixes (discount logic, notification channels, report exporters, integration types, role permissions). Try the following:
- Find one method that has more than three branches in a
caseorif/elsifchain. Note which file it is in. - Search for the same field across the codebase:
grep -rn "field_name" app/. Count how many other branches you find. - Sketch the strategy classes. What is the shared contract? Two arguments in, what comes out, what type?
- Sketch the registry. What are the constants? What does
getreturn? - Sketch what adding a new variant would look like in the new structure. Compare it to what adding a new variant looks like today.
Tip: The point is not to refactor today. It is to recognize the pattern and decide if the cost of indirection is worth paying for this particular feature. Sometimes the answer is no, and that is also a senior decision.