Composition Over Inheritance

A one-line change to a base class took down password resets for four hours. I was on call, and the diff that caused it was, honestly, correct.

Someone had added retry-with-backoff to our Notifier base class so that email sends would survive a flaky SMTP provider. Sensible change. Good change. The problem is that every notifier in the system extended Notifier, including the one that sends SMS two-factor codes. When the SMS provider had a bad afternoon, that inherited retry logic kicked in, queued, backed off, and retried, and the codes arrived three, five, ten minutes late. By then they had expired. Password reset was dead for everyone.

The person who edited the base class had never heard of the 2FA path. They did not need to. That is the whole problem with a base class in one sentence: you inherit its future, not just its present. Every subclass signed a contract to receive whatever the parent grows next, and nobody read it.

This lesson is about the principle that would have prevented that outage, and it is the most useful idea in this entire part. A lot of the patterns you will meet later (strategy, decorator, dependency injection) exist because inheritance gets rigid, and composition is what they reach for instead.

How the tree got tall

The notifier hierarchy started completely reasonable. One base, a few channels:

class Notifier {
  async send(message) { /* subclasses fill this in */ }
}

class EmailNotifier extends Notifier {
  async send(message) { return smtp.send(message); }
}

class SmsNotifier extends Notifier {
  async send(message) { return twilio.send(message); }
}

Nothing wrong here. EmailNotifier genuinely is a Notifier. Shallow, stable, honest. If the story had stopped there, inheritance would have been the right tool and this article would be shorter.

It never stops there. The requirements arrive one ticket at a time, and none of them respect your tree:

  • Marketing email should retry on failure.
  • Marketing email should be rate-limited so we do not get flagged as spam.
  • Digest email should batch overnight and send one summary at 7am.
  • 2FA SMS must send immediately, once, and never batch or delay.

Where does retry live? If you put it on the base class, every channel inherits it, including the one channel that must never have it. If you push it down into EmailNotifier, then SMS marketing blasts (which also want retry) cannot reach it. So you start growing the base class with flags, send(message, { retry, throttle, batch }), and now the base knows about concerns that only apply to half its children. That is the fragile base class: a parent so loaded with behaviour that touching it can break any descendant, and you cannot tell which without reading all of them.

class Notifiersend retry throttle batchedit retry() onceone change ripples to every subclassEmailNotifierwants retry, throttle, batchinherits all fourhappens to be fine, this timeSmsNotifier (2FA)wants send onlyforced to inherit batch2FA code arrives late
One base class carrying every behaviour. Editing retry() ripples to every subclass at once, and SmsNotifier is forced to inherit batch() it must never use.

The other road is worse. Instead of a bloated base, you make a class per combination: RetryingEmailNotifier, ThrottledRetryingEmailNotifier, BatchedEmailNotifier. Four independent behaviours across two channels is already a mess, and every new behaviour doubles the tree. This is the combinatorial explosion, and it is where inheritance goes to die.

is-a versus has-a

Step back from the code. Inheritance answers one question, and it is a question about identity: is this thing a kind of that thing? A SavingsAccount is a BankAccount. A Circle is a Shape. When the answer is a clean, permanent yes, extends fits.

Composition answers a different question, and it is about capability: what can this thing do? A duck can swim. A duck can quack. It has those behaviours, and it did not have to become a subclass of Swimmer and Quacker to get them. That distinction, is-a against has-a, is the entire lesson.

is-a · inheritanceAnimalBirdfly()Duckis ais aPenguin is-a Bird, so it inherits fly()but penguins cannot fly. the tree lied.has-a · compositionDucka plain objectwalk()swim()quack()a penguin just omits fly. no lie.
The same duck two ways. On the left it inherits behaviour by being a kind of Bird; on the right it holds behaviour as capabilities it was given. Only one of them survives a penguin.

The bird taxonomy is the classic because it breaks so cleanly. You build Animal, then Bird with a fly() method, because obviously birds fly. Then someone adds a penguin. A penguin is a bird by every biological measure, so it goes under Bird, and now it has a fly() method that is a lie. You override it to throw, or to do nothing, and either way you have a subclass fighting the thing it inherited from. The taxonomy you built so carefully now has an exception carved into it, and exceptions in a type hierarchy are the cracks that let bugs in.

Composition never has this problem, because nothing is forced. A duck is an object you gave walk, swim, and quack to. A penguin is an object you gave walk and swim to. You did not give it fly, so it does not have fly, and there is nothing to override and nothing to lie about. The behaviour a thing has is the behaviour you handed it, full stop.

Behaviour is just functions and objects

Here is what makes this principle land so hard in JavaScript specifically. In a language where the only unit of reuse is the class, composing behaviour takes real machinery: interfaces, delegation objects, a pile of boilerplate. In JavaScript, a behaviour is a function, or an object with some methods, and you already know four different ways to combine those. Composition is not a pattern you import here. It is Tuesday.

The plainest form is object composition. Build the small pieces, then assemble the thing that needs them:

const walker = {
  walk() { return `${this.name} waddles along`; },
};

