Engineering Atlas › Low-Level Design

Why composition beats inheritance

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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:

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

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

  1. Features vary independently, and declaring a class per combination grows exponentially.
  2. Subclasses depend on their parent's internal self-calls, so parent changes break them; depending only on a public interface avoids that.
  3. Some behaviour must change after construction (a spell, a feature flag), and only a held reference can be swapped at runtime.
  4. Forwarding has a code cost, so languages added embedding and by delegation to generate it.
  5. 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

OptionStrengthsWeaknessesChoose when
Implementation inheritanceLittle code; natural for true subtypesFragile to parent changes; fixed at compile time; combination explosionStable is-a hierarchies and framework hooks
Composition with explicit forwardingBlack-box reuse; parts swappable at runtimeBoilerplate; self problemMost application code
Language-assisted delegation (Go embedding, Kotlin by)Forwarding generated; no open recursionPromoted methods can surprise readersWrappers over wide interfaces
Mixins or traits with implementationReuse across unrelated classesOrdering and name clashes (MRO)Small orthogonal helpers
Data-oriented components (ECS)Cache-friendly; trivially combinableLogic spread across systems; harder to followSimulations and games with many entities

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →