Skip to content
academia.sh

Lesson 07 / 14

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.

Contents

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.

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

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

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

// 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)))}`);
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.

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

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close