Why composition beats inheritance
Open in the interactive app: practice, quiz, mock interview →In one minute
- The principle, from the Gang of Four book in 1994: favour object composition over class inheritance. Build behaviour by giving an object collaborators that it calls through their interfaces, rather than by extending a class and overriding parts of it.
- Inheritance is white-box reuse: the subclass sees, and comes to depend on, how the parent works inside. Composition is black-box reuse: the outer object depends only on the published contract of its parts, so either side can change its internals safely.
- Composition handles combinations. Three independent features done by inheritance need up to eight subclasses; done by composition they need three small parts that can be attached in any mix, and changed at runtime.
- The costs are forwarding code, an extra object and pointer per part, and the "self problem": a wrapped object hands out references to itself, not to its wrapper. Language features (Go embedding, Kotlin
by) remove most of the boilerplate. - Agents, asked for "a unit that also has armour", tend to add a subclass. In review, ask whether the new behaviour is a kind of thing (inheritance may be fine) or a feature some things have (compose it).
How it works
The problem
A strategy game has units. The first design is a hierarchy: Unit, then ArmoredUnit(Unit), then ShieldedUnit(Unit). Design asks for a unit with both, so someone adds ArmoredShieldedUnit(ArmoredUnit) and copies the shield logic into it. Then come units that are vulnerable to fire, units that gain a shield from a spell for ten turns, and a boss whose armour breaks at half health. Each independent feature doubles the number of possible classes, and a spell that grants a shield mid-game cannot change the class of an existing object at all.
The features are not kinds of unit. They are things a unit has. That is the clue to compose.
The composed design
A unit owns its health and holds a list of damage modifiers. Each modifier implements one small interface:
class Shield:
kind, order = "shield", 10
def __init__(self, points): self.points = points
def absorb(self, amount):
taken = min(self.points, amount)
self.points -= taken
return amount - taken
class Unit:
def hit(self, amount):
for m in sorted_by_order(self.modifiers):
amount = m.absorb(amount)
return self.health.take(amount)A shield spell is unit.attach(Shield(30)) and its expiry is unit.detach("shield"). A fire vulnerability is a new class with an absorb that doubles damage; Unit does not change. Every combination exists automatically because combinations are made at runtime, not declared as classes. This is the Strategy idea applied to several collaborators at once, and the same structure that Decorator uses when the parts wrap each other instead of sitting in a list.
Why inheritance breaks under change
A subclass depends on more than its parent's public methods. It depends on which of the parent's methods call which others, in what order, and with what state. The Inheritance lesson calls this the fragile base class problem and shows a store whose subclass silently stopped auditing bulk writes after the parent was optimised. Composition avoids it because the outer object only calls the part's public methods. When the part's author changes how absorb works inside, nothing outside notices, as long as the contract holds.
Inheritance also fixes the relationship at compile time. An object cannot become an ArmoredUnit after construction, but it can gain an Armor part.
Delegation and forwarding
Composition usually needs some methods of the part to appear on the whole. There are three ways:
- Explicit forwarding. Write
def size(self): return self.inner.size()for each method. Clear and greppable, but tedious for wide interfaces. - Language support. Kotlin's
class Logged(inner: Store) : Store by innergenerates every forwarding method at compile time, and you override the ones you need. Go's struct embedding promotes the embedded type's methods to the outer type. Neither gives the inner object a way to call back into the outer one, which is exactly what stops the fragile base class problem. - Dynamic forwarding. Python's
__getattr__is called only when normal attribute lookup fails, so a wrapper can forward everything it does not define. It is short, but it hides the interface from type checkers and readers.
Strictly, "delegation" in the original object-oriented literature meant forwarding where self stays bound to the original receiver, as in prototype-based languages. In everyday use the two words mean the same thing, and the distinction matters only when you hit the self problem.
The self problem
A wrapped object does not know about its wrapper. If a Shield registers itself with an event bus as bus.subscribe(self), the bus holds the inner object, and any behaviour the wrapper adds is skipped for callbacks. The fix is to pass the outer object explicitly (attach(owner)) or to have the whole register callbacks on behalf of its parts.
When inheritance is still right
- True subtypes with a stable contract. A
TcpConnectionreally is aConnection, and the Liskov substitution principle holds. - Framework extension points. A framework that owns an algorithm and asks you to fill in one step (the Template Method shape) is designed for subclassing; its authors promise which hooks exist.
- Closed families of data. A sealed set of variants such as
Circle | Squarein an algebraic data type, where the compiler checks exhaustiveness.
Implementing an interface is not the problem. Inheriting implementation from a concrete class you do not own is.
Reviewing agent-written code
Agents mirror the codebase they see, and a codebase with a deep hierarchy gets another level. Look for subclasses whose names combine features (CachedRetryingClient, AdminReadOnlyUser), subclasses that override a method only to call super() and add one line, and isinstance checks against feature subclasses. Steer toward a part with an interface, attached at construction or at runtime, and a test that attaches the parts in more than one combination.
The same shape elsewhere
Composing features from parts is the answer to combinatorial explosion wherever it shows up. Game engines such as Unity attach components (renderer, collider, script) to game objects instead of subclassing them. Linux builds a network path from netfilter hooks and qdiscs chained per interface. Kubernetes pods compose containers (an app plus a sidecar proxy) rather than baking the proxy into each image. Unix pipelines compose small programs. In each case independent features combine at deployment or runtime, not in a class declaration.
Down the stack
Layer 1The contract
A composed object promises what its own interface says and nothing about its parts' internals. Each part promises its own interface contract, for example that absorb(amount) returns a value between 0 and some bound and never makes health negative. The whole promises a defined order for consulting its parts, because order changes results: a shield before flat armour absorbs differently from armour before a shield. Attaching, detaching and duplicate handling are part of the contract too, and should fail loudly, not silently replace a part.
Layer 2The mechanism
In Go, type Logged struct { Store; count int } embeds an interface value; the compiler generates wrapper methods on Logged that load the embedded interface and call through it. A method Logged.Put that you write takes precedence, and calls from inside the inner store go to the inner store's methods, never to Logged's. Kotlin's by emits real forwarding methods into the class file, one per interface method. Python looks up obj.size through the type's MRO and the instance dictionary, and only on failure calls __getattr__, so dynamic forwarding pays for a failed lookup on every call. Rust has no implementation inheritance at all: a struct holds other structs and implements traits, and default trait methods are the only shared implementation.
Game engines take composition furthest. An entity-component-system stores each component type in its own dense array, and an entity is just an index. Systems iterate the arrays of the components they need. Bevy, for example, stores components in tables keyed by the set of component types an entity has.
Layer 3The machine
Composition by reference costs memory and a pointer chase. In Java every part is a separate heap object with a 12 to 16 byte header plus a 4 or 8 byte reference in the owner. If the part is not in cache, following the reference is a main memory access of about 80 to 100 ns, against about 1 ns for an L1 hit. Go can embed a struct by value, which lays its fields inline in the owner with no header and no pointer, so composition by value costs nothing in memory over inheritance.
Dispatch is cheap where it is predictable. A forwarding call through a Go interface is an indirect call through the itab, about 1 to 2 ns when predicted. HotSpot inlines a forwarding method at a monomorphic call site, so the wrapper often disappears after JIT. In CPython, each extra hop costs a method lookup and a frame, tens of nanoseconds, and a __getattr__ forward costs more. At a few calls per request none of this matters. In an inner loop over a million objects it does, which is why game engines store components in contiguous arrays: iterating 64-byte cache lines of packed health values is many times faster than following a pointer from each unit object.
Why it's built this way
- Features vary independently, and declaring a class per combination grows exponentially.
- Subclasses depend on their parent's internal self-calls, so parent changes break them; depending only on a public interface avoids that.
- Some behaviour must change after construction (a spell, a feature flag), and only a held reference can be swapped at runtime.
- Forwarding has a code cost, so languages added embedding and
bydelegation to generate it. - Separate part objects cost pointers and cache misses, so performance-critical systems compose by value or store parts in dense arrays.
Diagram
sequenceDiagram participant G as Game participant U as Unit participant S as Shield (order 10) participant A as Armor (order 20) participant H as Health G->>U: hit(50) U->>S: absorb(50) S-->>U: 20 left (shield 30 to 0) U->>A: absorb(20) A-->>U: 15 left (reduction 5) U->>H: take(15) H-->>U: hp 100 to 85 U-->>G: 15 dealt
Worked example
A mobile strategy game had 46 unit classes, most of them combinations: ArmoredHealer, ShieldedArcher, FlyingShieldedArcher. A designer's request for "any unit can be frozen for two turns" was estimated at three weeks because freezing touched take_damage and move in 19 classes that overrode them differently.
The rewrite gave every unit a Health, a movement part and a list of modifiers ordered by an integer priority. The 46 classes became 9 base unit types plus 14 modifier classes. Freeze became one modifier with an order of 0 that zeroes movement and has a turn counter; it shipped in two days. Balance designers began tuning combinations in data files, since attaching parts needed no code.
Performance was checked on the worst case, a 400-unit battle. Each hit walks about three modifiers through an interface; at 2,000 hits per turn that is 6,000 indirect calls, roughly 10 microseconds per turn on a mid-range phone, against a 16 ms frame budget. The one regression was memory churn: each spell allocated a new shield object, and the garbage collector paused for 4 ms during large battles. Pooling shield objects per unit removed the pauses.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Implementation inheritance | Little code; natural for true subtypes | Fragile to parent changes; fixed at compile time; combination explosion | Stable is-a hierarchies and framework hooks |
| Composition with explicit forwarding | Black-box reuse; parts swappable at runtime | Boilerplate; self problem | Most application code |
Language-assisted delegation (Go embedding, Kotlin by) | Forwarding generated; no open recursion | Promoted methods can surprise readers | Wrappers over wide interfaces |
| Mixins or traits with implementation | Reuse across unrelated classes | Ordering and name clashes (MRO) | Small orthogonal helpers |
| Data-oriented components (ECS) | Cache-friendly; trivially combinable | Logic spread across systems; harder to follow | Simulations and games with many entities |
Pitfalls in production
- Swapping inheritance for composition but leaving
isinstancechecks against concrete parts in callers. Fix: add the needed capability to the part's interface. - Order of parts left implicit (insertion order of a dictionary), so a refactor changes results. Fix: give parts an explicit order and test combinations.
- Attaching the same kind of part twice by accident, doubling an effect. Fix: reject duplicates by kind, or make stacking an explicit rule.
- The self problem in callback-heavy code, so wrapper behaviour is skipped for events. Fix: register the outer object, or route callbacks through the whole.
- Dynamic forwarding (
__getattr__, JavaScriptProxy) hiding the real interface from type checkers and IDEs. Fix: forward explicitly, or declare the interface the wrapper implements. - Allocating new part objects in hot loops. Fix: reuse parts, embed by value, or store them in arrays.
Leadership lens
- When a backlog item is estimated in weeks because it touches many subclasses, the hierarchy is the cost. Budget the move to composed parts as the enabler for that item, with the next three feature requests as the payoff.
- Ask in design review: "is this a kind of thing, or a feature some things have?" It settles most inheritance debates in one sentence.
- Composition moves combinations into data and configuration. That shifts work from engineers to designers or operators, which is usually the goal, but it needs validation and tests on the data.
- Agents extend whatever hierarchy they find. A short rule in the repo's agent instructions ("new behaviour is a part, not a subclass") prevents the next three levels.
Check yourself
Why can a Go type that embeds another not suffer the fragile base class problem?
Show answer
The embedded value's own method calls always go to its own methods, never to the outer type's, so the outer type depends only on the inner type's public behaviour.
Three independent features are each optional. How many classes does inheritance need to cover every combination, and how many parts does composition need?
Show answer
Up to eight classes (two to the power of three) against three parts that can be attached in any mix.
A wrapper adds audit logging, but events delivered to the wrapped object are not audited. What happened?
Show answer
The self problem: the inner object registered itself, not the wrapper, so callbacks bypass the wrapper. Register the outer object or route events through it.
When is subclassing the better choice?
Show answer
For true subtypes that honour the parent's contract, and for framework hooks designed to be overridden, where the parent's authors promise which methods call which.
Watch
- The Flaws of Inheritance (CodeAesthetic): a short, concrete walk from an inheritance tree to composed interfaces.
- Nothing is Something (Sandi Metz, RailsConf 2015): how injecting small role objects replaces subclass explosions.
- Composition over Inheritance (Fun Fun Function): the "is a" versus "has a" framing with a playful JavaScript example.
Free resources
- The Composition Over Inheritance Principle (Brandon Rhodes): the subclass explosion problem and four ways out, in Python.
- Component (Game Programming Patterns): Robert Nystrom's chapter on building game entities from parts.
- Embedding (Effective Go): how Go promotes methods without inheritance.
- Delegation (Kotlin docs): compiler-generated forwarding with
by, and what overriding does and does not affect. - Characteristics of Object-Oriented Languages (The Rust Book): why Rust offers traits and composition but no implementation inheritance.
- Fragile base class (Wikipedia): background on the problem composition avoids.