The ticket says “we’re launching in the US, add PayPal.” You open OrderProcessor, find a switch on a provider code, and add a fourth branch. The feature works. But you just edited a file that three other teams depend on, re-ran every existing provider’s tests for a change that had nothing to do with them, and picked up a merge conflict from whoever is adding Razorpay on another branch.

That is the Open-Closed Principle’s entire complaint. From the series glossary: you should be able to add behavior by adding code, not by editing code that already works.

Bertrand Meyer’s original phrasing is “open for extension, closed for modification,” which sounds like a riddle until you watch the same file get edited every quarter. This post takes the Order / PaymentGateway / OrderProcessor domain from Part 1, grows the payment switch until it hurts, and cuts one seam that makes the next provider a new file instead of a diff.

The switch that grows every quarter

Here is the payment slice of OrderProcessor after two expansions. It is honest code — nobody wrote this badly on purpose:

public class OrderProcessor {

    private final OrderRepository orders;

    public OrderProcessor(OrderRepository orders) {
        this.orders = orders;
    }

    public void process(Order order, String providerCode) {
        BigDecimal payable = order.total();
        String reference;

        switch (providerCode) {
            case "STRIPE" -> {
                StripeClient stripe = new StripeClient(System.getenv("STRIPE_KEY"));
                reference = stripe.charge(order.id(), payable);
            }
            case "PAYPAL" -> {
                PayPalClient paypal = new PayPalClient(
                        System.getenv("PAYPAL_ID"), System.getenv("PAYPAL_SECRET"));
                reference = paypal.createAndCapture(payable, "USD", order.customerEmail());
            }
            case "RAZORPAY" -> {
                RazorpayClient razorpay = new RazorpayClient(System.getenv("RAZORPAY_KEY"));
                reference = razorpay.pay(payable, order.id());
            }
            default -> throw new IllegalStateException("unknown provider: " + providerCode);
        }

        orders.markPaid(order.id(), reference);
    }
}

Now price the next change. Adding PayU means:

M OrderProcessor.java      one more case, one more SDK, one more secret lookup
  -> re-review a file that owns the paid-order state machine
  -> re-run Stripe / PayPal / Razorpay tests that did not change
  -> conflict with anyone else editing the same switch this sprint

Three costs, and none of them are about PayU. The switch makes every provider a co-owner of the core policy.

Note: The problem is not the switch keyword. A switch over a stable set is fine (see When the switch is fine). The problem is a branch set that grows on someone else’s schedule, sitting inside code you cannot afford to break.

What “open” and “closed” actually mean

Two words, two different guarantees:

WordMeansYou can tell because
Open for extensionNew behavior is possibleA new provider can be plugged in
Closed for modificationExisting source is not editedgit status shows an added file, not a modified one

The trap is treating “closed” as absolute. Nothing is closed against every kind of change. A class is closed against a specific axis, and you choose the axis. So the useful design question is never “is this class open-closed?” but “which change do I expect to repeat, and is the code closed against that one?”

For OrderProcessor, the repeating change is a new payment provider. The axis is clear, the evidence is in the git log, and the fix is one seam. If instead the repeating change were “payments now need a refund step,” no interface would help — that is a change to the policy itself, and the policy file should be edited.

The seam: one interface per provider

Part 1 already sketched the abstraction. Give it a result type so failures do not have to travel as exceptions from three different SDKs:

public interface PaymentGateway {

    String providerCode();

    PaymentResult charge(Order order, BigDecimal amount);
}

public record PaymentResult(boolean approved, String reference, String failureReason) {

    public static PaymentResult approved(String reference) {
        return new PaymentResult(true, reference, null);
    }

    public static PaymentResult declined(String reason) {
        return new PaymentResult(false, null, reason);
    }
}

Each case of the old switch becomes a class. The vendor SDK arrives through the constructor, so the gateway does not read the environment either:

public final class StripeGateway implements PaymentGateway {

    private final StripeClient client;

    public StripeGateway(StripeClient client) {
        this.client = client;
    }

    @Override
    public String providerCode() {
        return "STRIPE";
    }

    @Override
    public PaymentResult charge(Order order, BigDecimal amount) {
        try {
            return PaymentResult.approved(client.charge(order.id(), amount));
        } catch (StripeCardException e) {
            return PaymentResult.declined(e.getDeclineCode());
        }
    }
}

