skip to content

An architect claims a design is "fully closed for modification." Why is that claim impossible in general, and what trade-off does interface-based Open/Closed force you to accept about adding new types versus adding new operations?

level: seniorimportance: should knowfreq 38%

answer

  1. Closure is strategic and directional, never total
  2. Name the axis: closed against X, exposed to Y
  3. Expression problem: rows = types, columns = operations
  4. Interfaces: cheap new type, costly new operation; switch: the reverse
  5. Sealed type + exhaustive match = invasive but loud

basics

~20 s

You can only be closed against changes you predicted. Using interfaces makes adding a new implementation cheap but adding a new method to the interface expensive — every implementation must change. A big switch statement has the opposite trade-off.

solid answer

~50 s

Closure is always *relative to an axis of change*. A design closed against "new payment provider" is wide open to modification if the requirement becomes "every payment must be two-phase and idempotent" — that alters the contract, so the interface, every implementation, and every caller change together. Robert Martin's own framing calls this **strategic closure**: you pick the likely axis and accept exposure on the others. The formal statement of the underlying trade-off is the **expression problem**: given a set of types and a set of operations over them, interface/polymorphic designs make adding a *type* additive and adding an *operation* invasive; conditional/`switch`-and-visitor-style designs make adding an *operation* additive and adding a *type* invasive. No mainstream design gives you both without extra machinery (default methods, extension methods/typeclasses, multiple dispatch, pattern matching with exhaustiveness checks). The engineering move is to identify which dimension actually grows in your domain, optimize for it, and use compiler-enforced exhaustiveness on the other dimension so the invasive change is at least loud rather than silent.

code

typescript · 14 lines
typescript
// Closed against NEW TYPES (add a class), open to churn on NEW OPERATIONS.
interface Shape { area(): number }
class Circle implements Shape { area() { /* ... */ return 0 } }
// Adding perimeter() to Shape => every implementation must change.

// Closed against NEW OPERATIONS (add a function), open to churn on NEW TYPES.
type Shape2 = { kind: 'circle'; r: number } | { kind: 'square'; s: number }
function area(s: Shape2): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.r ** 2
    case 'square': return s.s ** 2
    // adding a new kind => compiler flags every exhaustive switch. Invasive, but loud.
  }
}

go deeper

for a junior

Say that you can only protect against changes you expected, and that adding a method to an interface forces every implementation to change.

for a middle

Name the axis explicitly with an example (closed against new providers, exposed to a signature change), and state the interface-vs-switch trade-off in both directions.

for a senior

Name the expression problem and strategic closure, list mitigations (default methods, visitor, extension methods/typeclasses, exhaustive matching) with their costs, and tie interface width to ISP.

for a principal

Discuss it as contract governance: published interfaces cannot take breaking additions, so versioning, capability negotiation, and deprecation policy are the real mechanisms; and note that behavioral contract changes break closure silently even when signatures don't move.

