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:
- Run
grep -rn "case " app/and look at the results. - Pick one
casestatement that switches on a string field (likekind,type,status, orprocessor). Note which file it is in. - Search for the same field in other places:
grep -rn "field_name". Look for othercaseorif/elsifbranches that switch on it. - 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.
- 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.