Back to Course

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:

  1. Find one method that has more than three branches in a case or if/elsif chain. Note which file it is in.
  2. Search for the same field across the codebase: grep -rn "field_name" app/. Count how many other branches you find.
  3. Sketch the strategy classes. What is the shared contract? Two arguments in, what comes out, what type?
  4. Sketch the registry. What are the constants? What does get return?
  5. 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.