PayPalGateway has the same shape around a client with a completely different API — which is the point, since the mismatch now lives next to the vendor instead of inside the order policy:

    @Override
    public PaymentResult charge(Order order, BigDecimal amount) {
        PayPalCapture capture = client.createAndCapture(amount, "USD", order.customerEmail());
        return capture.isCompleted()
                ? PaymentResult.approved(capture.id())
                : PaymentResult.declined(capture.statusReason());
    }

The processor loses the branches, the SDKs, the secrets, and the provider argument:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final OrderRepository orders;

    public OrderProcessor(PaymentGateway gateway, OrderRepository orders) {
        this.gateway = gateway;
        this.orders = orders;
    }

    public void process(Order order) {
        PaymentResult result = gateway.charge(order, order.total());
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
    }
}

Read what happened to the dependency direction: OrderProcessor used to know three vendors, and now the three vendors know one interface it owns. That flip is why adding a fourth vendor cannot reach this file — and it is most of Dependency Inversion arriving as a side effect.

Choosing a provider without a new switch

“You just moved the switch” is the fair objection, so answer it directly: something still has to turn "PAYPAL" from a checkout form into an object. The difference is that the choice becomes a lookup in data, not a branch in control flow:

public class PaymentGateways {

    private final Map<String, PaymentGateway> byCode;

    public PaymentGateways(List<PaymentGateway> gateways) {
        this.byCode = gateways.stream()
                .collect(Collectors.toMap(PaymentGateway::providerCode, Function.identity()));
    }

    public PaymentGateway forCode(String code) {
        PaymentGateway gateway = byCode.get(code);
        if (gateway == null) {
            throw new UnsupportedProviderException(code);
        }
        return gateway;
    }
}

The map has no cases to add. Registration happens once, at the edge, where secrets and configuration already live:

PaymentGateways gateways = new PaymentGateways(List.of(
        new StripeGateway(new StripeClient(env("STRIPE_KEY"))),
        new PayPalGateway(new PayPalClient(env("PAYPAL_ID"), env("PAYPAL_SECRET"))),
        new RazorpayGateway(new RazorpayClient(env("RAZORPAY_KEY")))));

In Spring you do not even write the list: declare each gateway as a bean and take List<PaymentGateway> in the constructor, and the container collects every implementation on the classpath.

Note: Keep the registry dumb. The moment PaymentGateways starts asking which provider it holds — “if it’s Razorpay, convert to INR first” — the switch has grown back in a new file. Currency conversion belongs in the gateway that needs it.

What the diff looks like now

Same feature request, both designs:

Before — add PayU
  M OrderProcessor.java     new case inside the paid-order policy
  M OrderProcessorTest.java existing tests re-run, some rewritten

After — add PayU
  A PayUGateway.java        new class, no existing caller touched
  A PayUGatewayTest.java    new test, isolated to one vendor
  M PaymentConfig.java      one line in the registration list

One added file plus one line of wiring. OrderProcessor is now closed against “a new provider appears” and still fully open to editing when the policy changes — which is correct. A refund step, a fraud check, a second charge attempt: all of those are edits to process, because they are changes to what the core actually does.

Proving it with a fake gateway

The seam pays a second dividend immediately: the policy becomes testable without a network, a sandbox account, or a key.

class FakePaymentGateway implements PaymentGateway {

    PaymentResult next = PaymentResult.approved("ref-1");

    @Override
    public String providerCode() {
        return "FAKE";
    }

    @Override
    public PaymentResult charge(Order order, BigDecimal amount) {
        return next;
    }
}

@Test
void declined_payment_does_not_mark_the_order_paid() {
    FakePaymentGateway gateway = new FakePaymentGateway();
    gateway.next = PaymentResult.declined("insufficient_funds");
    InMemoryOrderRepository orders = new InMemoryOrderRepository();

    OrderProcessor processor = new OrderProcessor(gateway, orders);

    assertThrows(PaymentDeclinedException.class, () -> processor.process(sampleOrder()));
    assertTrue(orders.paidIds().isEmpty());
}

That test was impossible against the switch version without a live Stripe key. Testability is the cheapest evidence that a seam is real: if you cannot substitute an implementation in a test, you have not built an extension point.

Sealed hierarchies: when a closed set is the point

