You own a widely-used library. What is your policy on letting consumers subclass your classes, and how does that policy affect versioning and long-term evolution?
answer
- final/sealed by default; open deliberately
- subclassing publishes self-calls + protected state
- opening later is non-breaking; closing is breaking
- seams: interfaces, strategies, hooks, builders
- adding an interface method breaks implementers → defaults
basics
~10 sDefault to closed classes (final/sealed) and offer extension through documented interfaces, callbacks, or injected strategies. Subclassing exposes your internal call structure as a contract you can never change without breaking consumers.
solid answer
~50 sPolicy: **classes are final/sealed unless deliberately designed for inheritance**, and extension is offered through published seams — interfaces to implement, strategies to inject, listeners/hooks to register, configuration objects — not by extending concrete classes. Reason: when consumers subclass, your **internal self-call order, protected members, constructor sequencing and invariants become de-facto public API**. Under semantic versioning any change to them is breaking, so ordinary refactors force major versions. Closed classes let you rewrite internals in a patch release. Where inheritance is genuinely the right seam (framework lifecycle base classes, skeletal implementations alongside an interface), you must pay the design tax: document the self-use pattern, expose a minimal set of protected hooks, forbid overridable calls from constructors, ship subclasses of your own in CI, and version the hook contract explicitly. Opening a class later is easy; closing one is a breaking change — so start closed. Reciprocally, adding methods to a published *interface* breaks implementers, which is why default/defender methods and "implement this abstract base" escape hatches exist.
go deeper
Say classes should be final by default and extension offered via interfaces/callbacks, because subclassing locks in internal details.
Explain what subclassing publishes — self-calls, protected members, constructor ordering — and that this turns refactors into breaking changes.
Add the design tax for intentionally-open bases (documented self-use, narrow protected hooks, no overridables in constructors, own subclass in CI) and the reciprocal interface-evolution problem with default methods.
Give it as a governed policy: source vs binary compatibility, automated API-diff enforcement in CI, an explicit consumer-implemented vs library-implemented interface classification, sealed-hierarchy version implications, and a staged migration plan for an already-open library.
## Why the question matters at library scale Inside one repo, a bad inheritance decision costs a refactor. In a published library it costs a **major version**, because the set of subclasses is unknown, unbounded, and outside your build. The API surface you actually committed to is much larger than the one you documented. ## What subclassing silently publishes Once a concrete class is open: 1. **Self-call structure.** "`process()` calls `validate()` then `persist()`" is now observable behavior someone overrides against. Reordering or inlining those calls breaks them, invisibly and at runtime. 2. **Protected members.** Fields, methods, their types, nullability, and lifecycle are API — with implementation detail baked in. Renaming a protected field is breaking. 3. **Constructor sequencing.** If any constructor calls an overridable method, subclasses observe partially-initialized state; that ordering is now frozen. 4. **Invariants and thread-safety assumptions** subclasses rely on. 5. **Method presence itself.** Adding a method to an open class can accidentally collide with a subclass's existing method (accidental override), silently repurposing it. Compilers requiring an explicit override keyword turn this into a compile error — better, but still a source-breaking change for consumers. None of this appears in your API docs, yet all of it constrains you forever. ## The policy **Default: closed.** - Mark classes `final` / `sealed` / non-`open` by default (some languages are closed by default — lean into it). - Where a closed set of variants is meaningful, use a **sealed hierarchy**: consumers can match exhaustively, you keep the right to know all cases. Note the reverse obligation — adding a case to a sealed type breaks consumers' exhaustive matches, so treat that as a minor/major decision consciously. **Extension seams you publish instead:** - **Interfaces to implement** for pluggable behavior (`interface RetryPolicy`, `interface Serializer`) — you depend only on the contract you wrote. - **Strategies/functions injected** at construction or per call — smallest possible contract, trivially testable. - **Listeners / hooks / middleware chains** for cross-cutting concerns; you control invocation points and can add new ones without breaking anyone. - **Builders and configuration objects** for variation that is really data, not behavior. - **Decorator-friendly design**: publish an interface for everything a consumer might want to wrap, so wrapping works without subclassing. **When inheritance is the intended seam** (framework lifecycle bases, skeletal implementations like an abstract adapter that turns a 12-method interface into 3), pay the tax explicitly: - Document the **self-use pattern** precisely, and treat it as frozen contract. - Expose a **minimal set of protected hooks**; make everything else final. Template Method: you own the skeleton, they own named steps. - **Never invoke overridable methods from constructors/initializers**; use a post-construction `init()` the framework calls. - Prefer **abstract methods over optional overrides** where a step is mandatory — it makes the contract explicit at compile time. - **Write subclasses yourself in your own test suite** so internal refactors fail your CI, not your users' production. ## Interface evolution — the reciprocal trap Closing classes pushes contracts into interfaces, which have their own compatibility rule: **adding a method to a published interface breaks every implementer**. Mitigations: default/defender methods with a sane implementation; a companion abstract skeletal class consumers may extend (documented for inheritance, so the tax is paid deliberately); or a `sealed` interface when you don't want external implementations at all. Decide up front whether an interface is **consumer-implemented** (evolve conservatively, use defaults) or **library-implemented** (you can add freely, consumers only call it) — and say which in the docs. ## Binary vs source compatibility For compiled/distributed artifacts, distinguish: - **Source compatibility** — consumer code still compiles. - **Binary compatibility** — pre-compiled consumer artifacts still link/run without recompilation. Inheritance-related changes frequently break binary compatibility even when source looks fine (changing a protected field's type, changing a method's declared throws/return in a way linkers care about, adding an abstract method). Automated checks (binary-compatibility validators, API-diff tools in CI) are the practical control; the policy without enforcement erodes. ## Migration for an already-open library You usually inherit an open codebase. A workable sequence: 1. **Inventory** which classes are actually subclassed by consumers (telemetry, code search across the ecosystem, issue history). 2. **Freeze** the rest at the next major: mark final, announce with a deprecation window and a replacement seam. 3. **Introduce the replacement seam first** (interface + strategy injection), migrate your own internals to it, then deprecate the inheritance path. 4. **Keep a thin open shim** for the highest-value cases with an explicit, documented hook contract. 5. **Add API-compatibility checks to CI** so the policy is enforced mechanically, not by reviewer memory. ## The one-liner Opening a class later is a **non-breaking** change; closing one is **breaking**. Therefore: start closed, open deliberately, and only where you're willing to freeze your internal call structure for the life of the major version.
- If closing classes pushes contracts into interfaces, what compatibility problem does that create instead?Adding a method to a consumer-implemented interface breaks every implementer. You mitigate with default/defender methods, a documented abstract skeletal class to extend, or a sealed interface if external implementations aren't wanted — and you declare up front which interfaces are consumer-implemented versus library-implemented.
- Why is adding a variant to a sealed hierarchy a breaking change for consumers?Consumers rely on exhaustive matching over the closed set. A new case makes their previously-exhaustive match non-exhaustive — a compile error at best, an unhandled runtime branch at worst — so it must be scheduled as a deliberate version bump.
- How do you enforce this policy in practice rather than by review culture?Automated API surface and binary-compatibility checks in CI (API-diff / binary-compatibility validators), plus a lint rule that classes are final unless annotated as designed-for-inheritance, and at least one in-repo subclass test for every intentionally open base.
Letting consumers subclass is handing out keys to the boiler room, not just the front door. Every pipe you rerouted is now someone's load-bearing assumption.