skip to content

When is class inheritance still the right choice over composition? Give concrete criteria, not just "when there's an is-a relationship".

level: seniorimportance: must knowfreq 55%

answer

  1. LSP: no stronger preconditions, no weaker postconditions
  2. designed for extension = documented self-calls + narrow hooks
  3. same ownership boundary = cheap refactors
  4. sealed/closed hierarchy = safe inheritance
  5. override that throws Unsupported = not is-a

basics

~20 s

Use inheritance when the subtype is genuinely substitutable for the base everywhere (Liskov), the base was deliberately designed and documented for extension, and both live under one owner. Closed/sealed hierarchies and framework template-method hooks are good fits.

solid answer

~50 s

Four criteria, all of which should hold: 1. **Substitutability (LSP).** Every caller written against the base keeps working with the subtype — no strengthened preconditions, no weakened postconditions, no broken invariants, no new exceptions. If any override must throw "unsupported", it's not an is-a. 2. **Designed for extension.** The base documents its self-calls and exposes deliberate, narrow hooks (Template Method) rather than letting subclasses override arbitrary public methods. Otherwise you inherit the fragile base class problem. 3. **Shared ownership boundary.** Base and subclasses evolve in the same repo/team, so a base refactor updates the subclasses in the same change. Across a library boundary, prefer callbacks/strategies. 4. **One axis of variation, stable set.** If a second independent axis appears, you're heading for subclass explosion. Strong positive cases: **sealed/closed hierarchies** for exhaustive matching (AST nodes, result types), **interface/abstract-type implementation** (subtyping without inherited implementation — never controversial), **framework extension points** with a fixed lifecycle, and specialization where the subtype only *adds* behavior.

code

pseudocode · 15 lines
pseudocode
// Good inheritance: closed set, exhaustive matching, one owner
sealed interface Expr
  data class Literal(v: Int)        : Expr
  data class Add(l: Expr, r: Expr)  : Expr

fun eval(e: Expr): Int = when (e) {      // compiler checks exhaustiveness
  is Literal -> e.v
  is Add     -> eval(e.l) + eval(e.r)
}

// Good inheritance: template method with one documented hook
abstract class Importer {
  fun run(f: File) { validate(f); parse(f); commit() }   // skeleton is fixed
  protected abstract fun parse(f: File)                  // the only hook
}

go deeper

for a junior

Say inheritance is for a real is-a where the subtype can substitute for the base, and give an example like a framework base class you fill in.

for a middle

State LSP with its precondition/postcondition/invariant rules, use Square/Rectangle, and add "the base must be designed for extension."

for a senior

Give the full checklist — substitutability, designed-for-extension, ownership boundary, single stable axis — plus the strong cases (sealed hierarchies, template-method framework hooks) and the wrap-don't-subclass rule for third-party types.

for a principal

Turn it into policy: default classes to final/sealed, expose extension only through versioned callback/strategy contracts, require a subclass-in-build test for any open base, and describe how you'd migrate an existing open hierarchy without breaking consumers.

