Skip to content

Low Level DesignFoundations

Composition vs inheritance

When to compose objects and when to inherit, through one notifier grown from a class tree to composed parts, with a class explosion demo and code in five languages.

Foundation32 min core · 35 min full depth

There are two ways to build a class out of code that already exists. Inheritance makes the new class a kind of the old one: UrgentEmailNotifier extends EmailNotifier. Composition makes the new class hold the old one and call it: a Notifier that has a Channel. Both reuse code. They differ in what else you get with the reuse, and in what it costs to change later.

The advice “favor object composition over class inheritance” comes from the Gang of Four’s Design Patterns (1994). It is the right default for most low level design rounds. It does not say “never inherit”. A small, stable hierarchy, where every subclass truly is a kind of its parent, is a good design. The Template method pattern is built on one. The skill is telling the two situations apart. The signal is usually a second dimension of variation. When a class needs to vary in two independent ways, a class tree multiplies, while composed parts just add.

This page grows one notifier from a tidy class tree to composed parts in four stages, and shows where inheritance still earns its place.

The idea in one picture
Inheritance: a class per combination Notifier EmailNotifier UrgentEmail HtmlEmail UrgentHtmlEmail SmsNotifier UrgentSms HtmlSms UrgentHtmlSms PushNotifier UrgentPush HtmlPush UrgentHtmlPush 3 × 2 × 2 = 12 classes and the retry loop is copied into 6 of them a 4th channel adds 4 more Composition: a part per value Notifier Channel Email Sms Push Formatter Plain Html Policy Once Retry 3 + 2 + 2 = 7 parts the retry loop exists once a 4th channel adds 1
Scroll or pinch to zoom, drag to pan
Two dimensions in one tree need a class per combination, and shared behavior is copied into each. Composed, each dimension is its own interface, and each value is one part.

class B extends A does two separate jobs, and most of the trouble comes from getting both when you wanted one.

What you get What it means How composition gets the same thing
Subtype polymorphism A B can be passed anywhere an A is expected, and calls dispatch to B’s overrides Implement an interface
Implementation reuse B gets A’s fields and methods without writing them Hold an A and forward calls to it
Both, welded together B is tied to A’s fields, its internal calls and its future changes Choose each part separately, and swap parts at run time
The two shapes

is a, gets code

has a, delegates

Parent

#state

+operation()

#step()

Child

#step()

Client

-part: Part

+operation()

«interface»

Part

+step()

PartA

PartB

is a, gets code

has a, delegates

Parent

#state

+operation()

#step()

Child

#step()

Client

-part: Part

+operation()

«interface»

Part

+step()

PartA

PartB

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

On the left, Child is fixed to one Parent when the code is compiled, and it sees its protected state. On the right, Client sees only the Part interface. So any implementation can be plugged in, including a fake in a test, or a different one per customer.

A subclass depends on more than its parent’s public methods. It depends on how the parent calls itself: which of its own methods it uses internally, and in what order. Those details are not part of any documented contract, so the parent’s author can change them, and the subclass breaks without being touched.

Joshua Bloch’s example in Effective Java is the standard one. To count how many elements were ever added to a set, extend HashSet and count in both add and addAll:

InstrumentedHashSet.javainstrumented_tag_set.pyInstrumentedTagSet.cstag_set.goinstrumented-tag-set.ts
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
// We want to know how many elements were ever added, so we extend HashSet and count.
// This is Joshua Bloch's example from Effective Java, and it is wrong in a way no test of
// add() alone would catch.
class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
// Looks right: count the batch, then let the parent add it.
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
// But HashSet's addAll (inherited from AbstractCollection) calls add() for each element,
// and add() is overridden, so every element is counted a second time. That detail is in
// the parent's implementation, not its contract. A JDK release could change it and the
// count would silently become right, or wrong in a new way.
return super.addAll(c);
}
public int addCount() { return addCount; }
}
class Main {
public static void main(String[] args) {
InstrumentedHashSet<String> tags = new InstrumentedHashSet<>();
tags.addAll(List.of("urgent", "billing", "eu"));
System.out.println(tags.addCount()); // 6, not 3
}
}

Adding three tags reports six. Java’s HashSet.addAll is inherited from AbstractCollection. It loops, and calls add for each element, and add is the overridden one. The other languages’ built-in sets can’t show this. C#’s HashSet<T> has no virtual methods. Python’s set.update never calls add. JavaScript’s Set has no bulk add. So those tabs extend a small library-style TagSet, whose addAll loops over add. It’s the same hidden dependency:

Why the count doubles
HashSet code (super)InstrumentedHashSetCallerHashSet code (super)InstrumentedHashSetCallerloop[each element]addCount is 6, not 3addAll(urgent, billing, eu)addCount += 3super.addAll(...)add(e), dispatched to the overrideaddCount += 1super.add(e)HashSet code (super)InstrumentedHashSetCallerHashSet code (super)InstrumentedHashSetCallerloop[each element]addCount is 6, not 3addAll(urgent, billing, eu)addCount += 3super.addAll(...)add(e), dispatched to the overrideaddCount += 1super.add(e)
Scroll or pinch to zoom, drag to pan

