skip to content

What is the fragile base class problem, and how does Kotlin's class delegation help you avoid it compared to using `open` inheritance?

level: middleimportance: should knowfreq 45%

answer

  1. subclass breaks when base internals change
  2. self-call sequence is undocumented coupling
  3. Kotlin classes final by default
  4. delegation couples only to interface contract
  5. HashSet add/addAll double-count example

basics

~20 s

The fragile base class problem is when a subclass quietly breaks because the parent class changed how it works inside, even if the parent's public methods look the same. Delegation avoids it by only using the parent's public interface, not its internals.

solid answer

~50 s

The fragile base class problem occurs when a subclass overrides methods and unknowingly depends on the base class's private call patterns. A classic example: a subclass extends `HashSet` and overrides `add`/`addAll` to count elements; later the base's `addAll` is changed to call `add` internally, so counts double. The subclass didn't change but broke. Kotlin discourages this by making classes `final` by default — you must explicitly mark a class `open` to allow inheritance. Class delegation (`by`) offers a safer reuse path: the wrapper depends only on the delegate's interface contract, never its internal self-calls, so base-internal refactors can't silently alter wrapper behavior. The trade-off: delegation also forgoes self-call interception, so partial overrides won't see the delegate's internal calls. The guidance is 'prefer composition (delegation) over inheritance' precisely to keep coupling at the stable interface boundary.

code

kotlin · 6 lines
kotlin
// Safe composition alternative to a fragile subclass
class CountingSet<E>(private val inner: MutableSet<E>) : MutableSet<E> by inner {
    var attempts = 0; private set
    override fun add(e: E): Boolean { attempts++; return inner.add(e) }
    override fun addAll(c: Collection<E>): Boolean { attempts += c.size; return inner.addAll(c) }
}

go deeper

for a junior

Can state that a subclass may break when the parent changes internally and delegation avoids depending on internals.

for a middle

Explains the self-call double-count example and how delegation + final-by-default mitigate it.

for a senior

Articulates the symmetric trade-off (robustness vs lost self-call interception) and when each is appropriate.

for a principal

Sets a team rule: expose stable interfaces, prefer delegation for cross-cutting reuse, and only open types with documented extension contracts.

## Definition The **fragile base class problem** is a structural flaw of implementation inheritance: a working subclass can break when the *base class's internal implementation* changes — even though the base's public API is unchanged. The subclass implicitly depended on **how** the base called its own methods (its self-call sequence), which is an undocumented detail. ## Canonical example (Java's `HashSet`/Effective Java) ```kotlin open class CountingSet<E> : HashSet<E>() { var attempts = 0; private set override fun add(e: E): Boolean { attempts++; return super.add(e) } override fun addAll(c: Collection<E>): Boolean { attempts += c.size; return super.addAll(c) } } ``` If `HashSet.addAll` is implemented by calling `add` per element, then `addAll(listOf(a,b,c))` increments `attempts` by 3 (in `addAll`) **and** 3 more (via each `add`), giving 6. The bug is invisible until you read the base's source. A future JDK change to `addAll`'s internals could change the count again. That is fragility. ## How Kotlin reduces the risk 1. **`final` by default.** In Kotlin a class cannot be subclassed unless declared `open`; members are non-overridable unless `open`. This makes accidental inheritance impossible and forces a deliberate choice. 2. **Class delegation as the safe alternative.** ```kotlin class CountingSet<E>(private val inner: MutableSet<E>) : MutableSet<E> by inner { var attempts = 0; private set override fun add(e: E): Boolean { attempts++; return inner.add(e) } override fun addAll(c: Collection<E>): Boolean { attempts += c.size; return inner.addAll(c) } } ``` Here `inner.addAll` calls `inner`'s own `add`, **never** the wrapper's `add`. So there is no double-count from self-calls. The wrapper depends only on the `MutableSet` interface, not on `HashSet`'s internal call graph — a base-internal change can't silently corrupt it. ## The trade-off, stated honestly Delegation's robustness comes from the **same** reason it loses self-call interception (see the CountingList trap). You trade automatic propagation of overrides through the base's internals for immunity to base-internal change. Usually that's the safer default — hence 'favor composition via `by`.' ## Recall Fragility = depending on the base's hidden self-calls; delegation depends only on the stable interface.

  • Why does Kotlin make classes final by default relate to this problem?
    It forces authors to opt into inheritance with `open`, so a class is only subclassable when its author has considered the self-call contract — reducing accidental fragile-base coupling.
  • Is delegation always strictly safer than inheritance?
    Not always — it loses self-call interception and adds a forwarding indirection. But for reuse across a stable interface it removes a major class of silent breakage.

Inheritance reads the parent's diary (private habits); delegation only signs the public contract — so the parent can change its habits freely.

saying these in an interview costs you the question

  • Defining fragile base class as merely 'a bug in the base class'
  • Claiming delegation has zero trade-offs vs inheritance
  • Not knowing Kotlin classes are final by default
  • Thinking the public API changing is what causes the fragility (it's the internals)

context