---
title: 'Composition Over Inheritance'
source: 'https://academia.sh/en/courses/paradigms/composition-over-inheritance'
course: 'Programming Paradigms'
language: en
updated: '2026-08-23T07:01:20+00:00'
license: 'CC BY-SA 4.0'
---

# Composition Over Inheritance

Measuring the fragile base class problem: an implementation change in the superclass that does not change its own behavior silently breaking the subclass's counter in 200 out of 200 objects, the same change producing no deviation in the composed version, and counting composition's forwarding cost.

The previous lesson showed that shared behavior can be shared two ways: through inheritance
and through functions that take the object as an argument. The measurement at that point was
the number of repeated lines. The same choice has one more consequence, and it shows up when
the superclass changes something within itself. A subclass can depend not only on what the
superclass **says to the outside**, but also on what it does inside.

The **fragile base class problem** is an implementation change made in the superclass, one
that does not change the superclass's own behavior, breaking the behavior of its subclasses.
This lesson measures the problem, then tries the same change on a composed version.

## Consignment and Two Wrapping Paths

The superclass is a simplified version of the consignment class from the encapsulation
lesson: it accepts shipments that do not exceed capacity and rejects the ones that do. The
`addAll` method hands a batch to the `add` method one item at a time.

```js
// consignment.mjs — superclass, first version: addAll hands each item to its own add method
export class Consignment {
  #shipments = []; #total = 0; #capacity;

  constructor(capacityGrams) { this.#capacity = capacityGrams; }

  add(shipment) {
    if (this.#total + shipment.weightGrams > this.#capacity) return false;
    this.#shipments.push(shipment);
    this.#total += shipment.weightGrams;
    return true;
  }

  addAll(shipments) { for (const s of shipments) this.add(s); }

  state() { return { count: this.#shipments.length, total: this.#total }; }
}
```

A new need arises: counting the accepted shipments. The first answer is inheritance. The
subclass meets the `add` method with its own implementation, calls the superclass version,
and counts the acceptances.

```js
// counting-inheritance.mjs — wrapping through inheritance: subclass overrides add with its own version
import { Consignment } from "./consignment.mjs";

export class CountingConsignment extends Consignment {
  #count = 0;
  get count() { return this.#count; }
  add(shipment) {
    const accepted = super.add(shipment);
    if (accepted) this.#count += 1;
    return accepted;
  }
}
```

The second answer is composition. The wrapper holds the consignment inside, offers the same
methods to the outside, and writes the `addAll` method through its own `add` method.

```js
// counting-composition.mjs — wrapping through composition: consignment is held inside, methods are forwarded
export class CountingWrapper {
  #inner; #count = 0;

  constructor(consignment) { this.#inner = consignment; }

  get count() { return this.#count; }

  add(shipment) {
    const accepted = this.#inner.add(shipment);
    if (accepted) this.#count += 1;
    return accepted;
  }

  addAll(shipments) { for (const s of shipments) this.add(s); }

  state() { return this.#inner.state(); }
}
```

The two versions cannot be told apart from the outside. The run generates 200 batches with a
fixed-seed generator, hands each batch to both wrappers, and compares the counters against a
value computed independently as the correct one.

```js
// run.mjs — applies the same batch sequence to both wrapping paths and verifies the counters
import { Consignment } from "./consignment.mjs";
import { CountingConsignment } from "./counting-inheritance.mjs";
import { CountingWrapper } from "./counting-composition.mjs";

const generator = (seed) => {
  let x = seed;
  return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
};

const OBJECTS = 200;
const CAPACITY = 20000;

const batches = () => {
  const rand = generator(20250729);
  return Array.from({ length: OBJECTS }, () =>
    Array.from({ length: 12 }, (_, i) => ({ id: i, weightGrams: 500 + Math.floor(rand() * 3500) })));
};

const correctCount = (batch) => {
  let total = 0, count = 0;
  for (const s of batch) if (total + s.weightGrams <= CAPACITY) { total += s.weightGrams; count += 1; }
  return count;
};

let inheritanceWrong = 0, compositionWrong = 0;
const data = batches();
for (const batch of data) {
  const correct = correctCount(batch);
  const k = new CountingConsignment(CAPACITY); k.addAll(batch);
  const c = new CountingWrapper(new Consignment(CAPACITY)); c.addAll(batch);
  if (k.count !== correct) inheritanceWrong += 1;
  if (c.count !== correct) compositionWrong += 1;
}

const plain = new Consignment(CAPACITY);
plain.addAll(data[0]);
const d = plain.state();
console.log(`the superclass's own behavior = count ${d.count}, total ${d.total}`);
console.log(`inheritance counter wrong  = ${inheritanceWrong}/${OBJECTS}`);
console.log(`composition counter wrong  = ${compositionWrong}/${OBJECTS}`);
const hasNew = (n) => (typeof n.seal === "function" ? "yes" : "no");
console.log(`new superclass method      = inheritance ${hasNew(new CountingConsignment(CAPACITY))},` +
  ` composition ${hasNew(new CountingWrapper(new Consignment(CAPACITY)))}`);