Removing the addAll override gives the right answer today. But it gives the wrong one if a future version of the parent stops calling add from addAll. Either way, the subclass is correct only by knowing the parent’s insides. Go has no inheritance, and its embedding shows the mirror image of the bug. An embedded type never calls back into the outer one. So overriding only Add counts nothing from AddAll.

The fix is to hold the set instead of being one. Every call goes through the inner set’s public methods, so whatever it does inside stays inside:

CountingSet.javacounting_tag_set.pyCountingTagSet.cscounting_tag_set.gocounting-tag-set.ts
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
// The same counter, built by holding a Set instead of being one. Every call goes to the inner
// set through its public methods, so how HashSet implements addAll can't reach our count.
class CountingSet<E> {
private final Set<E> inner;
private int addCount = 0;
// Any Set works: HashSet, TreeSet, a concurrent set. Inheritance fixed the parent at
// compile time; holding it lets the caller choose.
CountingSet(Set<E> inner) { this.inner = inner; }
public boolean add(E e) {
addCount++;
return inner.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
// inner.addAll may call inner.add for each element. That is inner's own add, not ours,
// so nothing is counted twice whatever HashSet does inside.
return inner.addAll(c);
}
// We expose only what callers need. Bloch's version implements all of Set by forwarding
// each method (Guava ships this as ForwardingSet), so it can be passed anywhere a Set is.
public boolean contains(E e) { return inner.contains(e); }
public int size() { return inner.size(); }
public int addCount() { return addCount; }
}
class Main {
public static void main(String[] args) {
CountingSet<String> tags = new CountingSet<>(new HashSet<>());
tags.addAll(List.of("urgent", "billing", "eu"));
System.out.println(tags.addCount()); // 3
}
}

CountingSet works with any set, can be handed a fake in a test, and cannot be broken by a new version of HashSet. Bloch calls this a wrapper class, and the pattern behind it is the Decorator. The price is writing the forwarding methods, which is why libraries such as Guava ship ForwardingSet and friends.

Classes multiply when a second dimension arrives

Section titled “Classes multiply when a second dimension arrives”

A class has one parent. Say objects vary in two independent ways: channel and priority, vehicle and pricing, storage and encryption. A tree has to pick one dimension for its branches, and repeat the other inside every branch. Three channels and two priorities need six classes. Add two formats, and it is twelve. Behavior that belongs to the second dimension, such as the retry loop for urgent messages, is copied into every branch that needs it. The worked example below shows this happening. The demo lets you push it as far as you like.

  • Protected state is public to every subclass. A field the parent marks protected can be read and written by code the parent’s author has never seen. Renaming it breaks them all.
  • The parent is fixed at compile time. An object can’t switch from EmailNotifier to SmsNotifier behavior while it runs. A field holding a Channel can be reassigned.
  • Tests inherit the parent too. Testing a subclass runs the parent’s constructor and logic, including anything slow or external it does.
  • Changes travel down. Every edit to the parent is an edit to every descendant. A wide tree makes the parent the riskiest file in the codebase.

Subclasses that break the parent’s promises

Section titled “Subclasses that break the parent’s promises”

Inheritance lets a subclass be passed anywhere the parent is accepted, whether or not it keeps the parent’s promises. A ReadOnlyTagList extends ArrayList that throws on add compiles, and it crashes the first caller that adds a tag. The rule that says when a subtype is safe to substitute is Liskov substitution. Composition avoids most of these cases, because a class that holds a list offers only the methods it chooses to.

Composition means a class holds other objects as fields, and builds its behavior from theirs. Delegation is passing a call to one of those objects. Notifier.send asks its Formatter for text, and hands the text to its Channel.

Paired with interfaces, composition gives back everything inheritance offered, but as separate choices:

  • Polymorphism comes from the interface. Notifier depends on Channel. Anything that implements Channel can be plugged in.
  • Reuse comes from the held object. The retry loop lives in one class, and every notifier that needs it holds one.
  • Combinations happen at run time. Which parts go together is decided where objects are built, from configuration or a user’s settings. It’s not fixed in a class name.

Two patterns are composition with a name:

Pattern What the object holds The dimension it takes out of the class tree
Strategy One implementation of an interface, chosen per object A rule that varies: which channel, which pricing, which eviction policy
Decorator Another object of the same interface, which it wraps A behavior to stack: retry, logging, caching, compression

A strategy replaces a subclass that overrides one method. A decorator replaces a subclass that overrides a method to do something before or after super. Both turn a branch of a class tree into a part you plug in.

Go deeperForwarding vs delegation, strictly speaking1 min

Strictly, “delegation” in the early object-oriented books means more than forwarding. The receiving object runs the call with this still pointing at the original object, so its self-calls come back. JavaScript’s prototype chain works that way, and so does inheritance. Simple forwarding doesn’t, and that’s why the CountingSet above is safe. This page uses “delegation” for forwarding, as most code does.