const swimmer = {
  swim() { return `${this.name} paddles across the pond`; },
};

const quacker = {
  quack() { return `${this.name} says quack`; },
};

Each of those is a bundle of behaviour with no opinion about who uses it. Now a duck takes all three, and a penguin takes exactly the two it should:

const duck = Object.assign({ name: "Daffy" }, walker, swimmer, quacker);
const penguin = Object.assign({ name: "Pingu" }, walker, swimmer);

duck.quack(); // "Daffy says quack"
penguin.swim(); // "Pingu paddles across the pond"
penguin.quack; // undefined, never given it, cannot misuse it

No base class. No tree. Each object got precisely the capabilities it needs and not one method more. Add a flyer bundle later and only the things that should fly ever receive it. The penguin problem cannot occur, because there is no inherited method to contradict.

an object gets exactly the capabilities you give it, and no morewalkswimquackassignduckwalk · swim · quackwalkswimflyleft behindassignpenguinwalk · swim (no fly, by omission)no override, no throw, no exception carved into a tree
Compose an object from behaviour pieces and it holds exactly what you assigned. The penguin simply is not given fly, so the impossible state never exists.

Mixins, and their sharp edge

That Object.assign move has a name. When you copy a bundle of methods onto an object (or onto a class prototype), you are applying a mixin. The bundles above, walker and swimmer, are mixins, and Object.assign is the whole mechanism. It shallow-copies each source’s own enumerable properties onto the target, left to right.

You can mix into a prototype too, so every instance of a class picks up the methods without that class extending anything:

class Duck {
  constructor(name) { this.name = name; }
}

Object.assign(Duck.prototype, walker, swimmer, quacker);

new Duck("Daffy").quack(); // "Daffy says quack"

Duck did not extend a Bird. It reached into a shelf of behaviours and took the three it wanted. If this reminds you of the module pattern, good: both are about assembling a public surface from independent parts instead of inheriting one wholesale.

walkerwalk()swimmerswim()quackerquack()Object.assign(target,walker, swimmer, quacker)duckname: Daffywalk() swim() quack()all three copied inshallow copy · own enumerable methods · last write wins on a clash
Object.assign copies each mixin's own methods onto the target, left to right. The duck ends up holding walk, swim, and quack without extending anything.

There is a more flexible variant worth knowing, the functional mixin: a function that takes an object and returns it augmented. Because it is a function, it can close over configuration and decide what to add:

const canRetry = (obj, { tries = 3 } = {}) =>
  Object.assign(obj, {
    async withRetry(fn) {
      for (let i = 0; i < tries; i++) {
        try { return await fn(); } catch (err) { if (i === tries - 1) throw err; }
      }
    },
  });

const service = canRetry({ name: "sync" }, { tries: 5 });

Mixins are useful, but they have a genuinely sharp edge, and the JavaScript world learned it the hard way. React shipped a mixin system in its early days, leaned on it for years, and then publicly walked away from it in favour of composition, first higher-order components, then hooks. The reasons are worth memorising, because they apply to every mixin you will ever write:

That last point is why, when a mixin needs to seed per-object state, you clone it rather than share it. structuredClone gives you a deep copy for plain data (it will not copy functions or class instances, so it is for the data, not the methods). Most of the time the cleaner answer is to prefer function-based composition, which sidesteps the whole category.

Wrapping behaviour with functions

The most idiomatic composition in JavaScript is not mixing methods at all. It is wrapping a function in another function. Because functions are values, a function can take a behaviour and return a new behaviour that adds something around it. That is the composition answer to the notification outage that opened this lesson.

Recall the trap: retry belongs to some channels, batching to others, and inheritance could not slice it that finely. So stop modelling channels as classes in a tree. Model sending as a plain function, and model each cross-cutting concern as a wrapper:

// the raw sends: just functions, no class, no this
const sendSms = (msg) => twilio.send(msg);
const sendEmail = (msg) => smtp.send(msg);

// each concern is a wrapper: takes a send, returns a send
const withRetry = (send, tries = 3) => async (msg) => {
  for (let i = 0; i < tries; i++) {
    try { return await send(msg); } catch (err) { if (i === tries - 1) throw err; }
  }
};

const withThrottle = (send, limiter) => async (msg) => {
  await limiter.take();
  return send(msg);
};

Now you assemble each channel from exactly the pieces it should have, and the assembly is readable top to bottom:

// 2FA: bare send. no retry, no throttle, no batch. it cannot arrive late.
const sendOtp = sendSms;

// marketing email: retried and rate-limited, composed a la carte
const sendMarketing = withThrottle(withRetry(sendEmail, 3), rateLimiter);

The 2FA path is physically incapable of being delayed, because nobody wrapped it in anything that delays. The marketing path has precisely the two behaviours it was given. Add a new concern next quarter and it is a new wrapper that only the channels needing it opt into. The base-class outage cannot recur, because there is no base class whose future everyone inherits.

Those withRetry and withThrottle functions are the decorator pattern in its native JavaScript form, higher-order functions that wrap and extend. And the fact that send is a value you pass in rather than a method you inherit is dependency injection: the caller supplies the behaviour instead of the type dictating it. Both get their own lessons. Here they are just the natural shape of composed functions.

