Designing for Extension & Polymorphism — Professional¶
What? The team lens: the vocabulary to name extensibility problems in review, the smells that signal a missing or wrong seam, the tooling that enforces extension boundaries (ArchUnit, Error Prone, SpotBugs, Modulith, japicmp/revapi), a refactoring playbook for introducing and evolving seams safely, and — crucially — when not to add one. How? Make "where can this grow, and is that the axis it will grow on?" a standing review question, back it with automated checks on the boundaries that matter, and treat published seams as API governed by binary-compatibility tooling.
1. Review vocabulary — name the problem precisely¶
Vague review comments ("this isn't extensible") get ignored. Precise ones get fixed:
| Say this | Instead of | Means |
|---|---|---|
"This switch on type repeats in 3 places — replace conditional with polymorphism." | "messy switch" | scattered type-branching → one polymorphic dispatch |
| "What's the axis of change here — new types or new operations?" | "make it flexible" | forces the Expression-Problem decision |
"This abstraction leaks PreparedStatement — depend on a domain type." | "bad interface" | implementation type in a published contract |
"This base class isn't designed for inheritance — final it or document self-use." | "don't subclass" | Bloch Item 19 |
| "Speculative seam — no second implementer. YAGNI; inline it." | "over-engineered" | a variation point with no predicted variation |
| "Sealing this plugin point forbids the extension we built it for." | "wrong" | sealed where open was needed |
"Adding this method breaks every implementer — make it a default." | "breaking change" | implementer-side binary break |
These phrases tie the comment to a principle the author can act on and look up.
2. Smells that signal a seam problem¶
In review or static scan, these patterns flag extensibility issues:
- The repeated type-switch. The same
switch (x.type())/if (x instanceof …)ladder in multiple methods. One is fine; the third copy is a polymorphism candidate. Grep for the enum/getType()in switches. - The shotgun edit. A pull request that adds one feature but touches eight unrelated files is missing a seam — the variation point isn't isolated. (High change coupling in git history; see §5.)
instanceofladders with a finalelse throw. Often a sealed-type-plus-exhaustive-switch begging to exist (noelse, compiler-checked).UnsupportedOperationExceptionin implementations. The interface is too wide — Interface Segregation violation; the seam doesn't fit its implementers.- A
protectedfield or a non-finalclass with no documented self-use. Accidental inheritance surface — fragile base class risk. new ConcreteThing()outside the composition root. A hard-wired dependency where a seam was intended; the class isn't actually closed.- Constructor calling an overridable method. Latent crash in any subclass (see §4 of
senior.md).
3. Tooling that enforces extension boundaries¶
Reviews don't scale; encode the rules.
ArchUnit — assert architectural extension boundaries as tests:
@ArchTest
static final ArchRule callers_depend_on_abstractions =
classes().that().resideInAPackage("..service..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..service..", "..spi..", "java..");
@ArchTest
static final ArchRule concretes_only_created_in_root =
noClasses().that().resideOutsideOfPackage("..config..")
.should().callConstructor(GzipCompressor.class); // wiring stays in the root
@ArchTest // SPI implementations must not be referenced by name elsewhere
static final ArchRule spi_impls_are_isolated =
classes().that().implement(Channel.class)
.should().onlyBeAccessed().byClassesThat().resideInAPackage("..config..");
Error Prone — catches the inheritance-design bugs at compile time: @DoNotCall, ConstructorInvokesOverridable, MissingOverride, DoNotMock. Turn ConstructorInvokesOverridable to an error in any module with public extensible base classes.
SpotBugs — MALICIOUS_CODE/FINALIZER and the "class is final but declares protected member" / "method invokes overridable in constructor" detectors flag unsafe inheritance surfaces.
Modulith / jMolecules — verify that module boundaries (where most real seams live) aren't bypassed; pairs with named SPI packages.
japicmp / revapi — for any published seam, fail the build on a binary-incompatible change (added abstract method, removed method, narrowed access). This is what makes "don't break implementers" mechanical:
@implSpec Javadoc + Checkstyle — require documented self-use on any non-final public/protected method in extensible base classes.
4. The refactoring playbook: introducing a seam safely¶
When a missing seam is confirmed (a real second variant arrived), introduce it without a big-bang rewrite:
- Confirm the variation is real. Two concrete behaviours now, not "might". Otherwise stop — adding the seam is the smell.
- Characterize with tests. Pin current behaviour for every existing branch before moving anything. This is your safety net.
- Extract the abstraction (IDE-assisted). Extract Interface / Extract Method on the varying logic. Keep the original as the first implementation.
- Replace conditional with polymorphism one switch at a time. Move each branch's body into the matching implementation; the switch becomes a dispatch. Run tests between each.
- Push creation to the composition root. Replace inline
new/switch-to-pick with injection of the chosen implementation. - Add the new variant. Only now write the second implementation — the feature that triggered the work. It touches no existing code: the proof the seam works.
- Lock the boundary. Add the ArchUnit rule (§3) so the closed callers stay closed.
For the inverse (a switch that should stay): if branching is over a closed domain set, seal the type and convert if/else throw to an exhaustive switch — now the compiler guards completeness. See ../../05-advanced-language-features/01-sealed-classes-and-pattern-matching/.
5. Measuring extensibility — change-cost, not gut feel¶
"Extensible" should be measurable, so you can show the seam paid off:
- Files-touched-per-feature. From git: for the last N features of the same kind (e.g. "add a payment method"), how many files changed? A working seam drives this toward one new file. Rising count = the seam is wrong-axis or leaking.
- Change coupling (temporal coupling). Mine
git log(e.g.code-maat,git-of-theseus) for files that always change together. A caller that changes every time a new variant is added is not actually closed. - CK metrics on the abstraction. NOC (number of children) shows real polymorphic use; a seam interface with NOC=1 after months is speculative. DIT/CBO on the base class flag over-deep or over-coupled hierarchies. See ../02-oo-metrics-ck-suite/.
- Cyclomatic complexity of the would-be switch. A method whose CC climbs by 2 every release is the conditional-explosion signal; track it.
Bring these numbers to the design discussion: "the last four payment integrations each touched the Checkout class — the seam is in the wrong place" is an argument; "feels rigid" is not.
6. When extensibility is the wrong call¶
A professional pushes back on seams as often as they add them. Decline the seam when:
- One implementation, no roadmap for a second. The seam is speculative generality. Inline it; add the seam when the second arrives (the refactor in §4 is cheap with tests).
- The variation is over values, not types. No
Adult/Minorclasses for an age check. - The "flexibility" is configuration nobody will use. Every plugin point is attack surface, test surface, and documentation burden. An SPI used by exactly your own team is just indirection.
- It's a leaf with no callers to protect. OCP protects callers; a seam on a class nothing depends on closes nothing.
- The team can't maintain the contract. An SPI is forever. If you can't commit to binary-compat governance, don't publish one.
"We don't need a seam here yet" is a senior position, not a junior one. The cost of a wrong seam (frozen on the wrong axis, see senior.md §2) exceeds the cost of adding the right one later.
7. Governing a published SPI¶
If your team ships an extension point others depend on, it needs lightweight governance:
- Mark the API surface. Dedicated
..spi../..api..packages or a module that only exports the seam; everything else is internal (JPMS makes this enforceable — see ../../05-advanced-language-features/02-jpms-modules/). - Compatibility gate in CI. japicmp/revapi fails the build on binary breaks (§3).
- Evolution policy in writing. New capability →
defaultmethod or capability sub-interface, never a new abstract method. Deprecate-then-remove over two minor versions; never silent removal. - A reference implementation + TCK. Ship a test kit that any provider runs to prove conformance — turns the prose contract into executable spec, the only real defense against leaky-abstraction drift.
- Document self-use and threading. Implementers need to know what you call, when, and on which thread. See ../03-thread-safe-object-design/ for the concurrency half of a provider contract.
8. Review checklist¶
- Repeated type-switches identified and either justified (lone/stable) or flagged for polymorphism.
- Each new seam has a real second implementer or concrete roadmap — no speculative seams.
- Axis of change named: types-grow (polymorphism) vs operations-grow (sealed+switch).
- Abstractions are minimal and free of implementation types (no leaks).
- Non-
finalbase classes document self-use; no overridable calls in constructors. - Concrete construction confined to the composition root (ArchUnit-enforced).
- Sealed used for complete sets; plain interface/SPI for outsider-extension.
- Published seams under japicmp/revapi; evolution via
default/capability/versioning. - Change-cost trend (files/feature) flat or falling for the seam's feature kind.
9. What's next¶
| Topic | File |
|---|---|
| OCP/PV/Bloch/GoF/JLS canonical sources | specification.md |
| Rigid-switch, leaky-abstraction, unsafe-subclass bugs | find-bug.md |
| Measuring and removing conditional explosion | optimize.md |
| Design-an-extensible-API exercises | tasks.md |
Memorize this: make "where can this grow, and on which axis?" a standing review question; name the problem precisely (repeated type-switch, leaky abstraction, undesigned inheritance, speculative seam); enforce the boundaries with ArchUnit + Error Prone + japicmp rather than vigilance; measure extensibility by files-touched-per-feature and change coupling; and push back on seams with no real second implementer — a wrong-axis seam costs more than the right one added later.
In this topic