A product sends alerts: order shipped, payment failed, password changed. At first only the channel varies. Then a priority arrives, then formats. Each stage below is a reasonable design for what is known at that point.

Stage 1: one dimension, and a tree that fits

Section titled “Stage 1: one dimension, and a tree that fits”
Interviewerminute 5

We send notifications by email, SMS and push. Sketch the classes.

What they are testing

Whether you can produce a clean, small design quickly, without over-engineering for changes nobody has asked for. A base class with three channel subclasses is a fine answer here. The red flag is ten interfaces before the first requirement is understood.

Every notifier formats the message, then delivers it. Only delivery differs, plus SMS’s 160-character limit. Here, an abstract base class fits. It fixes the order of the steps, and lets each channel fill in delivery. That is the Template method pattern.

Stage 1: a base class with one subclass per channel

«abstract»

Notifier

+send(Message)

#format(Message) : String

#deliver(to, text)

EmailNotifier

#deliver(to, text)

SmsNotifier

#format(Message) : String

#deliver(to, text)

PushNotifier

#deliver(to, text)

Message

+to

+title

+body

«abstract»

Notifier

+send(Message)

#format(Message) : String

#deliver(to, text)

EmailNotifier

#deliver(to, text)

SmsNotifier

#format(Message) : String

#deliver(to, text)

PushNotifier

#deliver(to, text)

Message

+to

+title

+body

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Notifier.javanotifier.pySmsNotifier.cspush_notifier.gonotifier.ts
import java.util.List;
record Message(String to, String title, String body) {}
// The base class owns the steps every notifier shares, in a fixed order: format, then
// deliver. send() is final, so no subclass can skip a step.
abstract class Notifier {
public final void send(Message m) {
deliver(m.to(), format(m));
}
// A default most channels use. A subclass may override it.
protected String format(Message m) {
return m.title() + ": " + m.body();
}
// The one step each channel must supply.
protected abstract void deliver(String to, String text);
}
class EmailNotifier extends Notifier {
protected void deliver(String to, String text) {
System.out.println("email to " + to + ": " + text);
}
}
class SmsNotifier extends Notifier {
// The 160-character limit is a rule of this channel, so it overrides the format step.
@Override
protected String format(Message m) {
String text = super.format(m);
return text.length() <= 160 ? text : text.substring(0, 157) + "...";
}
protected void deliver(String to, String text) {
System.out.println("sms to " + to + ": " + text);
}
}
class PushNotifier extends Notifier {
protected void deliver(String to, String text) {
System.out.println("push to " + to + ": " + text);
}
}
class Main {
public static void main(String[] args) {
// One dimension (the channel), a small base class that will not change often, and
// subclasses that only fill in steps. Inheritance is a fine choice at this point.
Message m = new Message("ana", "Order shipped", "Your order #1042 is on its way.");
List<Notifier> notifiers = List.of(new EmailNotifier(), new SmsNotifier(), new PushNotifier());
for (Notifier n : notifiers) n.send(m);
}
}
  • One dimension, one tree. Each subclass differs from the others in one way. So there is exactly one class per channel, and no repeated code.
  • The parent is small and controls the order. send is final, so no subclass can skip formatting. The subclasses only fill in steps the parent was designed to have filled in.
  • Go has no base class to fill in. Its version is an interface for callers, and an embedded struct for the shared format. That’s already composition. Embedding shares code, but can’t call back into the outer type. So it can’t express a template method.

Stage 2: a second dimension, and the tree multiplies

Section titled “Stage 2: a second dimension, and the tree multiplies”
Interviewerminute 14

Payment failures are urgent. If delivery fails, retry up to three times. Everything else is sent once.

What they are testing

Whether you notice that “urgent” cuts across every channel before writing UrgentEmailNotifier. Urgent subclasses run today, so the interviewer often lets them stand and asks what happens when HTML email arrives next week. The red flag is adding them without naming the cost: one class per channel now, and a copy of the retry loop in each.

Priority is a second dimension, independent of the channel: an SMS can be urgent or normal, and so can an email. The tree can only branch one way, so urgency becomes a subclass under each channel, and the retry loop is written once per channel.

Stage 2: urgency repeated under every channel

«abstract»

Notifier

+send(Message)

#format(Message) : String

#deliver(to, text) : bool

EmailNotifier

SmsNotifier

PushNotifier

UrgentEmailNotifier

+send(Message) : retry copy 1

UrgentSmsNotifier

+send(Message) : retry copy 2

UrgentPushNotifier

+send(Message) : retry copy 3

«abstract»

Notifier

+send(Message)

#format(Message) : String

#deliver(to, text) : bool

EmailNotifier

SmsNotifier

PushNotifier

UrgentEmailNotifier

+send(Message) : retry copy 1

UrgentSmsNotifier

+send(Message) : retry copy 2

UrgentPushNotifier