## Why "fully closed" is a category error OCP's phrasing — closed for modification — invites the misreading that a sufficiently good design never needs editing. Bertrand Meyer and Robert Martin both explicitly reject that. Martin's wording is that closure is **strategic**: you cannot close a module against *every* kind of change, so you choose the changes you consider most likely and close against those, deliberately leaving other axes exposed. A concrete demonstration: ``` interface PaymentProvider { charge(amount, card): Receipt } ``` - Add Stripe, Adyen, PayPal → additive. **Closed on the provider axis.** - Now the requirement becomes: charges must be **idempotent** (carry a caller-supplied key so retries don't double-charge) and **asynchronous** (return a pending handle plus a webhook). The signature must change to `charge(amount, card, idempotencyKey): PendingCharge`. The interface changes, all implementations change, all callers change, tests change. **Wide open on the contract axis.** So closure is a directional property, not an absolute one. The right interview answer is to name the axis explicitly: "closed against new providers, exposed to changes in the charge contract." ## The expression problem, defined Imagine a grid: rows are **types/cases** (Circle, Square, Triangle), columns are **operations** (area, draw, serialize). Every cell is a piece of behavior. Any implementation strategy decides how the grid is sliced into modules: - **Object-oriented / interface slicing (by row).** Each class owns all operations for one type. Adding a *row* (a new shape) = one new file, nothing else touched — clean OCP. Adding a *column* (a new operation, `perimeter()`) = edit the interface plus every class. - **Functional / `switch` slicing (by column).** Each function owns all types for one operation. Adding a *column* = one new function, nothing else touched. Adding a *row* = edit every function's switch. Both are OCP-compliant *along their own axis* and neither is along the other. That is the trade-off you accept the moment you pick a style; it is not a defect of your design. ### Mitigations, and what each costs - **Default/interface methods**: add an operation with a default body so existing implementations still compile. Cheap, but a default that is wrong for some implementations produces silent misbehavior instead of a compile error. - **Visitor pattern**: makes adding operations over a fixed type set additive. Cost: adding a *type* now breaks every visitor, and the double-dispatch machinery is heavy and hard to read. - **Extension methods / typeclasses / protocol conformances**: attach operations to types from outside; genuinely solves the problem in some languages, at the cost of dispatch that isn't visible at the definition site. - **Multiple dispatch**: dispatch on several arguments; powerful, rare in mainstream languages. - **Exhaustive pattern matching over a sealed/closed type set**: the compiler enumerates all cases, so adding a type produces a compile error in every function that must adapt. You accept the invasive change but make it *loud and complete* — often the best engineering answer for genuinely finite variant sets. ## Practical guidance 1. **Ask which dimension grows.** Payment providers, tenants, file formats, locales, carriers → the type axis grows; use interfaces. A fixed domain algebra (`Success | Retryable | Fatal`, order states, AST node kinds) with an ever-growing list of analyses/reports over it → the operation axis grows; use sealed types plus exhaustive matching or visitors. 2. **Make the invasive change safe, not impossible.** Where you know you'll have to change the other axis eventually, prefer mechanisms with compile-time completeness checks so nothing is silently missed. 3. **Keep interfaces narrow (ISP).** The wider the interface, the more expensive every future operation change is, and the more painful it is to write decorators and test doubles. 4. **Version published contracts.** For interfaces others implement, an added method is a breaking change you cannot fix by editing their code. Use defaults, a `V2` interface, or capability negotiation. 5. **Watch for the false comfort of "we abstracted it."** Teams often believe an abstraction protects them when the changes actually arriving are contract-shaped (timeouts, async, batching, observability, multi-tenancy). Those cut across the interface, not along it. ## Edge cases worth mentioning - **Adding an operation isn't always invasive** if only some implementations must support it — then a second, smaller interface plus a capability check may be better than widening the main one. - **Behavioral (non-signature) changes still break closure.** Tightening a precondition or changing an error contract forces implementations to change even though nothing recompiles — a silent version of the same problem, and a Liskov-substitution hazard. - **Data-driven designs sidestep both axes.** If both types and operations live in a rules table or DSL interpreted by one engine, adding either is a data change — at the price of losing type checking and reviewability.

  • Your team keeps having to change an interface that was supposed to make you 'closed'. What does that tell you?
    That you closed against the wrong axis. The changes arriving are contract-shaped — async, idempotency, timeouts, batching, tenancy — rather than new-implementation-shaped. The response is to re-examine which dimension actually grows and possibly to narrow or split the interface (ISP) so contract changes touch less.
  • How can you make adding a new type to a sealed set safe even though it forces edits everywhere?
    Use a language feature with exhaustiveness checking — sealed/closed hierarchies with pattern matching — so the compiler produces an error at every site that must adapt. You accept the invasive change but eliminate the risk of a silently missed branch, which is usually the real danger.
  • When is adding a method to a published interface *not* acceptable at all?
    When third parties implement it and you cannot recompile their code. Then you must add it with a default implementation, publish a second interface and detect capability at runtime, or version the contract — otherwise every existing implementation breaks on upgrade.

A spreadsheet where rows are animal species and columns are measurements. Store it as one card per species and adding a species is one card, but adding a measurement means editing every card. Store it as one sheet per measurement and the reverse holds. Nothing about being organized removes the choice — you can only decide which direction is cheap to grow.

context