skip to content

What does the Open/Closed Principle (the "O" in SOLID) mean by "open for extension, closed for modification"?

level: juniorimportance: must knowfreq 82%

answer

  1. Add new code, don't edit old code
  2. Abstraction = the hinge: closed core, open implementations
  3. Kill the switch-on-type; each branch becomes a class
  4. Closed only against the anticipated axis of change
  5. Meyer = subclass; Martin = interface + polymorphism

basics

~10 s

You should be able to add new behavior to a module by adding new code (a new class or plug-in), without editing and re-testing the existing, already-working code.

solid answer

~50 s

The Open/Closed Principle (OCP), the "O" in SOLID, says a software entity — class, module, function — should be **open for extension** (its behavior can be varied for new requirements) but **closed for modification** (its existing source, which is already written, reviewed, tested and shipped, does not have to change to do so). The practical mechanism is an abstraction: the stable code depends on an interface/abstract contract, and new behavior arrives as a new implementation of that contract that gets plugged in. The classic smell that OCP addresses is a growing `if type == A … else if type == B …` or `switch` on a type code, repeated in several places, where every new case forces edits in existing files. The payoff is smaller blast radius: fewer regressions, less re-testing, less merge conflict, independent deployability. The cost is an extra indirection, so it is applied where change is actually expected, not everywhere.

code

typescript · 14 lines
typescript
// Closed for modification: this never changes when a format is added.
interface Renderer { readonly name: string; render(r: Report): Bytes }

class ReportService {
  constructor(private readonly renderers: Map<string, Renderer>) {}
  render(r: Report, format: string): Bytes {
    const renderer = this.renderers.get(format)
    if (!renderer) throw new Error(`no renderer for ${format}`)
    return renderer.render(r)   // polymorphic call = the extension point
  }
}

// Open for extension: a new file, nothing existing edited.
class XlsxRenderer implements Renderer { name = 'xlsx'; render(r: Report) { /* ... */ } }

go deeper

for a junior

State the definition and give the switch-statement-to-interface example. Naming polymorphism as the mechanism is enough.

for a middle

Show the refactor concretely (conditional → strategy/registry), and name decorator and plug-in registration as other routes. Mention that only anticipated axes of change are protected.

for a senior

Contrast Meyer's inheritance formulation with Martin's polymorphic one, tie OCP to DIP, and be explicit about the cost: indirection, speculative generality, YAGNI. Say when you'd deliberately keep an exhaustive switch.

for a principal

Frame OCP as a decision about where variation is expected, applied at module/service/API scale: stable published contracts, plug-in boundaries, backward-compatible schema evolution, and the organizational payoff of teams extending without coordinating edits.