+send(Message) : retry copy 3

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
SmsNotifier.javasms_notifier.pySmsNotifier.cssms_notifier.gosms-notifier.ts
record Message(String to, String title, String body) {}
abstract class Notifier {
// No longer final: urgent notifiers must override send() to retry. The base class's
// promise ("format, then deliver, always") is now only as good as every subclass.
public void send(Message m) {
if (!deliver(m.to(), format(m))) System.out.println(" lost: " + m.title());
}
protected String format(Message m) {
return m.title() + ": " + m.body();
}
// deliver now reports failure, because urgent messages need to know when to retry.
// Every existing subclass had to change for this: the base class is not as stable as it looked.
protected abstract boolean deliver(String to, String text);
}
class EmailNotifier extends Notifier {
protected boolean deliver(String to, String text) {
System.out.println(" email to " + to + ": " + text);
return true;
}
}
class SmsNotifier extends Notifier {
private int calls = 0;
// A flaky gateway: the first call times out. Enough to show what retry is for.
protected boolean deliver(String to, String text) {
if (++calls == 1) {
System.out.println(" sms gateway timeout");
return false;
}
System.out.println(" sms to " + to + ": " + text);
return true;
}
}
// A second dimension: priority. Urgent messages retry up to three times. A class has one
// parent, so UrgentSmsNotifier can't inherit from both "SMS" and "urgent". It extends
// SmsNotifier and gets the retry loop by copying it.
class UrgentSmsNotifier extends SmsNotifier {
@Override
public void send(Message m) {
String text = format(m);
for (int attempt = 1; attempt <= 3; attempt++) {
if (deliver(m.to(), text)) return;
}
System.out.println(" gave up: " + m.title());
}
}
// The same loop again. A fix to one copy (say, a back-off between attempts) has to be made
// in every copy. Next come UrgentPushNotifier, then a Digest priority (three more classes),
// then HTML formatting, which doubles all of them.
class UrgentEmailNotifier extends EmailNotifier {
@Override
public void send(Message m) {
String text = format(m);
for (int attempt = 1; attempt <= 3; attempt++) {
if (deliver(m.to(), text)) return;
}
System.out.println(" gave up: " + m.title());
}
}
class Main {
public static void main(String[] args) {
Message m = new Message("ana", "Payment failed", "Card ending 4242 was declined.");
System.out.println("normal sms:");
new SmsNotifier().send(m); // times out once, and the message is lost
System.out.println("urgent sms:");
new UrgentSmsNotifier().send(m); // times out once, then retries and arrives
}
}

Three things got worse at once:

  • The base class changed shape. deliver now returns success, so every existing subclass was edited. send is no longer final, because urgent subclasses override it. So the parent’s promise, that every message is formatted and then delivered, now depends on each override.
  • The retry loop is copied. A back-off between attempts, or a bug fix in the loop, has to be made in three places. Python can share it through a mixin (shown in its tab), which removes the copies but not the classes.
  • The count is now a product. The next request, HTML email, doubles every class. Try it:
Inheritance0
Composition0

Switch a dimension on or add a value. Then press a part on the right to see where its code lives.

A class per combination
A part per value

With channel, priority and format at 3, 2 and 2 values, the tree needs 12 classes and the composed design 7 parts. With every value in the demo switched on it is 90 against 13. Adding one channel costs one class composed, and a whole column of classes in the tree.

Interviewerminute 20

And HTML email for marketing messages, plain text for the rest. How many classes is that?

What they are testing

Whether you can name the problem: two, now three, independent dimensions in one tree. And whether you can refactor to composition in a few minutes, keeping the steps in a fixed order. The red flag is answering “twelve”, and starting to write them.

Each dimension becomes its own small interface: Channel answers “deliver this text”, Formatter answers “turn this message into text”, and DeliveryPolicy answers “how many tries”. Notifier is no longer subclassed. It holds one part of each kind and delegates each step.

Stage 3: a notifier composed of three parts

Notifier

-channel: Channel

-formatter: Formatter

-policy: DeliveryPolicy

+send(Message)

«interface»

Channel

+deliver(to, text) : bool

«interface»

Formatter

+format(Message) : String

«interface»

DeliveryPolicy

+run(attempt) : bool

EmailChannel

SmsChannel

PushChannel

PlainText

HtmlText

SendOnce

Retry

+times

Notifier

-channel: Channel

-formatter: Formatter

-policy: DeliveryPolicy

+send(Message)

«interface»

Channel

+deliver(to, text) : bool

«interface»

Formatter

+format(Message) : String

«interface»

DeliveryPolicy

+run(attempt) : bool

EmailChannel

SmsChannel

PushChannel

PlainText

HtmlText

SendOnce

Retry

