How do you decide when applying the Open/Closed Principle is worth it, versus when adding the abstraction is speculative over-engineering? What signals do you use?
answer
- Closed only against the anticipated axis — you must pick one
- Rule of three: refactor at the second/third real variant
- Interface with one impl + a mock = speculative generality
- Externally-driven variation & un-editable core → abstract now
- Retrofit is cheap for internals, expensive across published contracts
basics
~20 sYou can't be closed against every change, only the ones you predict. Write simple direct code first; add the abstraction when a second real variant shows up or history says that axis changes. One-implementation interfaces built "just in case" are cost with no payoff.
solid answer
~60 sOCP protects only the **axis of change you anticipate**, and every anticipated axis costs an abstraction: indirection, a contract to maintain, harder navigation, more test doubles. So the decision is a bet on where change will actually occur — and you should demand evidence before betting. Practical signals **for** abstracting: the same conditional on the same type code appears in 2–3 places; a variant has already been added once and hurt; the variation is externally driven (payment providers, tax jurisdictions, tenants, file formats); the extending party cannot edit your code (published library, other team, third party); regulatory or A/B needs demand swapping behavior at runtime. Signals **against**: exactly one implementation and no concrete second on the roadmap; the interface exists only to enable mocking; "we might need it" with no named requester; the variants differ only by data values (use config); the variant set is genuinely closed and exhaustiveness checking is more valuable. Default: write it directly, and refactor to the abstraction at the second occurrence — refactoring is cheap when the code is simple, and expensive after you've built the wrong framework.
go deeper
Say that abstraction has a cost, and that you add it when a second real case appears rather than guessing up front.
Name YAGNI and speculative generality, give the rule of three, and cite concrete signals like a repeated switch on a type code or an externally supplied set of variants.
Present it as a bet on an axis of change with named evidence (version-control history, roadmap items, external drivers), enumerate the costs of the abstraction, and give the default of refactoring at the second variant with explicit exceptions.
Add the asymmetry argument — retrofit is cheap inside a module, expensive across published APIs, wire formats, and team boundaries — and connect it to organizational design: extension points exist so other teams and third parties can move without coordinating with you.
## The core tension OCP says: design so new behavior is additive. **YAGNI** ("You Aren't Gonna Need It") says: don't build what no requirement demands. **Speculative generality** is the code smell Fowler names for abstraction created for imagined future needs — abstract classes with one subclass, interfaces with one implementation, unused hook parameters, config knobs nobody sets, a plug-in framework with zero third-party plug-ins. These are not actually in conflict once you see the key fact: **no design is closed against all change**. A renderer registry is closed against "new output format" and wide open if the requirement becomes "rendering must stream asynchronously" — that changes the interface, and every implementation and caller changes with it. You always choose *which* axis to protect. Choosing costs something. Choosing wrong costs more than not choosing. ## The cost side, spelled out An abstraction introduced for OCP is not free: - **Indirection**: reading the code no longer tells you what runs; you must find the wiring. "Go to definition" lands on an interface. - **A contract to maintain**: once published, the interface resists change; adding an operation forces edits to every implementation (the expression problem). - **More moving parts**: a factory/registry/DI wiring, more files, more names to invent. - **Testing distortion**: teams start mocking the interface, and tests verify interactions with a fiction instead of behavior. - **Premature shape lock-in**: an abstraction designed from one example almost always has the wrong seams. The second real variant reveals them; the third confirms them. Building at n=1 means you encode accidents of the first case as if they were the general rule. ## Signals that abstracting now is right 1. **Rule of three / repeated conditional.** The same `switch` on the same type code in two or three places is empirical evidence, not a guess. 2. **History of change.** Look at version control: has this area accepted a new variant before? Frequently-changed files are where OCP pays. 3. **The variation is externally driven and unbounded.** Payment providers, shipping carriers, tax jurisdictions, tenants, locales, file formats, auth providers — the world supplies new cases whether you like it or not. 4. **The extender cannot edit the code.** Publishing a library, an SDK, a platform, or crossing a team/service boundary makes "just edit it" unavailable, so the extension point is the only mechanism. 5. **Runtime swapping is a requirement**: feature flags, A/B tests, per-tenant behavior, kill switches. 6. **The cost of getting it wrong is high**: a missed branch causes a financial or compliance error, so structural impossibility beats vigilance. 7. **A concrete, named second variant exists** — on a roadmap, in a contract, with a date. "Sales promised carrier X by Q3" is evidence; "someday we may support other carriers" is not. ## Signals that you are over-engineering - One implementation, and the only other "implementation" is a test mock. - The interface is a mirror of a single class's methods (`FooService` / `FooServiceImpl` naming is the classic tell). - Config options with exactly one legal value. - A plug-in system with no external plug-ins after a year. - Extension points designed before the first customer conversation about what varies. - The differences between "variants" are only values → a table beats a class hierarchy. - The variant set is genuinely finite and closed (`Success | Failure`, days of week). Here an exhaustive `switch` with compiler-checked completeness is *safer* than an open interface, because adding a case produces compile errors at every place that must adapt instead of silent fall-through. ## The practical default: refactor to it, don't design for it Write the direct code. When the second real variant arrives, apply *Replace Conditional with Polymorphism* then — you now have two examples, so the seam you cut is informed by reality. This works because clean, simple code is cheap to refactor; the thing that is expensive to undo is a *wrong* framework that other code has already been shaped around. Two caveats to that default: - **Some seams are expensive to add later.** Anything crossing a persistence format, a public API, a wire protocol, or an organizational boundary is hard to retrofit because other parties depend on the current shape. Deliberate up-front design is justified where the cost of change is asymmetric. - **A cheap seam now beats an expensive one later.** Passing behavior as a function parameter, or keeping a conditional isolated in one place, costs almost nothing and preserves optionality. Prefer the cheapest mechanism that keeps the door open, rather than the most powerful one. ## How to argue it in an interview or a design review Don't answer "always apply OCP" or "YAGNI, never abstract." Answer with the decision procedure: *what varies, how do we know, who extends it, can they edit our code, and what does the wrong guess cost?* Then state your default (simple first, refactor at the second variant) and your exceptions (published contracts, external variation, asymmetric change cost).
- Someone says "we should extract an interface so we can mock it in tests." Is that a valid reason for the abstraction?Weakly, and often it is a smell. If the collaborator is a genuine external dependency (network, clock, filesystem, payment gateway), the seam is legitimate and doubles as an OCP extension point. If it is a pure in-process domain object, mocking it usually means testing interactions rather than behavior — prefer calling the real thing and keeping the code concrete.
- What is the cost of guessing the wrong axis of change?You pay the abstraction's ongoing cost (indirection, contract maintenance, extra wiring) while getting none of the benefit, and when the real change arrives on a different axis you must modify the interface anyway — which is now worse, because every implementation and caller has to change together.
- Are there cases where a plain exhaustive `switch` is the better OCP answer?Yes. When the variant set is genuinely closed and the language can check exhaustiveness, a single switch makes adding a case a compile error everywhere it matters. That is *safer* than an open interface, where a missing implementation surfaces as a runtime gap. OCP is about controlling change cost, not about banning conditionals.
Building a house with conduit in the walls. Running conduit where wiring will plausibly change is cheap foresight; pre-installing every possible outlet, in every wall, for appliances nobody owns is money spent on rooms you'll never use — and the clutter makes the real wiring harder to find.