skip to content

What is the fragile base class problem, and can you walk through a concrete way a harmless-looking change in a base class breaks its subclasses?

level: middleimportance: must knowfreq 62%

answer

  1. self-call: addAll → add → double count
  2. base refactor is externally invisible, still breaks subclass
  3. constructor calls overridable method → uninitialized fields
  4. accidental override on base evolution
  5. final/sealed by default; document hooks

basics

~20 s

When a subclass overrides methods, it can depend on how the base class calls its own methods internally. If the base later changes those internal calls — even without changing its public behavior — subclasses silently break. That's the fragile base class problem.

solid answer

~50 s

Implementation inheritance couples a subclass to the base class's *internal* structure, not just its published contract. The classic case is **self-calls**: if a base method `addAll(items)` is implemented as a loop over `add(item)`, a subclass that overrides both to count elements double-counts, because its overridden `add` runs inside the base's `addAll`. Fix the base later to copy the array directly, and the same subclass now under-counts. Neither version broke the base's documented contract — yet subclasses broke both times. Other flavors: adding a method to the base that accidentally collides with an unrelated subclass method ("accidental override"); changing which constructor path initializes state, so a base constructor calls an overridable method before the subclass's fields exist; and tightening/loosening a base invariant subclasses relied on. Mitigations: document the self-call pattern ("design for inheritance or prohibit it"), make classes final/sealed by default, expose narrow protected hook methods instead of overridable public ones, or use composition/delegation, where the wrapper only depends on the public interface.

code

pseudocode · 10 lines
pseudocode
class Base {
  fun add(x)     { store(x) }
  fun addAll(xs) { for (x in xs) add(x) }   // undocumented self-call
}
class Counting : Base() {
  var n = 0
  override fun add(x)     { n++;         super.add(x) }
  override fun addAll(xs) { n += xs.size; super.addAll(xs) }  // n grows by 2x
}
// Base later switches addAll to a bulk copy -> same subclass now undercounts.

go deeper

for a junior

State the idea — subclasses depend on base internals, so base changes break them — and give the self-call example.

for a middle

Walk the addAll/add double-count and the after-refactor undercount, then name a mitigation (document self-calls, or use composition).

for a senior

Cover multiple flavors — self-calls, constructors calling overridables, accidental override, protected-state drift — and the design rules: final by default, template-method hooks, subclass tests in the base's own build.

for a principal

Frame it as a versioning/API-compatibility problem across ownership boundaries: inheritance turns internal call structure into a semantic-versioning obligation. Discuss policy — sealed hierarchies, extension via callbacks/strategies at library edges, deprecation paths for existing open classes.

## Definition The **fragile base class problem**: a base class cannot be evolved safely, because subclasses depend on implementation details that were never part of its published contract. Changes that are legal and invisible from the outside break code that inherits from it. It is the single strongest argument behind "favor composition over inheritance." ## Flavor 1 — self-calls (the canonical example) A collection base class offers `add(x)` and `addAll(xs)`. Internally: ``` class BaseCollection { fun add(x) { store(x) } fun addAll(xs) { for (x in xs) add(x) } // self-call! } ``` A subclass wants to count everything added: ``` class CountingCollection : BaseCollection() { var count = 0 override fun add(x) { count += 1; super.add(x) } override fun addAll(xs) { count += xs.size; super.addAll(xs) } } ``` `addAll([a,b,c])` adds **6** to the count, not 3: the subclass's `addAll` adds 3, then `super.addAll` loops and calls the *overridden* `add` three more times (virtual dispatch always lands on the most-derived override). The subclass author had no way to know, because "`addAll` is implemented in terms of `add`" was nowhere in the docs. Now the base team optimizes: ``` fun addAll(xs) { storeBulk(xs) } // no more self-call ``` Externally identical. But `CountingCollection` now counts **3** — and if the author had "fixed" the double-count by deleting the `addAll` override, it now counts **0**. Either the self-call pattern is part of the contract (and can never change), or subclasses are broken by construction. That's the fragility. ## Flavor 2 — calling overridable methods from a constructor/initializer ``` open class Base { init { render() }; open fun render() {} } class Child : Base() { val label = "hi"; override fun render() { print(label.length) } } ``` The base's initializer runs before the child's fields are assigned, so `render()` sees `label` uninitialized (null / default / crash). Nothing about `Base`'s public API hints at this ordering. ## Flavor 3 — accidental override / name collision on base evolution Version 1 of a base has no method `flush()`. A subclass in another codebase defines its own `flush()` meaning "clear the local cache." Version 2 of the base adds `flush()` meaning "write buffered data to disk," and the framework starts calling it. The subclass method is now silently repurposed as an override and gets invoked at times its author never anticipated. Some languages require an explicit `override` keyword, which turns this into a compile error rather than silent corruption — a mitigation, not a cure, since the subclass still must be changed. ## Flavor 4 — invariant drift A base guarantees "`size` is never negative" and protected field `buf` is "never null after construction." A refactor introduces lazy allocation: `buf` is null until first use. Subclasses touching `buf` directly break. Protected state is a *published API* with all the compatibility obligations of public API — and far more implementation detail in it. ## Why composition doesn't have this problem A wrapper holds the object and calls only its public methods: ``` class CountingCollection(private val inner: Collection) : Collection { var count = 0 fun add(x) { count += 1; inner.add(x) } fun addAll(xs) { count += xs.size; inner.addAll(xs) } } ``` Whether `inner.addAll` loops internally or bulk-copies is invisible — its internal calls dispatch to *itself*, never back into the wrapper. The count is 3 in both base versions. The trade-off is the forwarding boilerplate and the **self-problem** (mirror image of the self-call issue: the wrapper *cannot* intercept the inner object's internal calls even when you want it to). ## Mitigations if you must ship an extensible base class 1. **Document the self-call structure** — "`addAll` invokes `add` for each element; overriding `add` affects `addAll`" — and treat it as frozen contract thereafter. 2. **Make classes final/sealed by default**; open only what you deliberately support (the "design for inheritance or else prohibit it" rule). 3. **Prefer narrow protected hooks** (Template Method: one `abstract fun step()` the base calls at a defined point) over letting subclasses override public workhorse methods. 4. **Never call overridable methods from constructors/initializers.** 5. **Ship subclass-facing tests** — write at least one subclass yourself, in the same build, so base refactors break your CI rather than a customer's. 6. **Keep base and subclasses in one ownership boundary** where you can refactor both together; across a library boundary, prefer callbacks/strategies over inheritance.

  • How does wrapping via composition avoid the double-count, exactly?
    The wrapper calls `inner.addAll(xs)`; whatever `inner` does internally dispatches to `inner`'s own methods, never back up into the wrapper. The wrapper's counting runs exactly once, regardless of the inner implementation.
  • What is the "self-problem" and why is it the mirror image of this?
    With composition the wrapper *cannot* intercept the wrapped object's internal self-calls. If you decorate a repository to add logging, an internal call inside the repo bypasses your log. Inheritance intercepts everything (sometimes too much); composition intercepts only what goes through the wrapper (sometimes too little).
  • Why is `protected` state considered part of a class's public API?
    Every subclass, including ones outside your codebase, can read and write it, so changing its type, nullability, or lifecycle is a breaking change with the same compatibility obligations as public API — but it exposes implementation detail, making it far harder to keep stable.

Inheriting is like remodeling a flat while relying on the neighbor's plumbing layout. The landlord can legally reroute the pipes — the building still works, your bathroom doesn't.

context