Composition Over Inheritance
A hierarchy picks one axis of variation forever. A field can be swapped. That reversibility — not elegance — is the whole argument.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Why does a three-level class hierarchy get expensive to change when the same behaviour held in fields does not?
The vehicle catalogue has cars, vans and motorbikes, all petrol. Product now wants electric vans, and next quarter hybrids, and the fleet team wants a diesel van that is also a refrigerated unit.
Model the real world. A Car *is a* Vehicle, and it *is an* EnginePoweredVehicle, so Car extends EnginePoweredVehicle extends Vehicle. The taxonomy already exists in the domain; the code should mirror it. Shared fuel logic goes up into the middle class where every powered vehicle can reuse it.
The taxonomy was chosen before anyone knew which axis would vary. Real requirements arrive on the power source, then the body type, then the refrigeration unit, and a single-inheritance chain can express exactly one of those three.
- The taxonomy was chosen before anyone knew which axis would vary. Real requirements arrive on the power source, then the body type, then the refrigeration unit, and a single-inheritance chain can express exactly one of those three.
ElectricVanhas to sit somewhere. UnderEnginePoweredVehicleit inheritsfuelTankLitresand lies about it; beside it, the shared body-type logic is now duplicated on both branches (Duplicate Knowledge).- The fix that gets applied under deadline is a flag on the parent —
isElectric— checked in three inherited methods. The hierarchy is now a conditional with extra steps, and the conditional lives in the base class where every descendant pays for it (Boolean Flag Explosion). - The middle class becomes the place shared code is *put*, not the place it *belongs*, and it acquires every reason to change that any descendant has (Divergent Change).
- Reading
Car.runningCost()now means reading three files, because the language assembles the behaviour and the call site shows none of it (Local Reasoning).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The hierarchy is two years old and about forty types deep in places; nothing can be rewritten in one release.
- The language is a single-inheritance one, so a type can have exactly one parent and that slot is already spent.
- The catalogue is serialized to the database by type name, so renaming or reparenting a class is a data migration (Data Migration).
- Every vehicle must be able to answer what it costs to run per kilometre, whatever powers it.
- A vehicle that cannot be constructed in a valid state must not be constructible at all — no half-configured engine (Making Illegal States Unrepresentable).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Something must own "how is this vehicle powered" as a single decision with one address — and it must be able to vary independently of what the vehicle *is*.
- The vehicle owns identity, registration and body — the parts that genuinely do not vary by power source.
- No type should own behaviour purely because a descendant needed somewhere to put it.
- The seam falls between the vehicle and its powertrain, because that is where the requirements keep arriving. Boundaries go where change lands, not where the noun taxonomy suggests (Finding Seams).
- A second seam, later, between vehicle and body fitting — refrigeration, tail lift, livery. Two independent axes is precisely what one inheritance chain cannot hold.
- Inside
Vehicle, the powertrain is reached only through its interface, so adding a hydrogen cell does not openVehicleat all (Designing a Module Interface).
The hierarchy commits you before you know anything
Written on day one, the chain is defensible: it is short, it mirrors how people talk, and the shared fuel logic genuinely has one home. What it also does — silently — is spend the single parent slot of every vehicle type on the question "how is it powered", years before anyone knew that was the question that would vary.
The tell is not the depth. It is that the third requirement in a row arrives on a different axis than the chain, and each one has to be encoded as a flag, a branch, or a class whose name is two adjectives glued together.
1class Vehicle {2 constructor(readonly plate: string) {}3 runningCost(km: number) { return 0 }4}5 6class EnginePoweredVehicle extends Vehicle {7 fuelTankLitres = 608 litresPerKm = 0.079 runningCost(km: number) { return km * this.litresPerKm * FUEL_PRICE }10}11 12class Car extends EnginePoweredVehicle {}13class Van extends EnginePoweredVehicle { fuelTankLitres = 90 }14 15// "We need electric vans."16// Under EnginePoweredVehicle: inherits fuelTankLitres, and lies.17// Beside it: body-type logic on Van is now duplicated.18// What actually ships:19class ElectricVan extends Van {20 isElectric = true // ...and three inherited methods21 runningCost(km: number) { // now branch on it22 return km * KWH_PER_KM * KWH_PRICE23 }24}Notice fuelTankLitres = 90 on ElectricVan, inherited and meaningless. An inherited field you have to remember to ignore is the hierarchy telling you the axis is wrong.
The same requirement, priced twice
This is the argument. Not that fields are more elegant than parents, but that the electric-van requirement opens six files in one design and zero in the other — and that the difference compounds, because the hybrid requirement behind it is impossible in one and trivial in the other.
Vans may be petrol, electric or hybrid. Running cost, range and the service schedule all depend on the powertrain; registration, plates and body type do not.
The base class is opened, so every descendant is in the regression. The serialized type name changes, so it is also a data migration. Hybrid, arriving next, has no valid place in the chain at all.
Nothing existing is opened. Hybrid is a HybridPowertrain holding two others — a new file, the same one-line wiring, and no change to Vehicle.
Powertrain no longer supplies defaults: adding emissionsPerKm() to the interface later touches every implementation at once, where the old base class would have given them all a free one. Composition traded a rigid hierarchy for a stiff interface.Choosing, honestly
The choice is not a principle, it is a question about the domain: how confident are you that this axis is the one that varies, and how expensive is being wrong? Inheritance is cheap when you are right and very expensive when you are wrong, because unwinding it is a migration. Composition is slightly expensive always and never catastrophic.
That asymmetry — not cleanliness — is why composition is the better default. It is the cheaper mistake (Reversible and Irreversible Decisions).
Two types share behaviour. Should one extend the other, hold the other, or neither?
when Every subtype genuinely substitutes for the base everywhere it is used, the set of subtypes is closed and stable, and the shared part is real shared state and behaviour rather than shared lines.
cost Spends the single parent slot permanently. Subclasses depend on the base implementation, not just its interface, so the base can no longer be changed freely (Liskov Substitution, Critically).
when The behaviour varies on an axis independent of what the type *is*, or you cannot yet tell which axis will vary.
cost Delegation boilerplate per method, deeper object graphs, and construction moved to the caller.
when The variation is one operation, has no state, and is decided per call rather than per object.
cost Nothing structural — but it does not scale to variation across several related operations, at which point you are re-deriving an interface by hand (Strategy).
when The two pieces of code look alike but answer to different owners, so a change to one must not move the other.
cost Two places to edit when they really do change together, and the duplication has to be re-examined at the third occurrence (The Rule of Three).
How to build it
Most important first.
- Ask which axis actually varies, using the last six changes as evidence rather than the domain vocabulary. Power source varied three times; the fact that a car is a vehicle varied never.
- Hold the varying part in a field behind a narrow interface:
Car has Powertrain, wherePowertrainanswersenergyCostPerKm()andrange()and nothing else (Interface Versus Implementation). - Delegate explicitly.
runningCost()onVehiclecallsthis.powertrain.energyCostPerKm(); the assembly is visible in one file, and a reader is done reading. - Construct the combination at one place — a small factory or the wiring layer — so that "which vehicles exist" is data rather than a class per combination (Wiring and the Composition Root).
- Leave inheritance where it is genuinely earning: if
Vehiclehas real shared state and every subtype truly substitutes for it, that base class is fine and moving it costs more than it returns (When Inheritance Fits). - Migrate incrementally. Introduce
Powertrain, have the existing subclasses delegate to it, and collapse the branch only once nothing reads the inherited fields (Incremental Migration).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Under the hierarchy, "add electric vans" costs a new branch or a flag, edits to three inherited methods, a migration of the serialized type name, and a regression of every descendant of the class you touched. Roughly linear in the number of subtypes below the edit.
- Under composition, it costs one new
Powertrainimplementation, its own test, and one line in the wiring table. Nothing existing is opened, so nothing existing needs re-testing beyond the interface contract. - The change after that — "hybrid", which is two powertrains — is cheap under composition (a
HybridPowertrainthat holds two) and structurally impossible under single inheritance without a flag. - What did not get cheaper: a change to the
Powertraininterface itself — addingemissionsPerKm()— now touches every implementation, where the base class would have supplied a default. That is the honest cost, and it is why a stable interface matters more here than a stable class (API Stability).
- More objects, more wiring, more indirection at every call. A reader tracing running cost now follows a field they did not have to follow before.
- Composition moves construction complexity to the edges, where it becomes visible and occasionally ugly. Inheritance hid it in the type system, which felt tidier and was not.
- Losing the base class means losing free defaults: every implementation must now supply every method, and adding one to the interface is a breaking change across all of them.
What can go wrong
- The powertrain interface is drawn to match the petrol implementation, so electric has to fake
fuelTankLitresanyway and nothing was gained (Leaky Abstractions). - Delegation is added but the base class keeps its own copy of the logic "for compatibility", so the rule now lives in two places and one of them is authoritative (Duplicate Knowledge).
- Composition is applied to a hierarchy that was not varying, buying indirection and no flexibility. The mitigation for a rigid hierarchy is itself a cost when there is no rigidity (Premature Abstraction).
- Every combination gets its own class anyway —
ElectricRefrigeratedVan— and the combinatorial explosion moves from inheritance into naming.
Vehicledepends on thePowertraininterface; each concrete powertrain depends on nothing inVehicle. The direction is deliberate and points away from the volatile part (Dependency Direction).- Under inheritance the dependency runs the other way and is invisible: a subclass depends on the *implementation* of every ancestor, including protected fields and the order in which the base calls its own methods.
- Composition adds a construction-time dependency — someone must decide which powertrain to pass — which is real work moved to the edge, not removed (Constructor Injection).
- "So never use inheritance." No. The claim is that inheritance commits you to one axis of variation permanently, which is a bad default and a fine deliberate choice. A stable base with genuine substitutability is good design (When Inheritance Fits).
- "Composition means an interface for everything." It does not.
Car has Engineis composition whether or notEngineis an interface; the interface is only worth adding once there is more than one engine (Polymorphism). - "The class hierarchy should mirror the domain taxonomy." Domain taxonomies are classifications, not variation axes. Biology has a taxonomy and no requirements (Choosing the Model).
- "This is just the strategy pattern." Strategy is one name for one shape of this. The principle here is about where variation is held, and it applies to code with no patterns in it at all (Strategy).
- divergent-change
- shotgun-surgery
- boolean-flag-explosion
Testing it, and how it ages
- Test each powertrain in isolation with no vehicle at all. If that is hard, the interface is still carrying vehicle knowledge.
- Test
Vehicle.runningCost()with a stub powertrain — composition makes the substitution a parameter rather than a mocking-framework trick (Test Doubles, Precisely). - One contract test run against every
Powertrainimplementation, asserting the properties all of them must satisfy — non-negative cost, range in kilometres (Contract Tests). - Characterize the existing hierarchy's outputs before delegating, so "behaviour did not change" is an assertion (Characterization Tests).
- Composition ages well along the axis you chose and no better than inheritance along the axes you did not. Choosing the axis is still a bet (The Cost of Change).
- As powertrains multiply, the interface will be pulled toward the union of what they all need. That is the point at which to split it rather than widen it (Interface Segregation, Critically).
- If the domain genuinely stabilises — the last powertrain was added four years ago — the flexibility stops paying and the indirection is pure cost. That is a legitimate reason to inline it back.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- PARADIGM-SPECIFICThe whole dilemma presupposes a class-based OO language. In a functional language the varying behaviour is a function value in a record and this lesson collapses to "pass the function", which is why Haskell and Clojure codebases have no composition-versus-inheritance debate; in Rust the choice is between a trait object and a generic parameter, which trades dynamic dispatch against monomorphisation rather than flexibility against rigidity.
- LANGUAGE-SPECIFICSingle-inheritance languages (Java, C#, Kotlin, TypeScript classes) make the "one axis only" constraint hard. C++ and Python allow multiple inheritance, which trades the rigidity for method-resolution-order ambiguity and the diamond problem — a different cost, not the absence of one (Mixins, Traits and Embedding).
- CONTESTEDThe strongest opposing case: delegation boilerplate is a genuine, recurring, per-method tax, and in a stable hierarchy — a UI widget tree, an AST node family, a parser combinator library — inheritance gives you defaults, exhaustive substitution and less code with no observed downside for years. Engineers who maintain such codebases correctly point out that "favour composition" is advice generalised from business systems, where the taxonomy really is unstable, to codebases where it is not.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — what
extendsactually costs at runtime: a vtable lookup, a fixed object layout and a devirtualisation opportunity the JIT loses when the hierarchy is deep, which is a different argument from the maintenance one and occasionally points the other way. - — Testing & Reliability Engineering — substituting a field is an ordinary parameter; substituting a superclass needs a framework, and that difference is most of why composed designs are easier to test.