```

```sh
node run.mjs
```

```
the superclass's own behavior = count 9, total 18566
inheritance counter wrong  = 0/200
composition counter wrong  = 0/200
new superclass method      = inheritance no, composition no
```

Both paths count correctly. At this point there is no visible reason to choose between them.

## A Change in the Superclass

The party maintaining the superclass rewrites the `addAll` method: instead of making a
method call for every item, it runs the loop internally. The same version also adds a new
method — `seal`, which fixes the consignment's capacity at its current total.

```js
// consignment-v2.mjs — second version of the superclass: addAll runs its own loop internally
export class Consignment {
  #shipments = []; #total = 0; #capacity;

  constructor(capacityGrams) { this.#capacity = capacityGrams; }

  add(shipment) {
    if (this.#total + shipment.weightGrams > this.#capacity) return false;
    this.#shipments.push(shipment);
    this.#total += shipment.weightGrams;
    return true;
  }

  addAll(shipments) {
    for (const s of shipments) {
      if (this.#total + s.weightGrams > this.#capacity) continue;
      this.#shipments.push(s);
      this.#total += s.weightGrams;
    }
  }

  seal() { this.#capacity = this.#total; }

  state() { return { count: this.#shipments.length, total: this.#total }; }
}
```

The change preserves the superclass's **own** behavior: the same shipments are accepted, the
same shipments are rejected, the same total comes out. The same run is repeated with the new
version.

```sh
cp consignment-v2.mjs consignment.mjs
node run.mjs
```

```
the superclass's own behavior = count 9, total 18566
inheritance counter wrong  = 200/200
composition counter wrong  = 0/200
new superclass method      = inheritance yes, composition no
```

The superclass's own behavior is identical in the first line: 9 shipments, 18566 grams.
Every test written for the superclass would have passed in both versions. The subclass's
counter, though, turned wrong in 200 out of 200 objects. No exception was thrown, no warning
appeared; the counter stayed at zero.

## Source of the Deviation

The subclass's counter depended on the superclass's `addAll` method calling `this.add`
internally. That call was not part of the superclass's documented contract; it was an
implementation detail. The subclass built a dependency on that detail without being aware of
it.

The real observation here is that inheritance turns the superclass's **internal call
pattern** into part of the contract as well. What a superclass guarantees to its subclasses
is not only "which methods exist, what they return"; "which method calls which" enters the
guarantee too. This second part is almost never written down, and because it is not written
down, it is not preserved.

The composed wrapper never built this dependency. Its `addAll` method called its own `add`
method; it never used the inner object's `addAll` method. The only thing it wanted from the
inner object was the behavior of the `add` and `state` methods it called explicitly — that
is, the documented contract. This is why it was unaffected when the superclass's internal
arrangement changed.

## The Cost of Composition

The last line shows the cost of composition. The `seal` method added in the new version
appeared automatically in the inherited version, and did not appear in the composed version.
The wrapper gets none of the inner object's new capabilities for free.

The second side of the cost is forwarding code. The wrapper wrote three methods by hand:
`add`, `addAll`, and `state`. If the inner class's interface grew to ten methods, the wrapper
would need ten forwarding methods written to present the same face outward. With
inheritance, this number is zero.

The two costs can be compared. Inheritance's cost is the silent deviation that can appear
every time the superclass changes and showed up as 200/200 in the measurement.
Composition's cost is the forwarding code that must be written every time the interface
grows, and it is visible immediately. The second is countable and predictable; the first is
neither countable nor predictable. This is the reason for the preference.

## Where Inheritance Stays Cheap

Inheritance is not expensive in every case. If the superclass and the subclass are
maintained by the same team on the same release, the subclasses can be reviewed when the
superclass's internal call pattern changes; the deviation does not stay silent. In the same
way, if the superclass is closed to change — a type with a fixed version and frozen behavior
— the ground for fragility disappears.

Fragility is born in situations where the superclass changes independently of its
subclasses: a different team, a different release line, a different repository. The
criterion is not the number of classes or the depth of the hierarchy, but **who changes what
and when**.

## Summary

- The fragile base class problem is an implementation change that breaks subclasses even
  though the superclass preserves its own behavior.
- In the measurement, the superclass's own behavior stayed identical across both versions (9
  shipments, 18566 grams); despite this, the inherited counter turned wrong in 200 out of
  200 objects.
- The same change produced 0 deviations in the composed wrapper; the wrapper depended on the
  inner object only for the behavior of the methods it called explicitly.
- Inheritance makes the superclass's internal call pattern part of the contract as well;
  because this part is almost never written down, it is not preserved.
- Composition's cost is forwarding code, and it is countable: the wrapper here forwarded
  three methods; also, the new method added to the superclass did not appear automatically
  in the composed version.
- Inheritance stays cheap when the superclass and the subclass are maintained together, or
  when the superclass is closed to change; the criterion is who changes what and when.

## Next Step

This lesson measured how two objects connect to each other. The next question is what a
single object carries within itself. The consignment class held both the shipments and the
capacity rule; the rule lived in the same place as the data. Another common arrangement
separates them: data carries only fields, and rules are spread across separate processing
units. The next lesson compares the two arrangements and counts how many separate places a
rule is repeated in; then, when the rule is changed, it measures how many results diverge
between the two arrangements in each.
