Back to Course

Lesson 2 — The Open/Closed Principle in Real Rails Code

Learn how to design Rails code that handles new variants without rewriting existing files, illustrated by the payout system in Gumroad's open-source codebase.

What Is the Open/Closed Principle?

Open/Closed is one of the five SOLID design principles. It was named by Bertrand Meyer in his 1988 book Object-Oriented Software Construction. The idea is that software entities (classes, modules) should be open for extension but closed for modification. You should be able to add new behavior without changing the code that already works.

In a Rails application, this principle shows up whenever you have multiple variants of the same concept: payment processors, notification channels, report formats, integration types. When you organize the code well, adding a new variant is cheap and the existing code doesn't need to change.

We are going to study how Gumroad applies the principle to their payout system, which supports both Stripe and PayPal.

The Anti-Pattern

Before looking at the good design, here is the version of the code most Rails apps have when no one has thought about extension. A worker processes a payout and uses a case statement to branch on the processor type:

class ProcessPaymentWorker
  def perform(payment_id)
    payment = Payment.find(payment_id)

    case payment.processor
    when "stripe"
      # 40 lines of Stripe-specific code
    when "paypal"
      # 35 lines of PayPal-specific code
    end
  end
end

This works. The problem is that the same case payment.processor statement tends to appear in many places: the worker, the user model, the admin dashboard, the mailer that sends payout receipts, the CSV export. Every time you add a processor, every one of these files needs a new when branch.

Note: This pattern has a name. Martin Fowler calls it shotgun surgery: one logical change scattered across many files. It is a strong signal that the code structure does not match the shape of the problem.

A Better Approach: The Registry

Gumroad's payout system uses a small Ruby module called PayoutProcessorType. The module maps each processor's string identifier to the class that handles it.

# app/business/payments/payouts/payout_processor_type.rb
module PayoutProcessorType
  PAYPAL = "PAYPAL"
  STRIPE = "STRIPE"

  ALL = {
    PAYPAL => PaypalPayoutProcessor,
    STRIPE => StripePayoutProcessor
  }.freeze
  private_constant :ALL

  def self.get(payout_processor_type)
    ALL[payout_processor_type]
  end

  def self.all
    ALL.keys
  end
end

The module has two string constants (one per processor), a private hash that maps each constant to the class that implements it, and two class methods. get returns the class for a given type, and all returns the list of types.

Now look at how the worker dispatches a payment using the registry:

# app/sidekiq/process_payment_worker.rb
class ProcessPaymentWorker
  def perform(payment_id)
    payment = Payment.find(payment_id)
    PayoutProcessorType.get(payment.processor).process_payments([payment])
  end
end

The worker does not know which processor it is calling. It asks the registry for the right class and tells that class to do the work. There is no case statement.

The Uniform Interface

For this design to work, both processor classes must expose the same public methods with the same signatures. Here is a simplified view of both classes:

class StripePayoutProcessor
  def self.is_user_payable(user, amount_cents, ...)
  def self.has_valid_payout_info?(user)
  def self.is_balance_payable(balance)
  def self.prepare_payment_and_set_amount(payment, balances)
  def self.process_payments(payments)
end

class PaypalPayoutProcessor
  def self.is_user_payable(user, amount_cents, ...)
  def self.has_valid_payout_info?(user)
  def self.is_balance_payable(balance)
  def self.prepare_payment_and_set_amount(payment, balances)
  def self.process_payments(payments)
end

The internal code in each class is completely different. StripePayoutProcessor#is_user_payable checks for a connected Stripe account and a valid bank account. PaypalPayoutProcessor#is_user_payable checks for a valid PayPal email address and compliance information. The implementations have nothing to do with each other. The contract, meaning the public methods and what they are supposed to do, is the same.

Important: The shared contract is what makes the registry work. If process_payments failed on PayPal but worked on Stripe, the worker would need processor-specific code again. Keeping the public surface uniform is the discipline that holds the design together.

Adding a New Processor

This is where the payoff shows up. Imagine your team needs to add a third payout processor, like Wise, for international creators.

In the anti-pattern version, you would grep for case payment.processor, find six different files, and add a new when "wise" branch in each one. Miss one, and a payment might get the wrong status email or skip a notification.

In Gumroad's design, you do two things:

# 1. Create a new file with the same public interface as the others:
# app/business/payments/payouts/processor/wise/wise_payout_processor.rb
class WisePayoutProcessor
  def self.is_user_payable(user, amount_cents, ...) ... end
  def self.has_valid_payout_info?(user) ... end
  # ... and the rest of the public interface
end

# 2. Add one line to the registry:
WISE = "WISE"

ALL = {
  PAYPAL => PaypalPayoutProcessor,
  STRIPE => StripePayoutProcessor,
  WISE   => WisePayoutProcessor,
}.freeze

That is the entire change. The worker, the user model, the mailer, and the CSV export all stay exactly as they are. New behavior comes from a new file. The existing code is closed for modification.

Supporting Principles

Two other SOLID principles support Open/Closed in this design.

The first is Liskov Substitution. Any class returned from PayoutProcessorType.get(...) must be usable in place of any other. The caller does not know which one it got, and does not need to. Ruby has no compiler to enforce this, so the discipline is on the developer to keep the public surface consistent. The test suite is what catches mistakes.

The second is Dependency Inversion. The worker does not depend on StripePayoutProcessor or PaypalPayoutProcessor directly. It depends on the registry's contract: a class that responds to process_payments. High-level code (workers, models, mailers) depends on the registry, not on the specific payment APIs. This makes mocking easier in tests and makes adding new processors cheap.

When to Apply It

Open/Closed has a cost. The registry adds a layer of indirection that someone reading the code has to follow. For one variant, that cost is not worth paying. Build a registry only when you have at least two variants in the codebase or when a second one is genuinely on the roadmap.

If the differences between variants are small (a few lines of if logic), keep the simple version. The pattern earns its weight when the variants have substantially different internals and when the type identifier appears in many places.

Important: A common mistake in the other direction is building a registry for a single class. The abstraction is empty until the second variant exists. Wait until the second variant is real before introducing the structure.

Practice Exercise

Open the source code of any Rails project you have access to (yours or an open-source app like Mastodon or Discourse) and complete the following:

  1. Run grep -rn "case " app/ and look at the results.
  2. Pick one case statement that switches on a string field (like kind, type, status, or processor). Note which file it is in.
  3. Search for the same field in other places: grep -rn "field_name". Look for other case or if/elsif branches that switch on it.
  4. Count the files. If the answer is one, the code is fine. If it is three or more, there is a registry-shaped opportunity to refactor.
  5. Sketch what the registry would look like. What would the constants be? What public methods would every variant need to implement? Could every existing variant honor that contract?

Tip: The point of this exercise is not to refactor real code right now. It is to train your eye to recognize the pattern. The earlier you can see a case-on-string-field that is about to grow into a problem, the more often you will catch it before it becomes a 30-file pull request.