A Philosophy of Software DesignCompositionLesson 8 of 13

Composition Over Inheritance

Inheritance is the most overused feature in object-oriented programming. It was designed as an implementation mechanism, not a modeling tool. But generations of programmers learned to think of it as a way to model the world: "a dog is an animal," "a car is a vehicle." This mental model creates fragile, tightly-coupled code.

Composition is the alternative. Instead of building complex things by extending simpler things, you build complex things by combining simpler things. The difference is profound.

Why Inheritance Creates Coupling

Inheritance creates the tightest coupling possible between two pieces of code. The subclass depends on the superclass's implementation, not just its interface. Change the superclass, and every subclass might break.

The Fragile Base Class Problem

// Base class
class Collection<T> {
  protected items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  addAll(items: T[]): void {
    for (const item of items) {
      this.add(item);
    }
  }
}

// Subclass: counts items added
class CountingCollection<T> extends Collection<T> {
  count = 0;

  add(item: T): void {
    this.count++;
    super.add(item);
  }
}

const c = new CountingCollection<string>();
c.addAll(["a", "b", "c"]);
console.log(c.count); // 3? or 6? or 0?

The answer depends on whether addAll calls this.add internally. If it does, count increments both in addAll's loop and in add. If it does not, count only increments when add is called directly. The subclass must know the base class's implementation to work correctly. This is the fragile base class problem: the subclass is coupled to implementation details that can change.

The Diamond Problem