+times

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Notifier.javanotifier.pyNotifier.csnotifier.gonotifier.ts
import java.util.function.BooleanSupplier;
record Message(String to, String title, String body) {}
// Each dimension becomes its own small interface. A notifier is built from one of each,
// so a new value on any dimension is one new class, not one per combination.
interface Channel { boolean deliver(String to, String text); }
interface Formatter { String format(Message m); }
interface DeliveryPolicy { boolean run(BooleanSupplier attempt); }
class EmailChannel implements Channel {
public boolean deliver(String to, String text) {
System.out.println(" email to " + to + ": " + text);
return true;
}
}
class SmsChannel implements Channel {
private int calls = 0;
// The 160-character limit moved here: it is a rule of the channel, whatever the format.
public boolean deliver(String to, String text) {
if (++calls == 1) {
System.out.println(" sms gateway timeout");
return false;
}
String body = text.length() <= 160 ? text : text.substring(0, 157) + "...";
System.out.println(" sms to " + to + ": " + body);
return true;
}
}
class PushChannel implements Channel {
public boolean deliver(String to, String text) {
System.out.println(" push to " + to + ": " + text);
return true;
}
}
class PlainText implements Formatter {
public String format(Message m) { return m.title() + ": " + m.body(); }
}
class HtmlText implements Formatter {
public String format(Message m) { return "<b>" + m.title() + "</b> " + m.body(); }
}
class SendOnce implements DeliveryPolicy {
public boolean run(BooleanSupplier attempt) { return attempt.getAsBoolean(); }
}
// The retry loop exists once. A back-off or a jitter fix is made here and every channel gets it.
record Retry(int times) implements DeliveryPolicy {
public boolean run(BooleanSupplier attempt) {
for (int i = 1; i <= times; i++) if (attempt.getAsBoolean()) return true;
return false;
}
}
// Not subclassed, and final so it stays that way. It holds one part per dimension and
// delegates each step. The order of the steps is still fixed in one place, as in stage 1.
final class Notifier {
private final Channel channel;
private final Formatter formatter;
private final DeliveryPolicy policy;
Notifier(Channel channel, Formatter formatter, DeliveryPolicy policy) {
this.channel = channel;
this.formatter = formatter;
this.policy = policy;
}
void send(Message m) {
String text = formatter.format(m);
if (!policy.run(() -> channel.deliver(m.to(), text))) System.out.println(" gave up: " + m.title());
}
}
class Main {
public static void main(String[] args) {
Message m = new Message("ana", "Payment failed", "Card ending 4242 was declined.");
// Combinations are made by choosing parts, at the point where notifiers are built.
// 3 channels + 2 formats + 2 policies = 7 classes cover all 12 combinations.
System.out.println("urgent sms:");
new Notifier(new SmsChannel(), new PlainText(), new Retry(3)).send(m);
System.out.println("normal email:");
new Notifier(new EmailChannel(), new HtmlText(), new SendOnce()).send(m);
}
}
  • Seven classes cover twelve combinations. A new channel is one class. A new format is one class. The combination is chosen where the notifier is built, new Notifier(sms, plain, retry). It’s not baked into a class name.
  • The fixed order survived. Notifier.send still formats, then delivers, in one place. Nothing extends it, so no override could skip a step. Composition kept the benefit stage 1 got from the template method.
  • Each rule moved to its owner. The 160-character limit belongs to SMS whatever the format, so it moved into SmsChannel. In stage 1 it lived in SmsNotifier.format, which would have clashed with HTML formatting.
  • Each part is testable alone. Retry can be tested with a lambda that fails twice. In stage 2, testing the retry meant building an UrgentSmsNotifier with a flaky gateway.
Interviewerminute 30

Urgent messages must also be written to an audit log for the on-call team. And a retry should count as one audit entry, not three.

What they are testing

Whether you treat retry and audit as wrappers around a channel, which stack in a chosen order. And whether you can say what the order changes. The red flag is a RetryingAuditedDeliveryPolicy class, or an if (urgent) inside send.

Retry and audit are not alternatives to each other. Urgent messages need both. Next month, something may need audit without retry. A single DeliveryPolicy slot holds one choice. So the behaviors become decorators. A RetryingChannel is a Channel that holds a Channel. It adds its behavior, and passes the call on. Decorators nest, so any set of behaviors can wrap any channel.

Stage 4: priorities become stacks of channel decorators

builds

wraps

wraps

Notifiers

+build(base, formatter, Priority) : Notifier

Notifier

-channel: Channel

-formatter: Formatter

+send(Message)

«interface»

Channel

+deliver(to, text) : bool

«interface»

Formatter

+format(Message) : String

EmailChannel

SmsChannel

RetryingChannel

-inner: Channel

-times

AuditedChannel

-inner: Channel

-log

«enumeration»

Priority

NORMAL

URGENT

builds

wraps

wraps

Notifiers

+build(base, formatter, Priority) : Notifier

Notifier

-channel: Channel

-formatter: Formatter

+send(Message)

«interface»

Channel

+deliver(to, text) : bool

«interface»

Formatter

+format(Message) : String

EmailChannel

SmsChannel

RetryingChannel

-inner: Channel

-times

AuditedChannel

-inner: Channel

-log

«enumeration»

Priority

NORMAL

URGENT

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Notifier.javanotifier.pyNotifier.csnotifier.gonotifier.ts
import java.util.ArrayList;
import java.util.List;
record Message(String to, String title, String body) {}
interface Channel { boolean deliver(String to, String text); }
interface Formatter { String format(Message m); }
class EmailChannel implements Channel {
public boolean deliver(String to, String text) {
System.out.println(" email to " + to + ": " + text);
return true;
}
}
class SmsChannel implements Channel {
private int calls = 0;
public boolean deliver(String to, String text) {
if (++calls == 1) {
System.out.println(" sms gateway timeout");
return false;
}
String body = text.length() <= 160 ? text : text.substring(0, 157) + "...";
System.out.println(" sms to " + to + ": " + body);
return true;
}
}
class PlainText implements Formatter {
public String format(Message m) { return m.title() + ": " + m.body(); }
}
// A decorator is a Channel that holds a Channel. It adds one behavior and passes the call
// on, so behaviors stack in any order and on any channel, with no class per combination.
// It is-a Channel (so callers can't tell it apart) and has-a Channel (so it can delegate).
record RetryingChannel(Channel inner, int times) implements Channel {
public boolean deliver(String to, String text) {
for (int i = 1; i <= times; i++) if (inner.deliver(to, text)) return true;
return false;
}
}
// Urgent messages are recorded for the on-call audit. A second behavior, written once.
record AuditedChannel(Channel inner, List<String> log) implements Channel {
public boolean deliver(String to, String text) {
boolean ok = inner.deliver(to, text);
log.add((ok ? "delivered to " : "failed for ") + to);
return ok;
}
}
enum Priority { NORMAL, URGENT }
// Smaller than in stage 3: priority is no longer its concern. It formats and hands off.
final class Notifier {
private final Channel channel;
private final Formatter formatter;
Notifier(Channel channel, Formatter formatter) {
this.channel = channel;
this.formatter = formatter;
}
void send(Message m) {
if (!channel.deliver(m.to(), formatter.format(m))) System.out.println(" gave up: " + m.title());
}
}
// The one place that knows what each priority means. A Digest priority is a new case here
// and perhaps one new decorator; no notifier or channel is edited.
final class Notifiers {
static Notifier build(Channel base, Formatter formatter, Priority p, List<String> audit) {
Channel channel = switch (p) {
case NORMAL -> base;
// Audit wraps retry, so one message is one audit line however many attempts it took.
// Swap the two and every attempt is audited: the order of decorators is a decision.
case URGENT -> new AuditedChannel(new RetryingChannel(base, 3), audit);
};
return new Notifier(channel, formatter);
}
}
class Main {
public static void main(String[] args) {
List<String> audit = new ArrayList<>();
Message m = new Message("ana", "Payment failed", "Card ending 4242 was declined.");
System.out.println("urgent sms:");
Notifiers.build(new SmsChannel(), new PlainText(), Priority.URGENT, audit).send(m);
System.out.println("normal email:");
Notifiers.build(new EmailChannel(), new PlainText(), Priority.NORMAL, audit).send(m);
System.out.println("audit: " + audit); // [delivered to ana]
}
}

An urgent SMS now travels through two wrappers before it reaches the gateway:

One urgent message through the decorators
SmsChannelRetryingChannelAuditedChannelNotifierSmsChannelRetryingChannelAuditedChannelNotifieraudit outside retry gives one entry per messagedeliver(ana, text)deliver(ana, text)deliver, attempt 1false (gateway timeout)deliver, attempt 2truetruelog "delivered to ana"trueSmsChannelRetryingChannelAuditedChannelNotifierSmsChannelRetryingChannelAuditedChannelNotifieraudit outside retry gives one entry per messagedeliver(ana, text)deliver(ana, text)deliver, attempt 1false (gateway timeout)deliver, attempt 2truetruelog "delivered to ana"true
Scroll or pinch to zoom, drag to pan
  • Color key
  • Implementation
  • Facade
  • The decorator is both is-a and has-a. RetryingChannel implements Channel, so the notifier can’t tell it from a real channel. And it holds a Channel, so it can delegate. Interface inheritance plus composition is the combination that works.
  • The order of wrappers is a design decision. Audit outside retry gives one line per message. Retry outside audit gives one line per attempt. Neither order is wrong. The code says which one was chosen, in one place.
  • Priority moved to the one place that builds notifiers. Notifier no longer knows about priorities at all. A digest priority is a new case in Notifiers.build and perhaps one new decorator. No channel or notifier is edited.
  • The switch on priority is fine. Priority is a small, closed enum that the factory maps to a stack of parts. That is a sealed set of cases, the situation the next section is about.
Say this out loud

Channel, format and priority vary independently, so I won’t put them in one class tree. The notifier holds a channel and a formatter. Priorities are stacks of channel decorators, retry and audit, built in one factory. So a new channel is one class, and a new behavior is one class.

Composition is a default, not a rule. Inheritance is the better tool in four situations, and in each one the conditions that make it fragile are absent.

