Appearance
9.3.6 — Open/Closed
Adding a fourth payment method to the delivery app means editing this function, which currently works and has not caused an incident in eight months:
typescript
function authorize(order: Order, method: MethodKind): Promise<AuthResult> {
switch (method) {
case "card": return cardGateway.auth(order.total, order.cardToken);
case "upi": return upiGateway.collect(order.total, order.upiId);
case "wallet": return wallet.hold(order.customerId, order.total);
default: throw new Error("unknown method"); // (1)
}
}To add cash on delivery you open this file and add a case. That sounds harmless. Here is what it actually costs.
The file must be re-reviewed by someone who understands card payments, because your change sits inside the function every card payment runs through. Every existing test for this function must be re-run and re-read. If you make a mistake in the shared part of the function — a variable renamed, an early return moved — you break card payments, which is the most business-critical path in the system. And line (1) means the mistake you are most likely to make, forgetting one of the other five switch statements elsewhere that also list payment methods, will not be caught by the compiler. It throws at runtime, in front of a customer.
Compare that to a world where adding cash on delivery means creating one new file that nothing else imports. Nothing existing is touched, so nothing existing can break. That is the entire content of the Open/Closed Principle.
1. What it says, and why the name is confusing
Bertrand Meyer's original wording, from 1988:
A module should be open for extension, but closed for modification.
That sounds like a contradiction, which is why the principle is so often misquoted. It means two separate things about two different views of the same code:
Open for extension — you can make the system do new things.
Closed for modification — you do that without editing the code that already works.
The reason both can be true at once is that the new behaviour arrives as new code rather than as edits. A new file, a new class, a new entry in a table. The existing, tested, working code is never opened.
And here is the point that makes it worth caring about, stated plainly: code that is never edited cannot acquire new bugs. That is the whole payoff. Not elegance, not purity — the fact that a file nobody opened is a file that still works.
2. The mechanism: a plug point
Every application of this principle has the same shape. You find the thing that varies, name it as a role, and let new variations arrive as new implementations of that role.
typescript
interface PaymentMethod { // (1)
authorize(order: Order): Promise<AuthResult>;
capture(auth: AuthResult): Promise<Receipt>;
refund(receipt: Receipt, amount: Money): Promise<Refund>;
}
class CardPayment implements PaymentMethod { /* … */ } // (2)
class UpiPayment implements PaymentMethod { /* … */ }
class WalletPayment implements PaymentMethod { /* … */ }
class CodPayment implements PaymentMethod { /* … */ } // (3) the new one
const METHODS: Record<MethodKind, PaymentMethod> = { // (4)
card: new CardPayment(gateway),
upi: new UpiPayment(gateway),
wallet: new WalletPayment(walletService),
cod: new CodPayment(), // (5) one line
};Line (1) is the plug point. It says what any payment method must be able to do, and it is the only thing the rest of the application depends on.
Line (2) and its neighbours are the existing methods, unchanged.
Line (3) is the new one — a new file, written and reviewed on its own, by whoever understands cash on delivery. Nobody who understands card payments needs to look at it.
Line (4) types the table as Record<MethodKind, PaymentMethod>, which means TypeScript requires an entry for every value in MethodKind. Add "cod" to that union and forget line (5), and the build fails immediately. That converts the runtime crash from default: throw into a compile error, which is the difference between a failed deploy and a failed customer.
The caller now reads:
typescript
const method = METHODS[order.methodKind];
const auth = await method.authorize(order); // (1) never changes againLine (1) is closed. It will look exactly like this after the tenth payment method.
3. You cannot be open along every axis
This is the part that separates people who have used the principle from people who have read about it.
A plug point makes one kind of change cheap. It makes every other kind of change no cheaper, and it makes the code slightly harder to read for everyone, forever. So you cannot apply this everywhere. Every plug point is a bet that a particular axis will move.
Look at what the payment interface made cheap and what it did not:
| Change | Cost with this design |
|---|---|
| A new payment method | one new file, one line |
A new operation on all methods, such as void() | edit the interface and every method class |
| A new currency | not addressed at all |
| A different retry policy per method | not addressed at all |
The first row is why the design exists. The second row is the price — this is the same expensive direction described in 9.2.5 section 6, where adding a type is cheap and adding an operation is expensive. The third and fourth rows are the honest reminder that a plug point on one axis does nothing for any other.
So how do you choose the axis? Read what has already changed. Open the git history for the area and count what has actually varied over the last year. In the payments example the business added three methods in two years and never changed currency, so the method axis is proven and the currency axis is not. That is evidence, not prediction, which is exactly how this principle stays compatible with YAGNI (9.3.4).
The rule to carry: open the axis that has already moved, leave every other axis concrete until it moves.
4. Four ways to build a plug point
The interface is the textbook version. There are cheaper ones, and reaching for the cheapest that works is usually right.
A function parameter. The smallest possible plug point, and often enough:
typescript
items.sort(byPrice); // the comparison is the plug pointA lookup table. New behaviour is a new entry, and no new type is needed:
typescript
const FEES: Record<MethodKind, Money> = { card: Money.of(200), upi: Money.zero(), /* … */ };An interface with several implementations. The payments example. Right when the varying thing has real logic and dependencies of its own.
A registry that things add themselves to at startup. Right when the set is genuinely open — when a plugin, another team, or a configuration file supplies new members that the core does not know about.
Choose by weight. If the varying thing is a value, use a table. If it is a one-line rule, use a function parameter. If it is a class with dependencies, use an interface. Building an interface for something that fits in a table is the over-application described in 9.3.1 section 6.
5. When editing the code is the right answer
Three cases where you should open the working file, so the principle does not become an excuse for contortions.
When the change is a genuine correction. A tax rule was wrong. Fix it in place. Open/Closed is about extension, not about never touching anything. Building a plug point so the wrong rule can coexist with the right one leaves the wrong rule in the codebase forever.
When there is one case and no evidence of a second. Editing an if is cheap. Building an abstraction for one variation costs more than the edit and buys nothing until the second case shows up.
When the plug point would be in the wrong place. A plug point built from one example is shaped like that example. Adding a case to a switch twice and then extracting from two real cases produces an interface that fits both. Guessing early produces one that fits neither (9.3.4 section 2).
The honest framing: Open/Closed is a target for the axes you have evidence about, not a prohibition on editing files.
6. Interview calibration
Almost every interviewer asks for the definition; the good ones ask what it costs.
The forty-second answer: "Open for extension, closed for modification — new behaviour should arrive as new code rather than as edits to code that already works, because a file nobody opened is a file that cannot acquire new bugs. In practice it means finding the thing that varies and putting it behind a plug point, which might be a function parameter, a lookup table, or an interface with one implementation per case. The catch is that you cannot be open along every axis: each plug point makes one kind of change cheap and everything else slightly harder to read. So I pick the axis from the git history — whatever has actually changed repeatedly gets a plug point, and everything else stays concrete until it moves."
The follow-up to be ready for: "does that mean you never edit existing code?" Answer no, and give the correction case — a wrong rule gets fixed in place, and building a plug point so a wrong rule can survive alongside a right one is worse than the edit.
Recall
- Open for extension, closed for modification: new behaviour arrives as new code, not as edits. The payoff is blunt — a file nobody opened cannot acquire new bugs.
- The mechanism is a plug point: name the thing that varies as a role, and let new variations be new implementations. Typing the registry as
Record<Kind, Role>turns "forgot to register the new one" from a runtime crash into a compile error. - You cannot be open along every axis. A plug point makes one kind of change cheap, does nothing for the others, and costs every reader a little. Adding a type becomes cheap; adding an operation to the interface stays expensive.
- Pick the axis from evidence, not prediction — read the git history for what has actually varied. That is what keeps this compatible with YAGNI.
- Four plug points, cheapest first: a function parameter, a lookup table, an interface with several implementations, a registry things add themselves to. Match the weight to the thing that varies.
- Editing is right when the change is a correction, when there is one case and no evidence of a second, and when the plug point would be shaped by a single example.
Self-test: Say why "open" and "closed" are not a contradiction. What is the blunt reason unedited code is valuable? Give the compile-time trick that catches an unregistered new case. Which kind of change does a payment-method interface make expensive? How do you choose which axis to open?
Quiz Bank
InterviewExplain Open/Closed, and then explain why you cannot apply it everywhere.
The statement: open for extension, closed for modification. New behaviour should arrive as new code — a new file, a new class, a new row in a table — rather than as edits to code that already works.
Why it matters, stated without abstraction: a file nobody opened cannot acquire new bugs. When adding a payment method means editing the function every card payment runs through, your change needs a reviewer who understands card payments, it ships in the same release as the most business-critical path, and a mistake in the shared part of the function breaks something you never intended to touch.
The mechanism is a plug point: find the thing that varies, name it as a role, and let each variation be its own implementation. In TypeScript there is a bonus worth mentioning, because it converts a whole class of production bug into a build failure: type the registry as Record<MethodKind, PaymentMethod> and the compiler refuses to build if a new method kind has no entry.
Why you cannot apply it everywhere, which is the real question. Every plug point makes exactly one kind of change cheap and does nothing for the rest, while costing every future reader one more hop of indirection. A payment-method interface makes new methods cheap and leaves new operations on the interface expensive, because adding one means editing every implementation. It does nothing at all for a new currency or a per-method retry policy. So plug points cannot be free, and a codebase that is "open" on every imaginable axis is one where nothing can be read straight through.
How to choose the axis, without guessing: read the git history. Whatever has actually changed repeatedly for the same reason is a proven axis and earns a plug point. Everything else stays concrete until it moves. That is evidence rather than prediction, which is exactly how the principle stays compatible with YAGNI instead of contradicting it.