## The words, defined - **Software entity**: any unit of code you can name and reuse — a function, class, module, package, service. - **Open for extension**: the entity's behavior can be made to do something new — handle a new payment type, a new export format, a new discount rule. - **Closed for modification**: to get that new behavior, you do **not** have to open and edit the entity's existing source code. Its file stays byte-identical; you add new files instead. - **Abstraction**: a contract (interface, abstract class, function type, protocol) that names *what* is done without saying *how*. Concrete implementations supply the *how*. - **Polymorphism**: the ability of a single call site (`shape.area()`) to run different code depending on which concrete implementation is behind the reference. This is the engine that makes OCP possible in object-oriented code. At first hearing this sounds contradictory — how can something change without changing? The resolution is that *behavior of the system* changes while *the source of the existing modules* does not. The variation point was designed in ahead of time as an abstraction, and new code slots into it. ## The canonical shape of the problem Imagine a reporting module: ``` function render(report, format): if format == "pdf": ...pdf code... else if format == "csv": ...csv code... else if format == "html": ...html code... ``` Add XLSX and you must edit `render`. Worse, in real systems the same `switch` on `format` appears in three other places (file extension, MIME type, size estimate), so one new format means four coordinated edits — and if you miss one, you get a runtime bug rather than a compile error. This is called **shotgun surgery** (one conceptual change scattered across many files). The OCP-conforming version introduces the abstraction: ``` interface Renderer { name(); mimeType(); render(report) } ``` `render` now looks up a `Renderer` in a registry/map and calls it. Adding XLSX = adding one new file implementing `Renderer` and registering it. Nothing existing is edited, so nothing existing can regress. ## Why "closed" is valuable, concretely 1. **Regression risk**: code you did not touch cannot break (modulo shared state). Tests you already have stay valid. 2. **Review and blast radius**: a reviewer reads one new file, not a diff smeared over ten. 3. **Merge conflicts**: two teams adding two formats touch disjoint files instead of both editing the same `switch`. 4. **Distribution**: if the closed module ships as a compiled library, binary, or separately deployed service, a consumer *cannot* edit it — extension via plug-in is the only option available at all. ## Two historical formulations (both are "OCP") - **Bertrand Meyer (1988)** stated it in terms of **inheritance**: once a class is published and used, you do not modify it; you create a *subclass* that adds or overrides behavior. The original class stays frozen for existing clients. - **Robert C. Martin (1990s, "polymorphic OCP")** restated it in terms of **abstract interfaces**: clients depend on a fixed abstract interface; multiple implementations vary behind it, and the implementation can be swapped without recompiling clients. This is the version practiced today, and it composes with the Dependency Inversion Principle (high-level policy depends on abstractions, not on details). ## How you actually achieve it - **Polymorphism / strategy**: replace conditional-on-type with a call through an interface; each branch becomes an implementation. - **Decorator / wrapper**: wrap an existing implementation to add cross-cutting behavior (caching, retry, logging, metrics) without editing the wrapped class. - **Plug-in / registry hooks**: the core declares an extension point and discovers implementations at startup (service loader, dependency-injection container collecting all beans of a type, a config-driven map). - **Callbacks / higher-order functions**: pass in the varying behavior as a function parameter — the lightweight, language-neutral form of Strategy. - **Data-driven configuration**: move the variable part into data (rule tables, templates) so "new behavior" is a new row, not new code. ## The essential caveat: you cannot be closed against everything No design is closed against *all* possible changes — only against the **axis of change you anticipated**. A renderer registry is closed against "new format" but wide open to modification if the requirement becomes "every render must be async and streamed": that changes the interface itself, and every implementation must change. Choosing which axis to protect is a judgment call informed by what has actually changed before. Guessing wrong produces **speculative generality**: layers of abstraction that serve exactly one implementation forever, costing readability and indirection while buying nothing. The mature reading of OCP is therefore: write the simple direct code first; when the *second* variant appears, refactor to the abstraction; do not build the plug-in framework for a variant nobody has asked for. ## Edge cases and honest limits - **Bug fixes are modifications, and that's fine.** OCP is about extending behavior for new requirements, not a ban on ever editing code. - **Adding a case to a closed set can be worse.** If a type genuinely has a fixed set of variants (e.g. `Success | Failure`), an exhaustive `switch` that the compiler checks may be *safer* than an open interface, because adding a variant then produces compile errors at every place that must be updated rather than silent wrong behavior. - **The expression problem**: interface-based OCP makes it cheap to add new *types* (implementations) and expensive to add new *operations* (a new method forces edits to every implementation). The `switch`-based style is the mirror image. You choose which dimension you expect to grow. - **Runtime openness costs something**: dynamic dispatch defeats some inlining, plug-in discovery complicates startup and static analysis, and "anyone can register anything" widens the security/trust surface.

  • If OCP forbids modifying existing code, how do you ever fix a bug?
    You modify it. OCP targets *new behavior driven by new requirements*, not corrections. A bug means the existing code does not meet its already-agreed contract, so editing it is the right fix — and it does not add a new variation point.
  • Does adding a new method to an existing interface violate OCP?
    Effectively yes, for that interface's implementers: every implementation must now change. That is the expression-problem trade-off — interface-based OCP is closed against new implementations but open to churn when new operations are added. Mitigations are default methods, a second smaller interface (ISP), or an abstract base class.
  • How is OCP related to the Dependency Inversion Principle?
    DIP is the structural rule that makes OCP achievable: high-level policy and low-level details both depend on an abstraction owned by the policy side. Once the dependency points at an abstraction, a new detail can be plugged in without touching the policy — which is exactly OCP.

A wall socket. The house wiring is closed — you never re-wire the wall to use a new lamp. The socket is the fixed contract; any appliance that fits the plug extends what the house can do. But the socket is only open along the axis it anticipated: switch the country's voltage and every appliance breaks, because that change crosses the contract itself.

context