Situation Why inheritance fits Example
A true is-a with a small, stable parent Every subclass keeps every promise of the parent, and the parent rarely changes IOException extends Exception; File and Directory as kinds of Node in the file system
A fixed algorithm with steps to fill in The parent was designed to be extended: it says which steps to override and calls them itself Template method: stage 1 above, a test framework’s setUp and tearDown
A framework’s extension point The framework’s authors document what to override and keep it stable across versions Android’s Activity, a JUnit TestCase, a servlet’s doGet
A closed set of cases (a sealed hierarchy) The parent lists every subtype, so nothing outside can add one, and the compiler checks every switch A payment result that is approved, declined or needs action

The last row deserves a closer look, because it reverses the usual trade. A sealed hierarchy inherits a type only. The cases share no code with a parent, and the set is fixed. Adding a new operation over the cases is easy: one new function with a switch. Adding a new case is hard on purpose. Every switch must be updated, and the compiler finds them. That suits data whose shapes are known and stable. TypeScript and Python usually write it as a union of types. Go writes it as an interface with an unexported method:

Main.javaneeds_action.pyPaymentResult.csn.gopayment-result.ts
// A closed set of cases. "sealed ... permits" lists every subtype, so nobody can add a fourth
// result somewhere else, and the compiler can check that a switch handles all three.
// This is inheritance used for the type only: the records share no code with a parent.
sealed interface PaymentResult permits Approved, Declined, NeedsAction {}
record Approved(String authCode) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record NeedsAction(String url) implements PaymentResult {}
class Main {
// No default branch. Add a fourth result and this stops compiling until it is handled,
// which is the opposite of the open/closed trade: easy to add operations, hard to add cases.
static String describe(PaymentResult r) {
return switch (r) {
case Approved a -> "approved, code " + a.authCode();
case Declined d -> "declined: " + d.reason();
case NeedsAction n -> "customer must confirm at " + n.url();
};
}
public static void main(String[] args) {
System.out.println(describe(new Approved("A91F")));
System.out.println(describe(new Declined("insufficient funds")));
System.out.println(describe(new NeedsAction("https://bank.example/3ds")));
}
}

A quick test before writing extends:

  1. Is every child a kind of the parent, always? Not “has some of its behavior”, and not “is one today”. A member who can change from student to faculty is not a StudentMember.
  2. Will the child keep every promise the parent makes? If any method would throw “not supported” or ignore its input, it fails Liskov substitution.
  3. Is the parent built to be extended, and stable? It documents which methods to override, or it is your own small class that you control. Extending a concrete class from another library is the fragile base class case.
  4. Is there only one dimension of variation, now and on the roadmap? A second one turns the tree into a product.

Four yes answers mean inheritance is a good design. One no means compose.

Reuse without a parent class, in five languages

Section titled “Reuse without a parent class, in five languages”

Every language on this site offers a way to share behavior across classes without making one the parent of the others. The mechanisms differ more than their names suggest.

Language Mechanism Can it hold state? Can it call back into the class? The catch
Java Default methods on interfaces (Java 8) No Yes, through the interface’s abstract methods Two interfaces with the same default force the class to choose
C# Default interface methods (C# 8) No Yes, through the interface’s members Callable only through the interface type, not the class
Python Mixins with multiple inheritance Yes Yes, through self The method resolution order (C3) decides which parent wins, and it can surprise
Go Struct embedding, no inheritance at all Yes No, the embedded value has no reference to the outer struct Looks like inheritance, but nothing is overridden: there are no virtual self-calls
TypeScript Mixin functions that return a subclass Yes Yes, through this Still one prototype chain underneath, and types get verbose
Go deeperThe same audit line in five languages1 min

The same shared behavior, an audit line for any record, in each language’s own way:

Auditable.javarefund.pyProgram.csauditable.goconstructor.ts
// Java: a default method. The interface supplies behavior written in terms of its own
// abstract methods, so every implementer gets auditLine() without a shared parent class.
// Interfaces hold no instance state, so this works only for behavior built on other methods.
interface Auditable {
String id();
String actor();
default String auditLine() {
return getClass().getSimpleName() + " " + id() + " by " + actor();
}
}
// Records can't extend a class at all, but they can implement any number of interfaces.
record Order(String id, String actor, int totalCents) implements Auditable {}
record Refund(String id, String actor, String orderId) implements Auditable {}
class Main {
public static void main(String[] args) {
System.out.println(new Order("o-17", "ana", 2599).auditLine()); // Order o-17 by ana
System.out.println(new Refund("r-3", "support", "o-17").auditLine()); // Refund r-3 by support
}
}

The rows differ in one way that matters for this page. Java and C# default methods hold no state. So there are no inherited fields to couple to. But a default method that calls another interface method is still a self-call an implementer can trip over. Go’s embedding never calls back into the outer type. So the self-call problem can’t arise, and neither can a template method. Python and TypeScript mixins are real inheritance, with its strengths and risks. Keep them small: one behavior per mixin.

Go deeperPython's method resolution order in one example2 min