a class per combinationEmailNotifierRetryingEmailBatchedEmailRetryBatchedEmailThrottledRetryEmailevery behaviour doubles the treeBatchedThrottledRetryingEmailNotifier…and one more ticket doubles it againcompose from piecessend(msg)withRetrywithThrottlewithBatchmix any subset, get any combinationn pieces, zero new classes, 2FA opts out
A class per behaviour combination explodes as two-to-the-n. A handful of function wrappers covers every combination by opting in, adding zero new classes.

Program to an interface, not a class

Look back at every example and notice what the callers never asked. render(shape) calls shape.area(). It does not check shape instanceof Circle. send(msg) runs whatever function it was handed. Nothing anywhere said “you must be a subclass of X”. They only required that the object answer the right method.

That is duck typing: if it walks like a duck and quacks like a duck, it is a duck, as far as your code cares. You depend on the shape of a thing (the methods it responds to) rather than its place in a class hierarchy. It is what lets composition swap implementations freely. A test can pass a fake send that records calls. A different tenant can pass a different client. Nothing downstream notices, because nothing downstream asked what class it was.

This is why “program to an interface, not an implementation” is the older phrasing of the same advice. In JavaScript the interface is often implicit, just the set of methods you agree to call, though TypeScript lets you write it down with structural typing, which is duck typing the compiler can check. Either way the principle holds: depend on what an object can do, not on what it is descended from, and your code stops caring which door the object came through.

Compose it yourself

Toggle capabilities onto a plain object on the left and call its abilities: it holds exactly what you gave it, nothing more. On the right, the rigid class tree forces fly() onto a penguin, and there is nothing you can do about it from outside.

interactiveComposition takes only what you choose; inheritance forces the rest

When inheritance is still the right tool

None of this means extends is banned. It means inheritance is a specific tool for a specific shape, and that shape is narrower than people assume. Reach for it when all of these hold:

  • The relationship is a genuine, permanent is-a. Not “shares some code with”, but “is a kind of”. A ValidationError is an Error. That will never stop being true.
  • The hierarchy is shallow. One level, maybe two. The pain compounds with depth, so a tree that stays flat mostly stays fine.
  • The base is stable. It is not going to grow new behaviour that half the children must reject. If you cannot promise that, you have a fragile base class waiting to happen.
  • A framework hands it to you. Plenty of tools ask you to extend Component, extends HTMLElement for a web component, or subclass a base test case. When the framework owns the base and defines the contract, following it is correct. You are filling in a stable shape, not designing the tree.

Extending built-in Error is the cleanest everyday example, because it satisfies every bullet at once:

class PaymentDeclined extends Error {
  constructor(code) {
    super(`payment declined: ${code}`);
    this.name = "PaymentDeclined";
    this.code = code;
  }
}

PaymentDeclined truly is an Error, the tree is one level deep, Error is not going to sprout surprise methods, and the platform expects errors to be Error subclasses so instanceof Error and stack traces work. That is inheritance used exactly where it fits. If you want the mechanics of how super and the prototype chain make this work, class inheritance and prototype inheritance cover them.

The honest default, though, is the other way around. Start with composition. Reach for inheritance only when the is-a is real and the base is stable, and the moment you feel the hierarchy fighting you, a subclass overriding a parent to death, a base class you are scared to touch, a name like AbstractManagerFactoryBase, that is the signal to stop extending and start composing.

Summary

  • Inheritance models is-a; composition models has-a. Use extends for a genuine, shallow, stable “is a kind of”. Use composition for “can do these things”, which is most of the time.
  • Deep or growing inheritance breaks in predictable ways: the fragile base class (a parent edit ripples to every child, like the retry logic that delayed 2FA codes), forced inheritance (a subclass drags in a method it must never use), and the combinatorial explosion of a class per feature combination.
  • The penguin is the tell. A Bird.fly() that a penguin must override to a lie means the taxonomy was wrong. Composition never forces it, because a penguin is simply not given fly.
  • In JavaScript, behaviour is functions and objects, so composition is native. Object composition and mixins (Object.assign copying methods onto an object or prototype, kin to the module pattern) assemble a surface from parts.
  • Mixins have a sharp edge: silent name clashes (last write wins), lost origin, and shared mutable state through shallow copies. Prefer function composition when you can, and clone seeded data rather than share it.
  • Wrapping functions with functions is the most idiomatic form. withRetry(withThrottle(send)) gives each channel exactly the behaviours it opts into. That is the seed of the decorator pattern and dependency injection, and it is what fixes the base-class outage for good.
  • Duck typing: depend on the methods an object answers to, not the class it descends from. instanceof chains steering business logic are a coupling smell; call a method and let the object answer.
  • When to use inheritance: a real, shallow, permanent is-a with stable shared implementation, or a framework base class you fill in (extending Error is the model case). When to avoid: the relationship is really “shares code”, the tree is growing, the base keeps changing, or you feel yourself overriding a parent to cancel it out. Compose instead.