OCP is about variation that keeps arriving. Some sets are genuinely finished, and for those you want the opposite guarantee — a compiler error when someone adds a case and forgets a caller.

A payment outcome is that kind of set. There are three, you own all three, and every caller must handle each one:

public sealed interface ChargeOutcome
        permits ChargeOutcome.Approved, ChargeOutcome.Declined, ChargeOutcome.RequiresAction {

    record Approved(String reference) implements ChargeOutcome {}

    record Declined(String reason) implements ChargeOutcome {}

    record RequiresAction(URI redirect) implements ChargeOutcome {}
}
String describe(ChargeOutcome outcome) {
    return switch (outcome) {
        case ChargeOutcome.Approved(String reference) -> "charged, ref " + reference;
        case ChargeOutcome.Declined(String reason) -> "declined: " + reason;
        case ChargeOutcome.RequiresAction(URI redirect) -> "3-D Secure at " + redirect;
    };
}

No default. Add a fourth permitted record and the compiler hands you the list of every switch that must be updated — see Sealed Classes and Pattern Switch for the mechanics. That is a deliberate rejection of open extension, and it is the right call here because a silently unhandled outcome is a payment bug.

Pick by asking who adds the next member:

SetWho extends itShape
Payment providersBusiness partnerships, unpredictablyinterface + one class per provider
Notification channelsProduct, occasionallyinterface + registry
Charge outcomes, order statesYou, rarely, with intentsealed + exhaustive switch

Do not seal the provider list. Sealing it recreates the original problem with better syntax: every new gateway becomes a compile error in every consumer, which is exactly the coupling the seam removed.

When the switch is fine

OCP is the principle most often applied too early, so keep the brakes on. Leave the switch alone when:

  • It has three cases and has not changed in two years. Age is evidence. A stable branch set is not a design debt.
  • The branches share no dependency. Formatting a label three ways is local logic, not an integration; extracting it buys three one-line classes and a registry to find them.
  • You would ship exactly one implementation. An interface with a single implementer and no test double is ceremony, not design.
  • The variation is speculative. “We might add a provider someday” is not a change request. Build the seam when the second one is actually funded.

The trigger to refactor is a repeat: the same branch set edited twice for unrelated reasons, or a new case that drags a new dependency into a file that had no business knowing it. That is what happened above — each provider brought its own SDK and its own secret into the order policy.

Note: OCP does not say “never edit this file.” Bug fixes, performance work, and genuine policy changes all edit the core, and should. Closed means new variants of a known axis do not force an edit — nothing more.

Cheat sheet

Open    -> new behavior can be added        (a new class plugs in)
Closed  -> existing source is not edited    (git shows A, not M)
Always closed against ONE axis you choose — never against everything

Smell    growing switch/if-else over a type code, each case dragging its own SDK
Seam     interface + one implementation per variant + registry at the edge
Proof    can you swap an implementation in a test with no network?
Stop     one implementation, no test double, no funded second variant
Open set   (providers, channels, rules)  -> interface, extend by adding
Closed set (outcomes, states, commands)  -> sealed, extend by compiler error

Do:

  • Cut the seam on the axis your git log shows changing, not the one that feels abstract.
  • Keep the variant selection in a map or DI container so nothing branches on the type code.
  • Hand vendor SDKs and secrets to implementations through constructors, and use sealed when you want every consumer to break on a new case.
  • Treat “I can fake it in a test” as the acceptance criterion for a real extension point.

Don’t:

  • Add a strategy interface for a switch with three stable cases.
  • Rebuild the switch inside the registry, or seal a set that partners keep extending.
  • Read “closed for modification” as “this file is frozen” — policy changes belong in policy.

Wrap-up

The Open-Closed Principle is one trade: pay for an interface now so the next variant is an added file instead of an edited one. In the payment domain that meant PaymentGateway with a class per provider, a map instead of a branch, and wiring pushed to the edge — after which OrderProcessor stopped knowing that Stripe exists and became testable without a key.

The judgment call is not how to build the seam; it is which axis deserves one. Providers keep arriving, so they get an interface. Charge outcomes are a finished set you own, so they get sealed and an exhaustive switch that breaks loudly. Everything else keeps its switch until a real change request says otherwise. Definitions and the rest of the map are in SOLID Roadmap.

Next optional step in the series Make subtypes keep the contract callers already rely on. Liskov Substitution: Subtypes That Don't Surprise Callers