You own a framework whose base class exposes a template method with protected extension points implemented by thousands of subclasses you don't control. How do you evolve that algorithm without breaking them?
answer
- protected surface = published API
- new abstract step = source break
- reorder = silent behavioural break
- hooks with behaviour-preserving defaults
- migrate seam: subclassing → interface + adapter
basics
~20 sTreat every step as published API. Never add a required step or reorder existing ones; add optional hooks with defaults that keep current behaviour, deprecate slowly with clear migration notes, and offer a composition-based alternative for the next major version.
solid answer
~50 sOnce a base class is public, its protected surface — step names, calling order, what state is set when each fires, whether a step may be called zero/one/many times — is a contract you must support, even though no type signature captures it. Rules: adding an abstract step is a breaking change; add hooks with behaviour-preserving defaults instead. Never reorder or change the invocation count of existing steps; that is a silent behavioural break with no compile signal. New behaviour goes into new hooks that default to today's semantics. Version aggressively: document each step's contract, run a compatibility test suite built from real downstream subclasses, and deprecate over at least one major cycle with an automated migration where possible. Strategically, prefer a narrow injected interface or middleware chain as the *new* extension mechanism — it evolves via default methods and adapters instead of forcing recompiles — and keep the old base class as a thin adapter delegating to it until it can be removed.
go deeper
Say that the base class's steps are effectively public API and that adding a required step would break everyone; new behaviour should be optional with a default.
Distinguish source-breaking (new abstract member) from silently behaviour-breaking (reordering, changed call count) and describe deprecation with defaults.
Add the full implicit contract (order, cardinality, state, threading), a compatibility corpus in CI, abstract test kits for implementers, and flagged rollout of behavioural changes.
Set policy: what stability the protected surface guarantees and for how long; drive migration of the extension mechanism from subclassing to narrow interfaces plus adapters; balance ecosystem breakage against the cost of a frozen framework.
## Why this is a governance question, not a coding question Template Method makes a framework's lifecycle un-bypassable — the base class's *template method* (the fixed skeleton) calls *primitive operations* (abstract, must be overridden) and *hooks* (defaulted, may be overridden). That is great for the framework's guarantees and terrible for its evolution, because inheritance is **white-box reuse**: the subclass depends on things no signature expresses. The implicit contract of every step includes at minimum: - **When** it is called (which point in the skeleton, before or after which other steps). - **What state is established** by then (is the connection open? is validation done?). - **How many times** it is called (exactly once? once per row? possibly zero?). - **On which thread**, and whether concurrent invocation is possible. - **What it may do** — throw? return null? call back into the framework? block? - **What the framework does with the result** — is the return value validated, cached, mutated? Downstream code depends on all of it. Nothing in the type system protects you. ## Change classification | Change | Compatibility | Notes | |---|---|---| | Add a new **abstract** step | **Source-breaking** for every concrete subclass | Never do this in a minor release | | Add a new **hook with a behaviour-preserving default** | Compatible (usually) | Safest way to add an extension point; beware name collisions with existing subclass methods, which can accidentally become overrides | | Reorder existing steps | **Silently behaviour-breaking** | Compiles fine, misbehaves in production; treat as major-version only | | Change a step's invocation count (once → per item) | Silently breaking | Overrides written assuming "once" now run N times, duplicating side effects | | Change what state is set before a step | Silently breaking | Overrides read fields that are now unset/different | | Widen a step's return type / narrow its parameters | Usually breaking on override signature | Language-dependent; check override-matching rules | | Strengthen a precondition the step must satisfy | Breaking | Violates the substitutability the framework promised | | Wrap a step in try/catch or timeouts | Behaviour-changing | Overrides that relied on exceptions escaping now see them swallowed | | Add validation of a step's returned value | Behaviour-changing | Previously-tolerated nulls/empties now fail; ship behind a flag first | ## Practices that make evolution survivable 1. **Write the contract down per step**, in the same place as the code, including calling order, cardinality, threading, and prohibitions ("must not call the template method re-entrantly"). 2. **Compatibility test suite.** Maintain a corpus of representative downstream subclasses (real ones, with permission, or faithful synthetic ones) and run them on every change. This is the only mechanical detector for behavioural breaks. 3. **Abstract test kit for implementers.** Publish a base test class asserting the invariants; implementers extend it. It documents the contract executably and gives you telemetry on which behaviours people rely on. 4. **Feature flags for behavioural changes.** Ship the new ordering/validation off by default, let adopters opt in, then flip the default in a major release, then remove the flag. 5. **Deprecation discipline.** Mark old hooks deprecated with a concrete migration note and a target removal version; keep them functioning for at least one full major cycle. Where the ecosystem supports it, ship an automated migration (codemod/OpenRewrite-style recipe). 6. **Restrict future extension.** Seal new hierarchies, or expose extension only via interfaces, so you never again publish a skeleton you cannot change. 7. **Telemetry.** If the framework can report which hooks are overridden in the wild (build plugin, registry scan, opt-in analytics), you learn what is safe to change. ## The strategic move: change the extension mechanism The durable answer is to migrate the extension surface from *inheritance* to *composition*: - Define a narrow interface per concern (e.g. `RequestFilter`, `Serializer`, `RetryPolicy`) that users implement and register, rather than subclass. - Interfaces with default methods evolve far more gracefully: you can add a method with a default, and adapters can bridge old to new. - A **middleware/interceptor chain** handles the common "I want to do something before/after" case better than a hierarchy, and composes across independent concerns without combinatorial subclassing. - Keep the old abstract class alive as a **thin adapter** that implements the new interface and forwards to the legacy protected methods. Existing subclasses keep working; new users get the modern seam; you delete the adapter in a later major version. ## Judgement calls - **Do not stall forever.** Some frameworks freeze because every change breaks someone. Set an explicit support policy ("protected surface is stable within a major version") so you retain the right to change on a known cadence. - **Weigh blast radius vs benefit.** A reorder that fixes a correctness bug may be worth a documented behavioural break in a major release; a reorder for tidiness never is. - **Prefer additive extension points that default to today's behaviour** — the accumulated cost is API surface bloat, which is real but recoverable, versus broken users, which is not.
- Why is reordering two existing steps more dangerous than adding a new abstract one, even though only the latter breaks the build?Because the build break is loud, immediate, and localised — every affected user sees it at compile time and fixes it. Reordering compiles cleanly and changes behaviour only under specific runtime conditions, so it escapes into production and surfaces as data corruption or misordered side effects, often far from the change.
- How would you introduce a genuinely required new step without breaking existing subclasses?Ship it as a hook with a default that reproduces today's behaviour, so nothing breaks. Log or report when the default is used, deprecate it with a migration note and a target version, give the ecosystem at least one major cycle, then make it abstract (or better, move the whole concern behind an injected interface so it never needs to be abstract).
- What mechanism gives you the earliest warning that a framework change breaks downstream subclasses?A compatibility corpus: representative downstream subclasses compiled and executed against every candidate change in CI, paired with a published abstract test kit that implementers extend so contract violations show up in their builds too. Telemetry on which hooks are actually overridden tells you where the risk is concentrated.
It is like changing the plumbing in an apartment block where thousands of tenants have already built cabinets around the pipes. Adding a new spare tap is fine; moving an existing pipe six inches compiles perfectly and floods a hundred kitchens.
saying these in an interview costs you the question
- Treating protected members as "internal" — for a subclassable published class they are API with the same compatibility obligations as public members.
- Reordering steps or changing a step's invocation count in a minor release because "it still compiles".
- Adding an abstract member to a released base class.
- Assuming semantic versioning alone protects you — most Template Method breaks are behavioural, not signature-level, so no tooling flags them.
- Deprecating and removing in the same release cycle, with no migration path.
- Solving the problem by freezing the framework forever instead of publishing a support policy and a modern composition-based seam.