โ† Back to Course

OCP Series ยท 1 of 5

The Registry Pattern

Where the Open/Closed Principle comes from, what "open for extension, closed for modification" looks like in real Rails, and how Gumroad's PayoutProcessorType lets the team add a new payment processor without touching the existing ones.

Where this rule comes from

The Open/Closed Principle is the O in SOLID. The name was coined by Bertrand Meyer in his 1988 book Object-Oriented Software Construction, and his one-line definition is the version everyone still quotes: software entities should be open for extension, but closed for modification. You should be able to add new behavior without editing the code that already works.

Meyer was reacting to a specific pain point: in early procedural codebases, adding a new variant of anything meant editing every case statement, every if/else chain, and every dispatch table in the codebase. One conceptual change scattered into many file changes, each of which was an opportunity to break something. Martin Fowler later named this anti-pattern shotgun surgery: when one logical change requires firing buckshot at the code.

The Open/Closed Principle says: structure your code so that new variants arrive as new files, not as edits to existing files. The existing code stays closed to modification; the system stays open to extension by addition.

In Rails, the most common shape of this rule is the registry pattern. You have an attribute on a record, payment.processor, user.subscription_tier, notification.kind, and at runtime you need to behave differently depending on the value. The wrong move is a case statement scattered across many files. The right move is a single registry class that maps each value to the class that handles it.

The anti-pattern

Picture a marketplace app that pays its sellers. Stripe handles US sellers; PayPal handles international ones. The team builds the feature with a case statement in the worker that processes payments:

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

    case payment.processor
    when "stripe"
      # 40 lines of Stripe-specific code: API key, charge,
      # webhook handling, retry logic, error mapping
    when "paypal"
      # 35 lines of PayPal-specific code: different API,
      # different webhook shape, different error handling
    end
  end
end

This works. The problem is that the same case payment.processor statement starts appearing in other files. The user model has a "show their default payout method" method. The admin dashboard has a "format the processor name for display" helper. The mailer that sends payout receipts has different copy per processor. The CSV export labels rows by processor. The reporting service computes processor-specific fees.

By the time the team wants to add Wise as a third processor for international creators, the same logical change needs to land in six different files. Each one needs a new when "wise" branch. Miss any of them, and a payment to a Wise creator gets the wrong status email, the wrong CSV label, or, worst case, the wrong fee calculation. Adding one new processor is a six-file pull request, with six chances to introduce a bug.

How Gumroad solves it

Gumroad pays sellers through both Stripe and PayPal in their open-source codebase, and they reach for a registry. The whole pattern is one small module at app/business/payments/payouts/payout_processor_type.rb:

# gumroad/app/business/payments/payouts/payout_processor_type.rb ยท @8f6f1c60
# License: MIT

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 whole pattern is sixteen lines. Three pieces are doing all the work:

String constants for each variant. PAYPAL = "PAYPAL", STRIPE = "STRIPE". These are the values stored on the Payment row's processor column. Defining them as constants means the rest of the codebase refers to PayoutProcessorType::STRIPE instead of bare strings, so a typo is a NameError, not a silent miss.

A private hash mapping each constant to a class. ALL is the registry. private_constant :ALL means callers cannot reach in and modify it from outside, the only way to look up a class is through the public methods.

Two class methods: .get(type) and .all. .get returns the class that handles a given type, and .all returns the list of supported types. These two methods are the entire public surface of the registry.

Every caller in the system now dispatches through the registry:

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 delegates. There is no case, no if, no scattered knowledge of which processors exist. The same shape applies in every other file that used to have processor-specific logic.

The contract that makes it work

For the registry to dispatch correctly, every class it can return must expose the same public methods. StripePayoutProcessor and PaypalPayoutProcessor have completely different internals, different API libraries, different error mappings, different timing. But their public surface is identical:

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 shared contract is the price the design charges. Ruby has no compiler to enforce it; the discipline is on the developers to make sure every processor class implements every public method. The test suite is what catches mistakes. The payoff is that the registry can substitute any class for any other and the calling code does not notice.

Adding a new processor

This is where the design pays for itself. The team needs to add Wise for international creators. In the case-statement version, that change touches six files. In the registry version, it is two:

# 1. New file: a class 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 contract
end

# 2. One-line addition 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, the CSV export, the reporting service all stay exactly as they are. New behavior comes from a new file. The existing code is closed for modification.

When to use a registry, and when not to

Three signals that a registry will earn its weight:

  • The variant identifier appears in multiple files. If case payment.processor shows up once, you do not need a registry. If it shows up six times, you have shotgun-surgery risk every time a new processor lands.
  • The variants have substantially different internals. If Stripe and PayPal both call the same API with slightly different parameters, a single class with conditional logic is fine. If they use entirely different gems, error models, and webhook formats, separate classes match the underlying reality.
  • A second variant is real, not theoretical. Build the registry when the second variant arrives, not in anticipation of one that might come. A registry with one entry is only indirection.

The common mistake in the other direction is building a registry for a single variant. The abstraction is empty until the second one shows up, and reading code with a registry of one is harder than reading the inline version. Wait until you actually have two real variants before introducing the structure.

The principle at play

The Open/Closed Principle is a structural answer to a coupling problem. When the knowledge "what processors do we support" lives in many files, every file is coupled to every processor. Adding a new processor breaks that coupling by requiring N edits. A registry collapses the coupling into one place, one file knows the list, every other file looks up by identifier and stops caring.

The deeper move, going back to Meyer in 1988, is that code that depends on an abstraction (the registry's contract) is stable when implementations change. Code that depends on concrete classes is fragile. The registry mediates between the two: callers know about the contract, the contract knows about the classes, the classes do not know about each other.

The registry is the most basic OCP pattern in Rails. The next four lessons in this series build on the same idea, adding behavior by adding a file, applied to different shapes of variation: configured backends, pipeline stages, observed events, typed subclasses. All of them are the same instinct: keep the existing code closed, make new behavior additive.

Practice exercise

  1. Run grep -rn "case " app/ | wc -l to count the case statements in your app. They are not all bugs, some are exhaustive matches over a closed set. But each one is worth a glance.
  2. Pick one case statement that switches on a string field (kind, type, status, provider). Note the file.
  3. Search for the same field elsewhere: grep -rn "field_name" app/. Count the files that have a case or if branching on that field.
  4. If the count is one, the code is fine. If it is three or more, there is a registry-shaped opportunity. Sketch the registry: what would the constants be, what would the public contract look like, could every existing variant honor it?
  5. Bonus: for context, read the long-form version of this case study at /lessons/senior-open-closed-payouts. Same pattern, more depth on Gumroad's internal structure.