What happens when a class inherits from two parents that define the same method? Most languages either forbid it (Java) or use complex resolution rules (Python's MRO). The problem exists because inheritance conflates two things: sharing implementation and defining interfaces.

The Yo-Yo Problem

Deep inheritance hierarchies force you to read up and down the chain to understand any single class.

// Reading Button requires understanding 4 classes
class EventEmitter { /* ... */ }
class UIComponent extends EventEmitter { /* ... */ }
class InteractiveElement extends UIComponent { /* ... */ }
class Button extends InteractiveElement { /* ... */ }

// To understand Button.handleClick(), you might need to read
// EventEmitter.emit(), UIComponent.render(),
// InteractiveElement.onFocus(), and Button.handleClick().
// Your eyes bounce up and down the hierarchy like a yo-yo.

Inheritance Is Not a Modeling Tool

"A penguin is a bird" seems reasonable. But in code:

class Bird {
  fly(): void {
    console.log("Flying!");
  }
}

class Penguin extends Bird {
  fly(): void {
    throw new Error("Penguins can't fly!");
  }
}

A Penguin is a Bird that violates Bird's contract. This is the Liskov Substitution Principle violation: you cannot substitute a Penguin wherever a Bird is expected because calling fly() throws instead of flying.

The problem is using "is-a" relationships to model the world. The world has behaviors that do not follow inheritance hierarchies. Penguins are birds. Bats fly but are not birds. Ostriches are birds but do not fly. Flying is a behavior, not a position in a taxonomy.

Composition: Small Things That Plug Together

Composition builds complex objects by combining simple, independent pieces. Each piece does one thing. The pieces do not know about each other.

// Behaviors as independent types
interface Behavior {
  readonly name: string;
  perform(): string;
}

const canFly: Behavior = {
  name: "fly",
  perform: () => "soaring through the air",
};

const canSwim: Behavior = {
  name: "swim",
  perform: () => "gliding through the water",
};

const canWalk: Behavior = {
  name: "walk",
  perform: () => "walking on land",
};

const canDive: Behavior = {
  name: "dive",
  perform: () => "diving deep underwater",
};

Now animals are composed of behaviors:

interface Animal {
  readonly name: string;
  readonly species: string;
  readonly behaviors: readonly Behavior[];
}

function createAnimal(
  name: string,
  species: string,
  behaviors: readonly Behavior[]
): Animal {
  return { name, species, behaviors };
}

const eagle = createAnimal("Eagle", "Aquila chrysaetos", [canFly, canWalk]);
const penguin = createAnimal("Penguin", "Aptenodytes forsteri", [canSwim, canWalk, canDive]);
const bat = createAnimal("Bat", "Chiroptera", [canFly]);
const duck = createAnimal("Duck", "Anas platyrhynchos", [canFly, canSwim, canWalk]);

A penguin can swim and walk. A duck can fly, swim, and walk. No inheritance hierarchy needed. No Bird class that promises all birds can fly. Each animal is a composition of the behaviors it actually has.

function canPerform(animal: Animal, behaviorName: string): boolean {
  return animal.behaviors.some(b => b.name === behaviorName);
}

function performAll(animal: Animal): string[] {
  return animal.behaviors.map(b => `${animal.name} is ${b.perform()}`);
}

console.log(canPerform(penguin, "fly"));   // false
console.log(canPerform(penguin, "swim"));  // true
console.log(performAll(duck));
// ["Duck is soaring through the air", "Duck is gliding through the water", "Duck is walking on land"]

Interfaces as Contracts

TypeScript interfaces define what something can do without dictating how. They are contracts, not implementation.

// Interface: what can it do?
interface Serializable {
  serialize(): string;
}

interface Loggable {
  toLogEntry(): string;
}

interface Validatable {
  validate(): Result<void, string[]>;
}

// A type can satisfy multiple interfaces
interface UserRecord extends Serializable, Loggable, Validatable {
  readonly id: string;
  readonly name: string;
  readonly email: string;
}

Interfaces are composition at the type level. A UserRecord is not a subclass of Serializable. It is a type that satisfies the Serializable contract. The difference matters: there is no coupling to a base class implementation.

Dependency Injection Without Frameworks

Dependency injection is just passing dependencies as arguments instead of creating them internally. You do not need a framework for this.

// Tightly coupled: creates its own dependencies
class OrderProcessor {
  private db = new PostgresDatabase();
  private mailer = new SmtpMailer();

  async process(order: Order): Promise<void> {
    await this.db.save(order);
    await this.mailer.send(order.customer.email, "Order confirmed");
  }
}
// Cannot test without a real database and mail server.
// Cannot swap PostgresDatabase for SQLite.
// Cannot reuse with a different mailer.

// Composed: dependencies injected
interface Database {
  save(record: Record<string, unknown>): Promise<void>;
}

interface Mailer {
  send(to: string, body: string): Promise<void>;
}

function createOrderProcessor(db: Database, mailer: Mailer) {
  return {
    async process(order: Order): Promise<void> {
      await db.save(order as unknown as Record<string, unknown>);
      await mailer.send(order.customer.email, "Order confirmed");
    },
  };
}

// Production:
const processor = createOrderProcessor(postgresDb, smtpMailer);

// Test:
const testProcessor = createOrderProcessor(inMemoryDb, mockMailer);

The createOrderProcessor function does not know or care what database or mailer it gets. It works with any implementation that satisfies the interface. This is composition: small, independent pieces plugged together.

When Inheritance Is Appropriate

Inheritance has legitimate uses. They are narrow:

  1. Framework extension points: When a framework requires you to extend a base class (React's Component before hooks, Express middleware patterns).
  2. Abstract base classes with substantial shared implementation: When subclasses genuinely share complex behavior, not just one or two trivial methods.
  3. Template method pattern: When the algorithm skeleton is fixed but steps vary. Even here, composition of functions is often cleaner.

The test: if you can replace inheritance with an interface and a factory function without duplicating significant code, use composition.

Check Your Understanding

What is the fragile base class problem?

Not quite. The correct answer is highlighted.
Instead of building complex things by extending simpler things, builds them by combining simpler things.
Not quite.Expected: composition

Why does 'a penguin is a bird' fail as an inheritance model?

Not quite. The correct answer is highlighted.

What is dependency injection without a framework?

Not quite. The correct answer is highlighted.

Practice

Refactor an inheritance hierarchy into composed behaviors:

Summary

  • Inheritance creates tight coupling: fragile base class, diamond problem, yo-yo problem.
  • "Is-a" modeling fails when behaviors do not follow taxonomies (penguins, bats, ostriches).
  • Composition builds complex objects from independent, combinable pieces.
  • Interfaces as contracts define what something can do without dictating implementation.
  • Dependency injection is just passing arguments. No framework needed.
  • Inheritance is rarely the right choice. Use it for framework extension points and substantial shared implementation. For everything else, compose.

Next, we explore the building blocks of composition: pure functions and immutability.