Take class Refund(AuditMixin, JsonMixin). Python looks up a method in Refund first. Then in AuditMixin, then JsonMixin, then object. That order is called the C3 linearization, and Refund.__mro__ prints it. Two rules make it predictable. A class always comes before its parents. And parents keep the left-to-right order they were listed in. super() follows this order, not the class’s declared parent. That’s what lets cooperating mixins each call super().save(), and have every one run once. It’s also why a mixin that calls super() can end up calling a class it has never heard of. When no order satisfies both rules, Python refuses to create the class, with TypeError: Cannot create a consistent method resolution order.

Compose when:

  • Objects vary in two or more independent ways, or a second way is on the roadmap.
  • The behavior should change at run time, or per user, tenant or configuration.
  • The code you’d extend belongs to another team or library, or wasn’t designed for extension.
  • Behaviors need to stack (retry plus audit plus cache) in an order you choose.

Inherit in the four situations in the table above, when all four questions of the quick test answer yes.

Idea Looks like this topic because The difference
Strategy It is composition Strategy is one way to compose: a single replaceable rule. Composition is the general technique.
Template method It is the inheritance answer to the same problem Template method fixes the steps in a parent and fills them in subclasses. Its composition twin passes the steps in as objects, which is Strategy.
Liskov substitution Both are about when to subclass LSP says when a subtype is safe to use in place of its parent. This page is about whether to make a subtype at all.
Aggregation vs composition in UML Same word, “composition” In a UML class diagram, composition (filled diamond) means the part lives and dies with the whole. In “composition over inheritance” it means any has-a relationship.
Dependency injection Composed parts are usually passed in DI is how the parts reach the object (through the constructor). Composition is that the object is built from parts at all.

On this site:

  • Notification service: the full version of this page’s example, with every channel behind one Channel interface and retries with backoff behind a queue.
  • Library management: a borrowing policy object instead of StudentMember and FacultyMember subclasses, because a member’s type is data that changes.
  • Parking lot: vehicle types are an enum with data, not a class per vehicle. When an EV needs a charger, that’s a capability, not a subclass.
  • LRU cache: the eviction rule as a policy the cache holds, instead of a cache subclass per policy.
  • Logging framework: an async appender that wraps any appender, a decorator rather than an AsyncFileAppender subclass.
  • Chess: a piece is a kind and a color, and each kind’s movement is a MoveRule strategy. A class per piece would also be fair, a small closed hierarchy, as long as legality stays outside the pieces.
  • Car rental: pricing as a list of rules composed per rental.
  • How to approach an LLD interview: VIP rooms as a booking rule, not a VipRoom subclass.

In real systems, you’ll find composition in Java’s I/O streams (BufferedInputStream wraps any InputStream), and in HTTP client middleware and interceptors. React moved from class components and mixins to composed components and hooks. And Go’s standard library has no inheritance at all.

Interviewer asksWhy do you prefer composition here?

Because the class varies in more than one independent way: here, channel, format and priority. A class tree would need one class per combination, and copy shared behavior into each. Composed parts grow by adding, and each behavior is written once. With only one dimension and a stable parent, I’d be happy with a small hierarchy.

Interviewer asksWhat is the fragile base class problem?

A subclass that depends on how its parent calls itself internally. The classic case is counting additions in a HashSet subclass by overriding add and addAll: addAll calls add, so every element is counted twice. The parent never promised that detail, so a new version can break the subclass without either being edited. Wrapping the set instead of extending it removes the dependency.

Interviewer asksWhen would you use inheritance?

When the child truly is a kind of the parent, and keeps all of its promises. When the parent is small, stable and designed for extension, as in a template method or a framework’s documented extension point. And when there is one dimension of variation. Also for a closed set of cases, such as a sealed result type, where the compiler checks every switch.

Interviewer asksGo has no inheritance. How do you reuse code there?

Through interfaces for polymorphism, and struct embedding or plain fields for reuse. Embedding promotes the inner type’s methods. But the inner type never calls back into the outer one, so there is no overriding in the inheritance sense. Patterns that need it, like a template method, become a function or struct that takes the varying steps as an interface.

Decision We chose We gave up
One dimension of variation A small base class with a template method Nothing, while it stays one dimension
A second dimension arrives One interface per dimension, one part per value One class you can read top to bottom
Behaviors that stack Decorators around the channel, order chosen in one factory Seeing the full behavior in one class
Reusing a library class Holding it and forwarding Free access to every inherited method
A closed set of cases A sealed hierarchy or union the compiler checks Adding a case without editing every switch
Sharing small behavior Default methods, mixins or embedding, per language A single mechanism that works the same everywhere
  • Object modeling, for finding the classes before deciding how they relate
  • UML for interviews, for the inheritance, interface and composition arrows used in these diagrams
  • How to approach an LLD interview, where this choice comes up in the extension round
  • Liskov substitution, Strategy, Decorator and Template method
  • Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns (1994), chapter 1, where “favor object composition over class inheritance” comes from
  • Joshua Bloch, Effective Java, 3rd edition (2018), items 18 and 19: favor composition over inheritance, and design and document for inheritance or else prohibit it