Open/Closed, Critically
Prefer designs where common, observed variation can be added without rewriting stable core logic. Not "never modify existing code" — that reading builds plugin machinery for variation that never arrives.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
When is it worth making a piece of code extensible, given that extensibility is only ever cheap for the variation you predicted?
Checkout supports card and PayPal. Adding PayPal meant editing a switch statement in Checkout.process, and a bug in that edit briefly broke card payments. Bank transfer is now on the roadmap, and someone has proposed making checkout "closed for modification".
Add a case to the switch. It is one edit, it is visible, and a reader can see all three payment methods in one place — which is genuinely useful, because comparing them is a thing people do. Two cases in a switch is not a design problem, and abstracting it at that point would have been guessing (Premature Abstraction).
The switch is inside the core flow, so every payment-method edit is an edit to code that all payment methods depend on. That is why the PayPal change broke cards — not because modification is bad, but because *this* modification touched shared logic (Change Amplification).
- The switch is inside the core flow, so every payment-method edit is an edit to code that all payment methods depend on. That is why the PayPal change broke cards — not because modification is bad, but because *this* modification touched shared logic (Change Amplification).
- Each new method drags its concerns into the core: PayPal needs a redirect, bank transfer needs an asynchronous confirmation days later, and the flow accretes conditionals that only one branch uses.
- The core flow becomes untestable in isolation, since every test must pick a payment method and therefore exercises payment code it does not care about.
- By the fourth method the shared flow has method-specific
ifs scattered through it, and nobody can say which lines are shared any more (Divergent Change).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Two variations exist today; the third is named and funded, which is different from hypothetical and changes the arithmetic (The Rule of Three).
- The core checkout flow — reserve stock, charge, confirm, emit event — is the same for all payment methods and is the part that broke.
- This is a monorepo deployed continuously. Nothing is shipped as a binary to anyone, so Meyer's original justification for the principle does not apply here at all.
- The team has one previous plugin system in the codebase, built for variation that never materialised, and everyone remembers it.
- Stock is reserved before any charge is attempted, and released if the charge fails — whatever payment method is in play. This must not be re-implementable per method.
- Every completed checkout emits exactly one
OrderPlacedevent, with the same shape, regardless of payment method.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The core flow owns the invariant sequence: reserve, charge, confirm, emit. It should be one readable function and should not know how many payment methods exist.
- Each payment method owns its own mechanics: redirect handling, asynchronous confirmation, refund semantics.
- Something owns the mapping from a method identifier to an implementation, and that something is wiring, not logic (Wiring and the Composition Root).
- Nobody owns "all payment methods in one place" as a reading convenience — that is a real loss and it should be replaced by documentation, not by keeping the switch.
- The seam is between the invariant sequence and the per-method mechanics. It falls there because the sequence is what must not vary and the mechanics are what must.
- The seam is justified by observed variation, not by principle. Two payment methods with a third funded is evidence about the axis; one method and a feeling is not (Speculative Generality).
- Crucially, the seam does not have to be a plugin system. An interface with three implementations chosen by a map in the composition root is the same extensibility with none of the machinery (Strategy).
The fourth payment method, priced both ways
The argument for extraction here is not that modification is bad. It is that this particular modification touches a function every payment method depends on, which is why a PayPal change broke cards. The blast radius is the problem; the edit is not.
Reading the cost row is the whole point of pricing it. Extraction makes one kind of change cheap and another kind — the cross-cutting one — genuinely more expensive, and a team that only counts the first is going to be surprised later.
Customers can pay by bank transfer, which confirms asynchronously — sometimes days later — rather than at checkout time.
The edit lands in the function every method shares, so the whole checkout suite has to be re-run and re-reasoned about. Worse, asynchronous confirmation does not fit the flow at all, so a "pending" branch gets threaded through the shared sequence and every existing method now has a state it never uses.
One new implementation, one wiring line. Card and PayPal code is not touched, so it cannot break. The asynchronous confirmation still forces a real design decision — the interface has to be able to say "not settled yet" — but that decision is made once, in the contract, rather than smeared through the flow.
"Closed for modification" is not what makes this work
It is worth being precise about which property is doing the work, because the slogan points at the wrong one. The core sequence being unmodified is not a virtue in itself; the virtue is that adding a variation does not require touching logic that other variations depend on. Those coincide here, and they do not always.
The second version below is also more honest about its scope. It is open along exactly one axis, and it says so in a comment that a future engineer can act on.
// payments/registry.ts
export class PaymentPluginRegistry {
private plugins = new Map<string, PaymentPlugin>()
discover(dir: string) { /* scan, import, validate manifest */ }
register(p: PaymentPlugin) { /* version check, capability negotiation */ }
resolve(id: string): PaymentPlugin { /* ... */ }
}
interface PaymentPlugin {
readonly apiVersion: 2
readonly capabilities: Capability[]
charge(ctx: PluginContext, amount: Money): Promise<PluginResult>
}
// Three in-house implementations, in the same repo,
// deployed together, by the same team. Discovery, versioning
// and capability negotiation solve problems nobody has.// checkout/PaymentMethod.ts — declared by the core
export interface PaymentMethod {
charge(amount: Money, ref: OrderRef): Promise<Settlement>
}
// checkout/process.ts — concrete, stable, one screen
export async function process(order: Order, pay: PaymentMethod) {
const hold = await stock.reserve(order) // invariant sequence,
try { // identical for every method
const s = await pay.charge(order.total, order.ref)
await confirm(order, s); events.emit(placed(order))
} catch (e) { await stock.release(hold); throw e }
}
// composition/graph.ts — open along exactly one axis:
// new payment methods. Everything else is a normal edit.
const methods = { card, paypal, bank }Both are open to new payment methods without touching the core sequence, which was the entire requirement. The right-hand version gets there with an interface and an object literal; the left-hand version adds discovery, manifests, API versioning and capability negotiation — machinery that exists to support implementations written by people you cannot talk to, deployed on a schedule you do not control. Three implementations in your own repo have neither problem. The machinery is not extra safety; it is extra surface that must be maintained, versioned and debugged (Plugin Architecture covers when it does earn its place).
The five-part reading
Stated in the same shape as every principle in this module. The counterexample here is the important one, because "a switch statement is a smell" has become common enough that people extract on sight.
The failure table underneath is the specific way this principle goes wrong in practice — and note that the last row is the mitigation failing, which is the most interesting failure of all: having made something closed, teams stop making legitimate changes to it.
- Problem it addresses — adding the fourth variation requires editing logic the first three depend on, so every extension carries the risk of breaking what already works.
- Useful example — payment methods behind an interface, with the reserve-charge-confirm sequence concrete and unchanged, after two implementations made the axis visible and a third was funded.
- Misuse — "never modify existing code", producing plugin systems, extension points and configuration hooks for variation that never arrives (Speculative Generality).
- Trade-off — you lose the single place where variants can be compared, you freeze the interface on two or three data points, and cross-cutting changes become N-file changes forever.
- Counterexample — a
switchon an enum that is genuinely closed: order status, HTTP method, currency code. Making these polymorphic is worse, because the compiler can check a switch on a sum type for exhaustiveness and cannot check that you remembered to add a class (Making Illegal States Unrepresentable).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| One implementation exists and someone extracts an interface "so it is open" | An interface, an implementation and a factory, all with one variant, forever | The axis of variation was assumed rather than observed | Wait for the second case, and treat the third as the point where the shape becomes trustworthy (The Rule of Three). |
| The third variation does not fit the interface | Optional methods, a capabilities flag, or an implementation that throws NotSupported | The abstraction was drawn from two examples and the third varies on a different dimension | Change the interface — that is what the evidence is for — or split it. Do not add a flag; a flag is the abstraction admitting it is wrong (Liskov Substitution, Critically). |
| A genuinely needed change to the core sequence | A per-implementation hack that duplicates part of the sequence, because editing the closed part feels forbidden | Closure was treated as a rule rather than as a description of what is currently stable | Edit the core. It is closed because nothing has needed to change it, not because it is forbidden — and this misunderstanding is the principle's own mitigation failing. |
| Extension points added at several layers "for flexibility" | Hooks, callbacks and strategy parameters nobody has ever passed a non-default value to | Openness pursued without a named axis | Delete them. An unused extension point is not free optionality; it is surface that constrains every future refactor (Speculative Generality). |
| A published library follows OCP strictly and internal code copies the style | A monorepo service with library-grade extensibility and one consumer | Advice written for unknown downstream consumers applied where the consumer is in the same commit | Distinguish the two situations explicitly. For a published API, closure is a compatibility obligation; internally, it is a preference (API Stability, Backward Compatibility as a Constraint). |
How to build it
Most important first.
- Extract the varying part along the axis you have *seen* vary. Here that is "how do we take money", evidenced by two existing implementations and a third specified.
- Keep the invariant sequence in one concrete, non-extensible function. The core should be closed because it is stable, not because a principle says so.
- Prefer the smallest extension mechanism that works, in order: a function parameter, an interface with implementations chosen at wiring time, a registry, a plugin system. Most teams start three steps too far down that list (Extensibility).
- Write down the axis. "Open for new payment methods, closed for changes to the checkout sequence" is a sentence a future engineer can evaluate and, if necessary, overturn (Decision Records).
- Accept that the core will still be modified. A new step in the sequence — fraud screening — is a change to the closed part, and that is normal rather than a design failure.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Fourth payment method under the switch: edit shared code, retest every payment path because they share a function, and carry the risk that broke cards last time. Half a day plus a regression suite plus real deployment risk.
- Fourth payment method after extraction: one new file, one wiring line, one focused test suite. An hour, and the existing methods cannot be affected because their code was not touched.
- A change to the *sequence* — insert fraud screening before charge — costs one edit to the core function under both designs. Extraction bought nothing here, which is the honest limit of the principle.
- A change that cuts across methods — every method must now support partial refunds — is *more* expensive after extraction: four files instead of one switch, plus an interface change that must be made consistently. This is the cost nobody mentions when arguing for OCP (Change Amplification).
- You lose the single place where all variants can be compared. That is a genuine readability loss and teams underestimate it: "how does PayPal differ from card" used to be a one-screen answer.
- The interface freezes a vocabulary at a point when you have seen two or three cases, and cross-cutting changes to it become N-file changes forever.
- Extensibility is only cheap along the predicted axis, and buying it costs indirection along every other axis (The Cost of Change).
What can go wrong
- Extensibility built along the wrong axis. The system is open for new payment methods and the actual requirement is a new *currency*, which cuts across every implementation (Choosing the Model).
- The third variation does not fit the abstraction: bank transfer confirms asynchronously days later, and an interface designed around synchronous charge cannot express it, so a boolean or an optional callback gets bolted on and the abstraction starts lying (Leaky Abstractions).
- A plugin architecture is built for three in-house implementations, adding discovery, lifecycle and versioning problems in exchange for nothing (Plugin Architecture).
- The mitigation fails in reverse: having made the code closed, a genuinely needed change to the core sequence is routed around with a per-method hack, because modifying the closed part now feels like a violation.
- The core flow depends on a
PaymentMethodinterface it declares; the implementations depend on the core's vocabulary. That is dependency inversion in service of extensibility (Dependency Inversion). - The composition root depends on all implementations and picks which are registered, so adding a method touches one implementation file and one wiring line.
- Nothing depends on the number of payment methods, which is the property being bought.
- "Never modify existing code." The most damaging reading, and the one that produces extension points for variation nobody asked for. Modifying code is normal, safe when it is tested, and frequently the correct answer; the principle is about not having to rewrite *stable core logic* to add a *common* variation.
- "Open/closed means plugins." A
switchreplaced by a map of three implementations satisfies everything useful about the principle, with no discovery, no lifecycle and no versioning (Strategy). - "A switch statement is a code smell." It is a smell only when it is duplicated, or when it sits inside logic that must not change per case. A single switch in one place, listing three options a reader wants to compare, is often the best available design (Replace Conditional With Polymorphism is about the duplicated case).
- "We should be open to future requirements." Which ones? Open along a named axis is a design; open in general is not achievable and the attempt produces indirection along axes that never move (Speculative Generality).
- shotgun-surgery
- speculative-generality
Testing it, and how it ages
- The core sequence gets tests with a fake payment method, which is where the invariant "stock released if charge fails" belongs.
- Each implementation gets its own tests against the real provider's sandbox (Where a Test Must Be Real).
- A contract suite that every payment method must pass, so a new implementation cannot silently weaken a guarantee the core relies on (Contract Tests, Liskov Substitution, Critically).
- One end-to-end test per method, no more — the point of the split is that per-method behaviour is verified per method rather than through the whole flow.
- The abstraction gets its real shape at the third implementation, not the second. It is normal for the interface to change when bank transfer lands, and that revision is the design being corrected by evidence.
- When a fifth method arrives with genuinely different semantics — a stored-credit balance that needs no external call at all — the single interface starts straining, and splitting it beats adding optional methods (Interface Segregation, Critically).
- The design ages badly if the core sequence turns out to be the thing that varies. If half the payment methods need a different order of operations, the closed part was chosen wrongly and no amount of extension points will fix it (Revisit Triggers).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- CONTESTEDThe strongest opposing case: extensibility is a bet on a specific axis of variation, and the empirical record of such bets in ordinary business software is poor — most extension points are used once or never, while every reader pays for the indirection on every change. The "just edit the switch" position, argued well by people who maintain long-lived systems, is that a well-tested modification is cheap and safe, that a single switch is more readable than four polymorphic implementations, and that continuous deployment removed the distribution risk the principle was invented to manage. That case is strong and largely correct for the speculative version. It is weakest where a modification touches logic shared by all variants — as here, where editing the switch is what broke card payments — because then the cost is not the edit but the blast radius.
- LIFETIME-SPECIFICMeyer's original OCP addressed shipped, compiled libraries where modification meant your customers' builds broke: closure was a distribution constraint, not a style preference. For a service deployed from a monorepo twenty times a day, modifying a caller and its callee in one commit is trivial and the original justification simply does not apply. For a published library with unknown downstream consumers, it applies as strongly as it ever did — and those two situations get the same advice from most sources, which is why the advice so often lands wrong (API Stability).
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — in a language with sum types and exhaustiveness checking, a closed
matchis safer than an open class hierarchy, because the compiler tells you about the case you forgot; in a language without them the reverse is true. The principle's advice genuinely inverts on that language feature.