## First, split the question Two different things are called "inheritance": - **Interface (subtype) inheritance** — implementing an interface or a purely abstract type. You inherit a *contract*, no implementation, no internals. This is essentially always fine and is not what "favor composition" argues against. - **Implementation inheritance** — extending a concrete class and inheriting its code and state. This is the one requiring justification. Also separate **inheriting for subtyping** ("callers must be able to treat it as a Base") from **inheriting for reuse** ("I just want the parent's code"). Only the first is a reason to inherit; the second is what composition does better. ## The criteria in detail ### 1. Liskov Substitution Principle holds — behaviorally, not just syntactically LSP: if *S* is a subtype of *T*, objects of *T* may be replaced with objects of *S* without altering the correctness of the program. Concretely a subtype must: - **not strengthen preconditions** (can't demand more of callers — e.g. base accepts any string, subtype demands non-empty); - **not weaken postconditions** (must still deliver everything the base promised); - **preserve invariants** of the base; - **not introduce new checked failure modes** callers can't know about; - keep **history constraints** — e.g. an immutable `Point` must not gain a mutable `MutablePoint` subtype, because callers rely on the value never changing. The famous counterexample: `Square extends Rectangle`. `setWidth`/`setHeight` independently is a documented Rectangle behavior; a Square must couple them, so any code doing `r.setWidth(5); r.setHeight(4); assert area == 20` breaks. Syntactically fine, semantically not a subtype. The **red flag heuristic**: any override that throws "unsupported operation", ignores an argument, or documents "don't call this" means LSP is already violated. ### 2. The base was designed for extension "Design and document for inheritance, or else prohibit it." A base fit for subclassing must: - document **self-use patterns** (which overridable method is called by which other method, and when); - provide **narrow, purpose-built hooks** — the **Template Method** pattern: the base owns the invariant algorithm skeleton and calls a small number of abstract/overridable steps at defined points; - **never call overridable methods from constructors/initializers**; - avoid exposing mutable `protected` state (it is public API with implementation detail in it); - ship at least one subclass of its own, in the same build, so refactors break CI rather than users. If none of that has been done, subclassing a concrete class is a bet on undocumented internals. ### 3. Ownership boundary Inheritance turns internal call structure into a compatibility obligation. Inside one repo you can refactor base and subclasses atomically, so the obligation is cheap. Across a published library boundary, every self-call becomes semver-relevant forever. That's why mature libraries make classes `final`/`sealed` by default and offer extension via **callbacks, strategies, or event hooks** instead. ### 4. One stable axis of variation Inheritance encodes exactly one axis. If a second independent axis shows up, you get combinatorial explosion; restructure to composed strategies before it multiplies. ## Cases where inheritance is clearly right - **Sealed / closed hierarchies (sum types).** `sealed interface Expr { Add, Mul, Literal }`. The whole set is known and owned in one file; the compiler checks exhaustive matching; nobody outside adds cases. All the fragility arguments assume an *open* set of unknown subclasses, so they don't apply. This is arguably the strongest modern use of inheritance. - **Framework extension points with a fixed lifecycle.** `extend BaseActivity` / `implement AbstractProcessor`: the framework owns the algorithm, you fill in steps. The hooks are documented and versioned as API. - **Pure specialization that only adds.** A subtype that adds fields/behavior and overrides nothing (or only overrides where the base explicitly invites it) is low-risk. - **Exception hierarchies / marker types**, where the point is exactly the subtype relation used for catching/dispatching. - **Interface default methods / abstract skeletal implementations** — e.g. providing an `AbstractList` skeleton alongside the `List` interface so implementers write three methods; the abstract class is explicitly documented for extension. ## Cases that look like inheritance but shouldn't be - "I need the parent's utility method" → extract a helper or compose. - "Both share fields" → extract a value object both hold. - "It's *sort of* a kind of" → LSP will fail somewhere; use an interface plus composition. - Changing behavior of a third-party class → wrap it (Decorator), don't subclass it. ## The decision procedure to say in an interview 1. Do callers need to treat it as a Base? If no → compose. 2. Does LSP hold for *every* base method, forever? If no → compose. 3. Is the base final/undocumented for extension or owned by someone else? If yes → compose (or wrap). 4. Is there more than one independent axis of variation? If yes → compose. 5. Otherwise inheritance is fine — and prefer a sealed hierarchy or a documented template method over an open concrete base.

  • Why is `Square extends Rectangle` the standard counterexample?
    Rectangle's contract lets width and height be set independently; a Square must couple them, so code that sets width then height and asserts the area breaks. It's a valid syntactic subclass but not a behavioral subtype — LSP fails.
  • Why do sealed hierarchies escape most anti-inheritance arguments?
    The fragility arguments assume an open set of unknown subclasses evolving separately from the base. A sealed hierarchy is closed and owned in one place, so base and cases are refactored atomically and the compiler enforces exhaustive handling.
  • How do modern libraries offer extensibility without opening their classes?
    Classes are final/sealed; extension happens through injected strategies, callbacks/listeners, functional parameters, or a documented plugin interface — contracts they can version deliberately, instead of exposing internal call structure.

Inheritance is a marriage contract, composition is a service agreement. Marry only when you truly intend to be bound by everything the other party does — and only when you both live in the same house and can renegotiate together.

context