50%

الدرس 1 من 2

Composition Over Inheritance

Inheritance couples a subclass to the internals of its parent, forever and invisibly. Composition costs a little more typing and keeps the coupling explicit and local.

قراءة 11 دقيقة

Inheritance is the most tempting reuse mechanism a language offers: one keyword and you have all of the parent's behaviour. The advice to prefer composition is not about taste. It is about the fact that inheritance creates the strongest form of coupling available — a subclass depends on how its parent is implemented, not merely on what it does — and that this coupling is invisible at the call site.

The fragile base class

The classic failure is worth seeing concretely, because it explains why the coupling is different in kind from an ordinary dependency. A subclass can override one method and thereby change the behaviour of another method it never touched — because the parent called the overridden method internally. The author of the parent has no way to know, and a change that looks entirely internal to them breaks code they have never seen.

A refactor inside the base class silently doubles every count in the subclass.typescript
class Collection<T> {
  private items: T[] = [];
  private count = 0;

  add(item: T): void {
    this.items.push(item);
    this.count += 1;
  }

  addAll(items: readonly T[]): void {
    // Version 1: does not call add().
    for (const item of items) {
      this.items.push(item);
      this.count += 1;
    }
  }
}

class CountingCollection<T> extends Collection<T> {
  added = 0;
  override add(item: T): void {
    this.added += 1;      // correct in version 1
    super.add(item);
  }
  override addAll(items: readonly T[]): void {
    this.added += items.length;
    super.addAll(items);
  }
}

// Version 2 of the base class: addAll() is simplified to loop over add().
// Nothing in the subclass changed. Every addAll() now counts twice, silently,
// and the base class author had no way to know the subclass existed.

Reusing behaviour: two mechanisms

Inheritance

The subclass extends a base class and receives its behaviour, its state and its internal call structure.

  • Very little code to write for the common case
  • Genuine subtyping when the relationship really is "is a"
  • The compiler enforces the shared surface
  • Depends on the parent's implementation, not only its interface
  • One hierarchy has to serve every axis of variation at once
  • Behaviour is assembled at compile time and cannot vary per instance

Choose when: The subtype must be substitutable for the supertype in every context, and the hierarchy is shallow, closed and owned by one team.

Composition

The object holds collaborators and delegates to them. Behaviour is assembled by whoever constructs it.

  • Coupling is limited to the collaborator's public interface
  • Independent axes of variation can be combined freely
  • Behaviour can be swapped per instance, including in tests
  • More explicit wiring, and delegating methods to write
  • Requires a decision about who constructs the object
  • Easy to end up with many small collaborators to trace through

Choose when: You want the behaviour rather than the identity — which is the majority of cases, and always the case when two axes vary independently.

The "independent axes" point is where inheritance fails most visibly. Suppose notifications vary by channel (email, SMS, push) and by audience (customer, internal). With inheritance you must pick one axis for the hierarchy and handle the other some other way; add a third axis, such as locale-specific formatting, and the hierarchy either explodes combinatorially or collapses into a base class full of flags. With composition each axis is a separate collaborator and the combinations are assembled at construction.

Three axes composed rather than inherited. Any combination is a construction, not a new class.typescript
interface Transport { deliver(to: Address, body: string): Promise<void>; }
interface Renderer { render(event: DomainEvent, locale: Locale): string; }
interface Audience { addressFor(event: DomainEvent): Promise<Address>; }

class Notifier {
  constructor(
    private readonly transport: Transport,
    private readonly renderer: Renderer,
    private readonly audience: Audience,
  ) {}

  async notify(event: DomainEvent, locale: Locale): Promise<void> {
    const to = await this.audience.addressFor(event);
    await this.transport.deliver(to, this.renderer.render(event, locale));
  }
}

// 3 transports x 2 audiences x 2 renderers = 12 behaviours, 7 classes,
// no hierarchy, and each part is testable on its own.

In practice

The report hierarchy that stopped scaling

A finance product generated reports through a class hierarchy: `Report` at the root, then `PdfReport` and `CsvReport`, then `MonthlyPdfReport`, `AuditPdfReport` and so on. After two years there were 23 classes, four levels deep, and adding a new combination meant creating three of them.

Constraints

  • Three axes vary: format, period and audience
  • Two teams add report types independently
  • Regulatory reports must be byte-identical between runs
  • No appetite for a big-bang rewrite

Decision

Introduce a `Report` value describing what to produce, and three composed collaborators — a data source, a period selector and a formatter. Migrate new report types to the composed path immediately and existing ones opportunistically; the hierarchy was deleted eleven months later.

Why

The hierarchy had one dimension to spend and three axes to represent, so the class count was multiplying and shared behaviour had drifted into the root class as conditionals. Composition let each axis vary independently and made a new report type a configuration rather than three subclasses.

What it cost

For nearly a year both mechanisms existed, which is confusing for new joiners and required a rule about which to use for new work. The team also lost some compile-time guarantees: with the hierarchy, an invalid combination could not be named, whereas with composition it can be constructed and must be rejected by a validation step. They accepted that and added a factory that only permits the approved combinations.

A team proposes an abstract `BaseController` that every HTTP controller extends, providing authentication, logging and error translation. What is your assessment?Reveal

This is the shape that looks convenient and reliably becomes a bottleneck. Every controller now depends on the internals of one class, so changing how errors are translated risks every endpoint at once, and the base class will accumulate flags as controllers need slightly different behaviour. There is also a subtler problem: inheritance means the shared behaviour cannot be tested independently of a controller, and cannot be applied to any caller that is not a controller — the nightly job and the internal tool from the previous module. The composed alternative is to pass these concerns in, or to apply them at a chokepoint every path crosses. Reserve the base class for the case where controllers genuinely must be substitutable for one another, which they almost never are.

Key takeaways

  • Inheritance couples a subclass to the parent's implementation, including which internal methods call which.
  • A base class that calls its own overridable methods has made that call structure part of its contract.
  • Use inheritance for genuine substitutability; use composition when you want the behaviour.
  • Independent axes of variation are the clearest signal to compose: hierarchies can only spend one dimension.
  • Composition costs wiring and indirection, so it should be justified by a varying axis or a test seam, not applied everywhere.
Composition Over Inheritance · Architecture Atlas