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.
- Read first
- Object modelingLiskov substitution
- Patterns
- StrategyDecoratorTemplate method
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.
What inheritance gives you
Section titled “What inheritance gives you”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 |
- 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.
What goes wrong with inheritance
Section titled “What goes wrong with inheritance”The fragile base class
Section titled “The fragile base class”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:
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 }}# A small set class "from a library". Its add_all happens to loop over add(). That is an# implementation detail: nothing in its documentation promises it.class TagSet: def __init__(self) -> None: self._items: set[str] = set()
def add(self, item: str) -> bool: if item in self._items: return False self._items.add(item) return True
def add_all(self, items: list[str]) -> bool: changed = False for item in items: # self.add is looked up on the object, so a subclass's add runs here. changed = self.add(item) or changed return changed
# We want to know how many tags were ever added, so we subclass and count.class InstrumentedTagSet(TagSet): def __init__(self) -> None: super().__init__() self.add_count = 0
def add(self, item: str) -> bool: self.add_count += 1 return super().add(item)
# Looks right: count the batch, then let the parent add it. But the parent's add_all # calls our add() for each item, so every item is counted twice. Python's built-in set # would not do this (its update() never calls add), which is the point: the answer depends # on how the parent is written inside, not on what it promises. def add_all(self, items: list[str]) -> bool: self.add_count += len(items) return super().add_all(items)
if __name__ == "__main__": tags = InstrumentedTagSet() tags.add_all(["urgent", "billing", "eu"]) print(tags.add_count) # 6, not 3using System;using System.Collections.Generic;
// A small set class "from a library". Its AddAll happens to loop over Add. That is an// implementation detail: nothing in its documentation promises it. C# methods are not// virtual unless the author says so; this author made both overridable.class TagSet{ private readonly HashSet<string> _items = new();
public virtual bool Add(string item) => _items.Add(item);
public virtual bool AddAll(IEnumerable<string> items) { var changed = false; // A virtual call: a subclass's Add runs here. foreach (var item in items) changed |= Add(item); return changed; }}
// We want to know how many tags were ever added, so we subclass and count.class InstrumentedTagSet : TagSet{ public int AddCount { get; private set; }
public override bool Add(string item) { AddCount++; return base.Add(item); }
// Looks right: count the batch, then let the parent add it. But the parent's AddAll calls // our Add for each item, so every item is counted twice. (BCL's HashSet<T> is safe from // this only because its methods are not virtual, so it cannot be overridden this way.) public override bool AddAll(IEnumerable<string> items) { var list = new List<string>(items); AddCount += list.Count; return base.AddAll(list); }}
static class Program{ static void Main() { var tags = new InstrumentedTagSet(); tags.AddAll(new[] { "urgent", "billing", "eu" }); Console.WriteLine(tags.AddCount); // 6, not 3 }}package main
import "fmt"
// Go has no inheritance, so the textbook bug can't happen as written. Embedding is the// closest thing, and it shows the same problem from the other side.
// TagSet is a small set type "from a library". Its AddAll happens to loop over Add.type TagSet struct{ items map[string]bool }
func NewTagSet() *TagSet { return &TagSet{items: map[string]bool{}} }
func (t *TagSet) Add(item string) bool { if t.items[item] { return false } t.items[item] = true return true}
func (t *TagSet) AddAll(items []string) bool { changed := false for _, item := range items { // t is always the inner *TagSet. Go has no virtual self-calls: an outer type that // embeds TagSet can't redirect this call to its own Add. changed = t.Add(item) || changed } return changed}
// CountingTagSet embeds TagSet and "overrides" only Add, expecting AddAll to go through it.type CountingTagSet struct { *TagSet AddCount int}
func (c *CountingTagSet) Add(item string) bool { c.AddCount++ return c.TagSet.Add(item)}
func main() { tags := &CountingTagSet{TagSet: NewTagSet()} // AddAll is promoted from the embedded TagSet and calls TagSet.Add, not ours. tags.AddAll([]string{"urgent", "billing", "eu"}) fmt.Println(tags.AddCount) // 0, not 3
// In Java this exact subclass would print 3, and the one that also overrides AddAll // prints 6. Either way the right answer depends on how the parent calls itself inside, // which is the fragile base class problem.}// A small set class "from a library". Its addAll happens to loop over add(). That is an// implementation detail: nothing in its documentation promises it.class TagSet { private readonly items = new Set<string>();
add(item: string): boolean { if (this.items.has(item)) return false; this.items.add(item); return true; }
addAll(items: readonly string[]): boolean { let changed = false; // this.add is looked up on the object, so a subclass's add runs here. for (const item of items) changed = this.add(item) || changed; return changed; }}
// We want to know how many tags were ever added, so we subclass and count.class InstrumentedTagSet extends TagSet { addCount = 0;
override add(item: string): boolean { this.addCount++; return super.add(item); }
// Looks right: count the batch, then let the parent add it. But the parent's addAll calls // our add() for each item, so every item is counted twice. override addAll(items: readonly string[]): boolean { this.addCount += items.length; return super.addAll(items); }}
const tags = new InstrumentedTagSet();tags.addAll(['urgent', 'billing', 'eu']);console.log(tags.addCount); // 6, not 3Adding 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:
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:
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 }}# The same library class as before: add_all loops over add.class TagSet: def __init__(self) -> None: self._items: set[str] = set()
def add(self, item: str) -> bool: if item in self._items: return False self._items.add(item) return True
def add_all(self, items: list[str]) -> bool: changed = False for item in items: changed = self.add(item) or changed return changed
def __len__(self) -> int: return len(self._items)
# The same counter, built by holding a TagSet instead of being one. Every call goes to the# inner set through its public methods, so how add_all is written inside can't reach our count.class CountingTagSet: def __init__(self, inner: TagSet) -> None: # The caller passes the set in, so a test can pass a fake and production any variant. self._inner = inner self.add_count = 0
def add(self, item: str) -> bool: self.add_count += 1 return self._inner.add(item)
def add_all(self, items: list[str]) -> bool: self.add_count += len(items) # inner.add_all calls inner.add, not ours, so nothing is counted twice. return self._inner.add_all(items)
# Forward only what callers need. Anything not forwarded is simply not available, which # is safer than inheriting a method that bypasses the count. def __len__(self) -> int: return len(self._inner)
if __name__ == "__main__": tags = CountingTagSet(TagSet()) tags.add_all(["urgent", "billing", "eu"]) print(tags.add_count) # 3using System;using System.Collections.Generic;
// What callers need from a tag set. Both the plain set and the counting one implement it, so// the counter can stand in wherever a tag set is expected.interface ITagSet{ bool Add(string item); bool AddAll(IEnumerable<string> items); int Count { get; }}
// The same library class as before: AddAll loops over Add.class TagSet : ITagSet{ private readonly HashSet<string> _items = new();
public bool Add(string item) => _items.Add(item);
public bool AddAll(IEnumerable<string> items) { var changed = false; foreach (var item in items) changed |= Add(item); return changed; }
public int Count => _items.Count;}
// The same counter, built by holding a tag set instead of being one. Every call goes to the// inner set through its public methods, so how AddAll is written inside can't reach our count.class CountingTagSet : ITagSet{ private readonly ITagSet _inner;
// The caller passes the set in, so a test can pass a fake and production any variant. public CountingTagSet(ITagSet inner) => _inner = inner;
public int AddCount { get; private set; }
public bool Add(string item) { AddCount++; return _inner.Add(item); }
public bool AddAll(IEnumerable<string> items) { var list = new List<string>(items); AddCount += list.Count; // _inner.AddAll calls _inner's own Add, not ours, so nothing is counted twice. return _inner.AddAll(list); }
public int Count => _inner.Count;}
static class Program{ static void Main() { var tags = new CountingTagSet(new TagSet()); tags.AddAll(new[] { "urgent", "billing", "eu" }); Console.WriteLine(tags.AddCount); // 3 }}package main
import "fmt"
// TagStore is what callers need from a tag set. Both the plain set and the counting one// satisfy it, so the counter can stand in wherever a tag set is expected.type TagStore interface { Add(item string) bool AddAll(items []string) bool Len() int}
// TagSet is the same library type as before: AddAll loops over Add.type TagSet struct{ items map[string]bool }
func NewTagSet() *TagSet { return &TagSet{items: map[string]bool{}} }
func (t *TagSet) Add(item string) bool { if t.items[item] { return false } t.items[item] = true return true}
func (t *TagSet) AddAll(items []string) bool { changed := false for _, item := range items { changed = t.Add(item) || changed } return changed}
func (t *TagSet) Len() int { return len(t.items) }
// CountingTagSet holds a TagStore in a named field instead of embedding it. Nothing is// promoted by accident: each method it offers is written out and counts on purpose.type CountingTagSet struct { inner TagStore AddCount int}
func (c *CountingTagSet) Add(item string) bool { c.AddCount++ return c.inner.Add(item)}
func (c *CountingTagSet) AddAll(items []string) bool { c.AddCount += len(items) // inner.AddAll calls inner's own Add, not ours, so nothing is counted twice. return c.inner.AddAll(items)}
func (c *CountingTagSet) Len() int { return c.inner.Len() }
func main() { var tags = &CountingTagSet{inner: NewTagSet()} tags.AddAll([]string{"urgent", "billing", "eu"}) fmt.Println(tags.AddCount) // 3
// It satisfies the same interface, so it can be passed anywhere a TagStore is. var _ TagStore = tags}// What callers need from a tag set. Both the plain set and the counting one implement it, so// the counter can stand in wherever a tag set is expected.interface TagStore { add(item: string): boolean; addAll(items: readonly string[]): boolean; readonly size: number;}
// The same library class as before: addAll loops over add().class TagSet implements TagStore { private readonly items = new Set<string>();
add(item: string): boolean { if (this.items.has(item)) return false; this.items.add(item); return true; }
addAll(items: readonly string[]): boolean { let changed = false; for (const item of items) changed = this.add(item) || changed; return changed; }
get size() { return this.items.size; }}
// The same counter, built by holding a tag set instead of being one. Every call goes to the// inner set through its public methods, so how addAll is written inside can't reach our count.class CountingTagSet implements TagStore { addCount = 0;
// The caller passes the set in, so a test can pass a fake and production any variant. constructor(private readonly inner: TagStore) {}
add(item: string): boolean { this.addCount++; return this.inner.add(item); }
addAll(items: readonly string[]): boolean { this.addCount += items.length; // inner.addAll calls inner's own add, not ours, so nothing is counted twice. return this.inner.addAll(items); }
get size() { return this.inner.size; }}
const tags = new CountingTagSet(new TagSet());tags.addAll(['urgent', 'billing', 'eu']);console.log(tags.addCount); // 3CountingSet 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.
Tight coupling
Section titled “Tight coupling”- Protected state is public to every subclass. A field the parent marks
protectedcan 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
EmailNotifiertoSmsNotifierbehavior while it runs. A field holding aChannelcan 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 and delegation
Section titled “Composition and delegation”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.
Notifierdepends onChannel. Anything that implementsChannelcan 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.
Worked example: one notifier, four stages
Section titled “Worked example: one notifier, four stages”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”We send notifications by email, SMS and push. Sketch the classes.
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
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); }}from abc import ABC, abstractmethodfrom dataclasses import dataclass
@dataclass(frozen=True)class Message: to: str title: str body: str
# The base class owns the steps every notifier shares, in a fixed order: format, then# deliver. Subclasses fill in steps; they are not meant to override send().class Notifier(ABC): def send(self, m: Message) -> None: self.deliver(m.to, self.format(m))
# A default most channels use. A subclass may override it. def format(self, m: Message) -> str: return f"{m.title}: {m.body}"
# The one step each channel must supply. @abstractmethod def deliver(self, to: str, text: str) -> None: ...
class EmailNotifier(Notifier): def deliver(self, to: str, text: str) -> None: print(f"email to {to}: {text}")
class SmsNotifier(Notifier): # The 160-character limit is a rule of this channel, so it overrides the format step. def format(self, m: Message) -> str: text = super().format(m) return text if len(text) <= 160 else text[:157] + "..."
def deliver(self, to: str, text: str) -> None: print(f"sms to {to}: {text}")
class PushNotifier(Notifier): def deliver(self, to: str, text: str) -> None: print(f"push to {to}: {text}")
if __name__ == "__main__": # 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. m = Message("ana", "Order shipped", "Your order #1042 is on its way.") for n in (EmailNotifier(), SmsNotifier(), PushNotifier()): n.send(m)using System;using System.Collections.Generic;
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 not virtual, so no subclass can skip a step.abstract class Notifier{ public void Send(Message m) => Deliver(m.To, Format(m));
// A default most channels use. A subclass may override it. protected virtual string Format(Message m) => $"{m.Title}: {m.Body}";
// The one step each channel must supply. protected abstract void Deliver(string to, string text);}
class EmailNotifier : Notifier{ protected override void Deliver(string to, string text) => Console.WriteLine($"email to {to}: {text}");}
class SmsNotifier : Notifier{ // The 160-character limit is a rule of this channel, so it overrides the format step. protected override string Format(Message m) { var text = base.Format(m); return text.Length <= 160 ? text : text[..157] + "..."; }
protected override void Deliver(string to, string text) => Console.WriteLine($"sms to {to}: {text}");}
class PushNotifier : Notifier{ protected override void Deliver(string to, string text) => Console.WriteLine($"push to {to}: {text}");}
static class Program{ static void Main() { // 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. var m = new Message("ana", "Order shipped", "Your order #1042 is on its way."); var notifiers = new List<Notifier> { new EmailNotifier(), new SmsNotifier(), new PushNotifier() }; foreach (var n in notifiers) n.Send(m); }}package main
import "fmt"
type Message struct{ To, Title, Body string }
// Go has no base classes, so there is no abstract Notifier to fill in. The closest stage 1// is an interface for callers plus an embedded struct that shares the default format.type Notifier interface { Send(m Message)}
// plainFormat is embedded for reuse. Its Format is promoted onto each notifier, but it// can't call back into them: embedding shares code, it does not create a template.type plainFormat struct{}
func (plainFormat) Format(m Message) string { return m.Title + ": " + m.Body }
type EmailNotifier struct{ plainFormat }
func (n EmailNotifier) Send(m Message) { fmt.Printf("email to %s: %s\n", m.To, n.Format(m))}
// The 160-character limit is a rule of this channel, so SmsNotifier wraps the shared format.type SmsNotifier struct{ plainFormat }
func (n SmsNotifier) Send(m Message) { text := n.Format(m) if len(text) > 160 { text = text[:157] + "..." } fmt.Printf("sms to %s: %s\n", m.To, text)}
type PushNotifier struct{ plainFormat }
func (n PushNotifier) Send(m Message) { fmt.Printf("push to %s: %s\n", m.To, n.Format(m))}
func main() { // One dimension (the channel): one type per channel is fine at this point. m := Message{"ana", "Order shipped", "Your order #1042 is on its way."} for _, n := range []Notifier{EmailNotifier{}, SmsNotifier{}, PushNotifier{}} { n.Send(m) }}interface Message { readonly to: string; readonly title: string; readonly body: string;}
// The base class owns the steps every notifier shares, in a fixed order: format, then// deliver. Subclasses fill in steps; they are not meant to override send().abstract class Notifier { send(m: Message): void { this.deliver(m.to, this.format(m)); }
// A default most channels use. A subclass may override it. protected format(m: Message): string { return `${m.title}: ${m.body}`; }
// The one step each channel must supply. protected abstract deliver(to: string, text: string): void;}
class EmailNotifier extends Notifier { protected deliver(to: string, text: string) { console.log(`email to ${to}: ${text}`); }}
class SmsNotifier extends Notifier { // The 160-character limit is a rule of this channel, so it overrides the format step. protected override format(m: Message): string { const text = super.format(m); return text.length <= 160 ? text : text.slice(0, 157) + '...'; }
protected deliver(to: string, text: string) { console.log(`sms to ${to}: ${text}`); }}
class PushNotifier extends Notifier { protected deliver(to: string, text: string) { console.log(`push to ${to}: ${text}`); }}
// 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.const m: Message = { to: 'ana', title: 'Order shipped', body: 'Your order #1042 is on its way.' };for (const n of [new EmailNotifier(), new SmsNotifier(), new PushNotifier()]) n.send(m);What to notice
Section titled “What to notice”- 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.
sendis 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”Payment failures are urgent. If delivery fails, retry up to three times. Everything else is sent once.
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
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 }}from abc import ABC, abstractmethodfrom dataclasses import dataclass
@dataclass(frozen=True)class Message: to: str title: str body: str
class Notifier(ABC): # Urgent notifiers now override send() to retry. The base class's promise ("format, # then deliver, always") is only as good as every subclass. def send(self, m: Message) -> None: if not self.deliver(m.to, self.format(m)): print(f" lost: {m.title}")
def format(self, m: Message) -> str: return f"{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. @abstractmethod def deliver(self, to: str, text: str) -> bool: ...
class EmailNotifier(Notifier): def deliver(self, to: str, text: str) -> bool: print(f" email to {to}: {text}") return True
class SmsNotifier(Notifier): def __init__(self) -> None: self._calls = 0
# A flaky gateway: the first call times out. Enough to show what retry is for. def deliver(self, to: str, text: str) -> bool: self._calls += 1 if self._calls == 1: print(" sms gateway timeout") return False print(f" sms to {to}: {text}") return True
# A second dimension: priority. Python allows several parents, so the retry loop can live# once in a mixin instead of being copied. That removes the duplication, not the classes:# every channel still needs its own Urgent class, and the method resolution order# (UrgentSmsNotifier -> UrgentMixin -> SmsNotifier -> Notifier) decides whose send() runs.class UrgentMixin: def send(self, m: Message) -> None: text = self.format(m) # type: ignore[attr-defined] for _ in range(3): if self.deliver(m.to, text): # type: ignore[attr-defined] return print(f" gave up: {m.title}")
class UrgentSmsNotifier(UrgentMixin, SmsNotifier): pass
class UrgentEmailNotifier(UrgentMixin, EmailNotifier): pass
# Next come UrgentPushNotifier, then a Digest priority (three more classes), then HTML# formatting, which doubles all of them.
if __name__ == "__main__": m = Message("ana", "Payment failed", "Card ending 4242 was declined.") print("normal sms:") SmsNotifier().send(m) # times out once, and the message is lost print("urgent sms:") UrgentSmsNotifier().send(m) # times out once, then retries and arrivesusing System;
record Message(string To, string Title, string Body);
abstract class Notifier{ // Now virtual: 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 virtual void Send(Message m) { if (!Deliver(m.To, Format(m))) Console.WriteLine($" lost: {m.Title}"); }
protected virtual string Format(Message m) => $"{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 bool Deliver(string to, string text);}
class EmailNotifier : Notifier{ protected override bool Deliver(string to, string text) { Console.WriteLine($" email to {to}: {text}"); return true; }}
class SmsNotifier : Notifier{ private int _calls;
// A flaky gateway: the first call times out. Enough to show what retry is for. protected override bool Deliver(string to, string text) { if (++_calls == 1) { Console.WriteLine(" sms gateway timeout"); return false; } Console.WriteLine($" 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 : SmsNotifier{ public override void Send(Message m) { var text = Format(m); for (var attempt = 1; attempt <= 3; attempt++) if (Deliver(m.To, text)) return; Console.WriteLine($" 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 : EmailNotifier{ public override void Send(Message m) { var text = Format(m); for (var attempt = 1; attempt <= 3; attempt++) if (Deliver(m.To, text)) return; Console.WriteLine($" gave up: {m.Title}"); }}
static class Program{ static void Main() { var m = new Message("ana", "Payment failed", "Card ending 4242 was declined."); Console.WriteLine("normal sms:"); new SmsNotifier().Send(m); // times out once, and the message is lost Console.WriteLine("urgent sms:"); new UrgentSmsNotifier().Send(m); // times out once, then retries and arrives }}package main
import "fmt"
type Message struct{ To, Title, Body string }
type Notifier interface { Send(m Message)}
type plainFormat struct{}
func (plainFormat) Format(m Message) string { return m.Title + ": " + m.Body }
// Deliver now reports failure, because urgent messages need to know when to retry.type EmailNotifier struct{ plainFormat }
func (n *EmailNotifier) Deliver(to, text string) bool { fmt.Printf(" email to %s: %s\n", to, text) return true}
func (n *EmailNotifier) Send(m Message) { if !n.Deliver(m.To, n.Format(m)) { fmt.Println(" lost:", m.Title) }}
// A flaky gateway: the first call times out. Enough to show what retry is for.type SmsNotifier struct { plainFormat calls int}
func (n *SmsNotifier) Deliver(to, text string) bool { n.calls++ if n.calls == 1 { fmt.Println(" sms gateway timeout") return false } fmt.Printf(" sms to %s: %s\n", to, text) return true}
func (n *SmsNotifier) Send(m Message) { if !n.Deliver(m.To, n.Format(m)) { fmt.Println(" lost:", m.Title) }}
// A second dimension: priority. Urgent messages retry up to three times. Embedding// SmsNotifier reuses its Deliver, but the retry loop still has to be written per channel,// so it is copied into each urgent type.type UrgentSmsNotifier struct{ SmsNotifier }
func (n *UrgentSmsNotifier) Send(m Message) { text := n.Format(m) for attempt := 1; attempt <= 3; attempt++ { if n.Deliver(m.To, text) { return } } fmt.Println(" gave up:", m.Title)}
// The same loop again. A fix to one copy has to be made in every copy. Next come// UrgentPushNotifier, a Digest priority (three more types), then HTML formatting, which// doubles all of them.type UrgentEmailNotifier struct{ EmailNotifier }
func (n *UrgentEmailNotifier) Send(m Message) { text := n.Format(m) for attempt := 1; attempt <= 3; attempt++ { if n.Deliver(m.To, text) { return } } fmt.Println(" gave up:", m.Title)}
func main() { m := Message{"ana", "Payment failed", "Card ending 4242 was declined."} fmt.Println("normal sms:") (&SmsNotifier{}).Send(m) // times out once, and the message is lost fmt.Println("urgent sms:") (&UrgentSmsNotifier{}).Send(m) // times out once, then retries and arrives}interface Message { readonly to: string; readonly title: string; readonly body: string;}
abstract class Notifier { // Urgent notifiers now override send() to retry. The base class's promise ("format, // then deliver, always") is only as good as every subclass. send(m: Message): void { if (!this.deliver(m.to, this.format(m))) console.log(` lost: ${m.title}`); }
protected format(m: Message): string { 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 deliver(to: string, text: string): boolean;}
class EmailNotifier extends Notifier { protected deliver(to: string, text: string) { console.log(` email to ${to}: ${text}`); return true; }}
class SmsNotifier extends Notifier { private calls = 0;
// A flaky gateway: the first call times out. Enough to show what retry is for. protected deliver(to: string, text: string) { if (++this.calls === 1) { console.log(' sms gateway timeout'); return false; } console.log(` 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 extend both "SMS" and "urgent". It extends SmsNotifier// and gets the retry loop by copying it.class UrgentSmsNotifier extends SmsNotifier { override send(m: Message): void { const text = this.format(m); for (let attempt = 1; attempt <= 3; attempt++) { if (this.deliver(m.to, text)) return; } console.log(` 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 send(m: Message): void { const text = this.format(m); for (let attempt = 1; attempt <= 3; attempt++) { if (this.deliver(m.to, text)) return; } console.log(` gave up: ${m.title}`); }}
const m: Message = { to: 'ana', title: 'Payment failed', body: 'Card ending 4242 was declined.' };console.log('normal sms:');new SmsNotifier().send(m); // times out once, and the message is lostconsole.log('urgent sms:');new UrgentSmsNotifier().send(m); // times out once, then retries and arrivesThree things got worse at once:
- The base class changed shape.
delivernow returns success, so every existing subclass was edited.sendis 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:
Switch a dimension on or add a value. Then press a part on the right to see where its code lives.
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.
Stage 3: one part per dimension
Section titled “Stage 3: one part per dimension”And HTML email for marketing messages, plain text for the rest. How many classes is that?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
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); }}from dataclasses import dataclassfrom typing import Callable, Protocol
@dataclass(frozen=True)class Message: to: str title: str body: str
# 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.class Channel(Protocol): def deliver(self, to: str, text: str) -> bool: ...
class Formatter(Protocol): def format(self, m: Message) -> str: ...
class DeliveryPolicy(Protocol): def run(self, attempt: Callable[[], bool]) -> bool: ...
class EmailChannel: def deliver(self, to: str, text: str) -> bool: print(f" email to {to}: {text}") return True
class SmsChannel: def __init__(self) -> None: self._calls = 0
# The 160-character limit moved here: it is a rule of the channel, whatever the format. def deliver(self, to: str, text: str) -> bool: self._calls += 1 if self._calls == 1: print(" sms gateway timeout") return False body = text if len(text) <= 160 else text[:157] + "..." print(f" sms to {to}: {body}") return True
class PushChannel: def deliver(self, to: str, text: str) -> bool: print(f" push to {to}: {text}") return True
class PlainText: def format(self, m: Message) -> str: return f"{m.title}: {m.body}"
class HtmlText: def format(self, m: Message) -> str: return f"<b>{m.title}</b> {m.body}"
class SendOnce: def run(self, attempt: Callable[[], bool]) -> bool: return attempt()
# The retry loop exists once. A back-off or a jitter fix is made here and every channel gets it.@dataclass(frozen=True)class Retry: times: int
def run(self, attempt: Callable[[], bool]) -> bool: return any(attempt() for _ in range(self.times))
# Not subclassed. 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.class Notifier: def __init__(self, channel: Channel, formatter: Formatter, policy: DeliveryPolicy) -> None: self._channel = channel self._formatter = formatter self._policy = policy
def send(self, m: Message) -> None: text = self._formatter.format(m) if not self._policy.run(lambda: self._channel.deliver(m.to, text)): print(f" gave up: {m.title}")
if __name__ == "__main__": m = 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. print("urgent sms:") Notifier(SmsChannel(), PlainText(), Retry(3)).send(m) print("normal email:") Notifier(EmailChannel(), HtmlText(), SendOnce()).send(m)using System;
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 IChannel { bool Deliver(string to, string text); }
interface IFormatter { string Format(Message m); }
interface IDeliveryPolicy { bool Run(Func<bool> attempt); }
class EmailChannel : IChannel{ public bool Deliver(string to, string text) { Console.WriteLine($" email to {to}: {text}"); return true; }}
class SmsChannel : IChannel{ private int _calls;
// The 160-character limit moved here: it is a rule of the channel, whatever the format. public bool Deliver(string to, string text) { if (++_calls == 1) { Console.WriteLine(" sms gateway timeout"); return false; } var body = text.Length <= 160 ? text : text[..157] + "..."; Console.WriteLine($" sms to {to}: {body}"); return true; }}
class PushChannel : IChannel{ public bool Deliver(string to, string text) { Console.WriteLine($" push to {to}: {text}"); return true; }}
class PlainText : IFormatter{ public string Format(Message m) => $"{m.Title}: {m.Body}";}
class HtmlText : IFormatter{ public string Format(Message m) => $"<b>{m.Title}</b> {m.Body}";}
class SendOnce : IDeliveryPolicy{ public bool Run(Func<bool> attempt) => attempt();}
// The retry loop exists once. A back-off or a jitter fix is made here and every channel gets it.record Retry(int Times) : IDeliveryPolicy{ public bool Run(Func<bool> attempt) { for (var i = 1; i <= Times; i++) if (attempt()) return true; return false; }}
// Not subclassed, and sealed 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.sealed class Notifier{ private readonly IChannel _channel; private readonly IFormatter _formatter; private readonly IDeliveryPolicy _policy;
public Notifier(IChannel channel, IFormatter formatter, IDeliveryPolicy policy) { _channel = channel; _formatter = formatter; _policy = policy; }
public void Send(Message m) { var text = _formatter.Format(m); if (!_policy.Run(() => _channel.Deliver(m.To, text))) Console.WriteLine($" gave up: {m.Title}"); }}
static class Program{ static void Main() { var 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. Console.WriteLine("urgent sms:"); new Notifier(new SmsChannel(), new PlainText(), new Retry(3)).Send(m); Console.WriteLine("normal email:"); new Notifier(new EmailChannel(), new HtmlText(), new SendOnce()).Send(m); }}package main
import "fmt"
type Message struct{ To, Title, Body string }
// 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 type, not one per combination.type Channel interface { Deliver(to, text string) bool}
type Formatter interface { Format(m Message) string}
type DeliveryPolicy interface { Run(attempt func() bool) bool}
type EmailChannel struct{}
func (EmailChannel) Deliver(to, text string) bool { fmt.Printf(" email to %s: %s\n", to, text) return true}
type SmsChannel struct{ calls int }
// The 160-character limit moved here: it is a rule of the channel, whatever the format.func (c *SmsChannel) Deliver(to, text string) bool { c.calls++ if c.calls == 1 { fmt.Println(" sms gateway timeout") return false } if len(text) > 160 { text = text[:157] + "..." } fmt.Printf(" sms to %s: %s\n", to, text) return true}
type PushChannel struct{}
func (PushChannel) Deliver(to, text string) bool { fmt.Printf(" push to %s: %s\n", to, text) return true}
type PlainText struct{}
func (PlainText) Format(m Message) string { return m.Title + ": " + m.Body }
type HtmlText struct{}
func (HtmlText) Format(m Message) string { return "<b>" + m.Title + "</b> " + m.Body }
type SendOnce struct{}
func (SendOnce) Run(attempt func() bool) bool { return attempt() }
// The retry loop exists once. A back-off or a jitter fix is made here and every channel gets it.type Retry struct{ Times int }
func (r Retry) Run(attempt func() bool) bool { for i := 1; i <= r.Times; i++ { if attempt() { return true } } return false}
// Notifier holds one part per dimension in named fields and delegates each step. The order// of the steps is still fixed in one place, as in stage 1.type Notifier struct { channel Channel formatter Formatter policy DeliveryPolicy}
func (n Notifier) Send(m Message) { text := n.formatter.Format(m) if !n.policy.Run(func() bool { return n.channel.Deliver(m.To, text) }) { fmt.Println(" gave up:", m.Title) }}
func main() { m := 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 types cover all 12 combinations. fmt.Println("urgent sms:") Notifier{&SmsChannel{}, PlainText{}, Retry{3}}.Send(m) fmt.Println("normal email:") Notifier{EmailChannel{}, HtmlText{}, SendOnce{}}.Send(m)}interface Message { readonly to: string; readonly title: string; readonly body: string;}
// 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 { deliver(to: string, text: string): boolean;}
interface Formatter { format(m: Message): string;}
interface DeliveryPolicy { run(attempt: () => boolean): boolean;}
class EmailChannel implements Channel { deliver(to: string, text: string) { console.log(` email to ${to}: ${text}`); return true; }}
class SmsChannel implements Channel { private calls = 0;
// The 160-character limit moved here: it is a rule of the channel, whatever the format. deliver(to: string, text: string) { if (++this.calls === 1) { console.log(' sms gateway timeout'); return false; } const body = text.length <= 160 ? text : text.slice(0, 157) + '...'; console.log(` sms to ${to}: ${body}`); return true; }}
class PushChannel implements Channel { deliver(to: string, text: string) { console.log(` push to ${to}: ${text}`); return true; }}
class PlainText implements Formatter { format(m: Message) { return `${m.title}: ${m.body}`; }}
class HtmlText implements Formatter { format(m: Message) { return `<b>${m.title}</b> ${m.body}`; }}
class SendOnce implements DeliveryPolicy { run(attempt: () => boolean) { return attempt(); }}
// The retry loop exists once. A back-off or a jitter fix is made here and every channel gets it.class Retry implements DeliveryPolicy { constructor(private readonly times: number) {} run(attempt: () => boolean) { for (let i = 1; i <= this.times; i++) if (attempt()) return true; return false; }}
// Not subclassed. 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.class Notifier { constructor( private readonly channel: Channel, private readonly formatter: Formatter, private readonly policy: DeliveryPolicy, ) {}
send(m: Message): void { const text = this.formatter.format(m); if (!this.policy.run(() => this.channel.deliver(m.to, text))) console.log(` gave up: ${m.title}`); }}
const m: Message = { to: 'ana', title: 'Payment failed', body: '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.console.log('urgent sms:');new Notifier(new SmsChannel(), new PlainText(), new Retry(3)).send(m);console.log('normal email:');new Notifier(new EmailChannel(), new HtmlText(), new SendOnce()).send(m);What to notice
Section titled “What to notice”- 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.sendstill 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 inSmsNotifier.format, which would have clashed with HTML formatting. - Each part is testable alone.
Retrycan be tested with a lambda that fails twice. In stage 2, testing the retry meant building anUrgentSmsNotifierwith a flaky gateway.
Stage 4: behaviors that stack
Section titled “Stage 4: behaviors that stack”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.
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
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] }}from dataclasses import dataclassfrom enum import Enumfrom typing import Protocol, assert_never
@dataclass(frozen=True)class Message: to: str title: str body: str
class Channel(Protocol): def deliver(self, to: str, text: str) -> bool: ...
class Formatter(Protocol): def format(self, m: Message) -> str: ...
class EmailChannel: def deliver(self, to: str, text: str) -> bool: print(f" email to {to}: {text}") return True
class SmsChannel: def __init__(self) -> None: self._calls = 0
def deliver(self, to: str, text: str) -> bool: self._calls += 1 if self._calls == 1: print(" sms gateway timeout") return False body = text if len(text) <= 160 else text[:157] + "..." print(f" sms to {to}: {body}") return True
class PlainText: def format(self, m: Message) -> str: return f"{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 satisfies Channel (so callers can't tell it apart) and has a Channel (so it can delegate).@dataclassclass RetryingChannel: inner: Channel times: int
def deliver(self, to: str, text: str) -> bool: return any(self.inner.deliver(to, text) for _ in range(self.times))
# Urgent messages are recorded for the on-call audit. A second behavior, written once.@dataclassclass AuditedChannel: inner: Channel log: list[str]
def deliver(self, to: str, text: str) -> bool: ok = self.inner.deliver(to, text) self.log.append(f"delivered to {to}" if ok else f"failed for {to}") return ok
class Priority(Enum): NORMAL = "normal" URGENT = "urgent"
# Smaller than in stage 3: priority is no longer its concern. It formats and hands off.class Notifier: def __init__(self, channel: Channel, formatter: Formatter) -> None: self._channel = channel self._formatter = formatter
def send(self, m: Message) -> None: if not self._channel.deliver(m.to, self._formatter.format(m)): print(f" 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.def build_notifier(base: Channel, formatter: Formatter, p: Priority, audit: list[str]) -> Notifier: match p: case Priority.NORMAL: channel = base case Priority.URGENT: # 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 is a decision. channel = AuditedChannel(RetryingChannel(base, 3), audit) case _: assert_never(p) return Notifier(channel, formatter)
if __name__ == "__main__": audit: list[str] = [] m = Message("ana", "Payment failed", "Card ending 4242 was declined.") print("urgent sms:") build_notifier(SmsChannel(), PlainText(), Priority.URGENT, audit).send(m) print("normal email:") build_notifier(EmailChannel(), PlainText(), Priority.NORMAL, audit).send(m) print("audit:", audit) # ['delivered to ana']using System;using System.Collections.Generic;
record Message(string To, string Title, string Body);
interface IChannel { bool Deliver(string to, string text); }
interface IFormatter { string Format(Message m); }
class EmailChannel : IChannel{ public bool Deliver(string to, string text) { Console.WriteLine($" email to {to}: {text}"); return true; }}
class SmsChannel : IChannel{ private int _calls;
public bool Deliver(string to, string text) { if (++_calls == 1) { Console.WriteLine(" sms gateway timeout"); return false; } var body = text.Length <= 160 ? text : text[..157] + "..."; Console.WriteLine($" sms to {to}: {body}"); return true; }}
class PlainText : IFormatter{ public string Format(Message m) => $"{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 IChannel (so callers can't tell it apart) and has-a IChannel (so it can delegate).record RetryingChannel(IChannel Inner, int Times) : IChannel{ public bool Deliver(string to, string text) { for (var 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(IChannel Inner, List<string> Log) : IChannel{ public bool Deliver(string to, string text) { var ok = Inner.Deliver(to, text); Log.Add(ok ? $"delivered to {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.sealed class Notifier{ private readonly IChannel _channel; private readonly IFormatter _formatter;
public Notifier(IChannel channel, IFormatter formatter) { _channel = channel; _formatter = formatter; }
public void Send(Message m) { if (!_channel.Deliver(m.To, _formatter.Format(m))) Console.WriteLine($" 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.static class Notifiers{ public static Notifier Build(IChannel baseChannel, IFormatter formatter, Priority p, List<string> audit) { IChannel channel = p switch { Priority.Normal => baseChannel, // 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. Priority.Urgent => new AuditedChannel(new RetryingChannel(baseChannel, 3), audit), _ => throw new ArgumentOutOfRangeException(nameof(p)), }; return new Notifier(channel, formatter); }}
static class Program{ static void Main() { var audit = new List<string>(); var m = new Message("ana", "Payment failed", "Card ending 4242 was declined."); Console.WriteLine("urgent sms:"); Notifiers.Build(new SmsChannel(), new PlainText(), Priority.Urgent, audit).Send(m); Console.WriteLine("normal email:"); Notifiers.Build(new EmailChannel(), new PlainText(), Priority.Normal, audit).Send(m); Console.WriteLine("audit: " + string.Join(", ", audit)); // delivered to ana }}package main
import "fmt"
type Message struct{ To, Title, Body string }
type Channel interface { Deliver(to, text string) bool}
type Formatter interface { Format(m Message) string}
type EmailChannel struct{}
func (EmailChannel) Deliver(to, text string) bool { fmt.Printf(" email to %s: %s\n", to, text) return true}
type SmsChannel struct{ calls int }
func (c *SmsChannel) Deliver(to, text string) bool { c.calls++ if c.calls == 1 { fmt.Println(" sms gateway timeout") return false } if len(text) > 160 { text = text[:157] + "..." } fmt.Printf(" sms to %s: %s\n", to, text) return true}
type PlainText struct{}
func (PlainText) Format(m Message) string { 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 type per combination.// It satisfies Channel (so callers can't tell it apart) and has a Channel (so it can delegate).type RetryingChannel struct { inner Channel times int}
func (r RetryingChannel) Deliver(to, text string) bool { for i := 1; i <= r.times; i++ { if r.inner.Deliver(to, text) { return true } } return false}
// Urgent messages are recorded for the on-call audit. A second behavior, written once.type AuditedChannel struct { inner Channel log *[]string}
func (a AuditedChannel) Deliver(to, text string) bool { ok := a.inner.Deliver(to, text) if ok { *a.log = append(*a.log, "delivered to "+to) } else { *a.log = append(*a.log, "failed for "+to) } return ok}
type Priority int
const ( Normal Priority = iota Urgent)
// Smaller than in stage 3: priority is no longer its concern. It formats and hands off.type Notifier struct { channel Channel formatter Formatter}
func (n Notifier) Send(m Message) { if !n.channel.Deliver(m.To, n.formatter.Format(m)) { fmt.Println(" gave up:", m.Title) }}
// BuildNotifier is 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.func BuildNotifier(base Channel, f Formatter, p Priority, audit *[]string) Notifier { channel := base if p == Urgent { // 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. channel = AuditedChannel{RetryingChannel{base, 3}, audit} } return Notifier{channel, f}}
func main() { var audit []string m := Message{"ana", "Payment failed", "Card ending 4242 was declined."} fmt.Println("urgent sms:") BuildNotifier(&SmsChannel{}, PlainText{}, Urgent, &audit).Send(m) fmt.Println("normal email:") BuildNotifier(EmailChannel{}, PlainText{}, Normal, &audit).Send(m) fmt.Println("audit:", audit) // [delivered to ana]}interface Message { readonly to: string; readonly title: string; readonly body: string;}
interface Channel { deliver(to: string, text: string): boolean;}
interface Formatter { format(m: Message): string;}
class EmailChannel implements Channel { deliver(to: string, text: string) { console.log(` email to ${to}: ${text}`); return true; }}
class SmsChannel implements Channel { private calls = 0;
deliver(to: string, text: string) { if (++this.calls === 1) { console.log(' sms gateway timeout'); return false; } const body = text.length <= 160 ? text : text.slice(0, 157) + '...'; console.log(` sms to ${to}: ${body}`); return true; }}
class PlainText implements Formatter { format(m: Message) { 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).class RetryingChannel implements Channel { constructor( private readonly inner: Channel, private readonly times: number, ) {} deliver(to: string, text: string) { for (let i = 1; i <= this.times; i++) if (this.inner.deliver(to, text)) return true; return false; }}
// Urgent messages are recorded for the on-call audit. A second behavior, written once.class AuditedChannel implements Channel { constructor( private readonly inner: Channel, private readonly log: string[], ) {} deliver(to: string, text: string) { const ok = this.inner.deliver(to, text); this.log.push(ok ? `delivered to ${to}` : `failed for ${to}`); return ok; }}
type Priority = 'normal' | 'urgent';
// Smaller than in stage 3: priority is no longer its concern. It formats and hands off.class Notifier { constructor( private readonly channel: Channel, private readonly formatter: Formatter, ) {} send(m: Message): void { if (!this.channel.deliver(m.to, this.formatter.format(m))) console.log(` 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.function buildNotifier(base: Channel, formatter: Formatter, p: Priority, audit: string[]): Notifier { const channel: Channel = p === 'urgent' ? // 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. new AuditedChannel(new RetryingChannel(base, 3), audit) : base; return new Notifier(channel, formatter);}
const audit: string[] = [];const m: Message = { to: 'ana', title: 'Payment failed', body: 'Card ending 4242 was declined.' };console.log('urgent sms:');buildNotifier(new SmsChannel(), new PlainText(), 'urgent', audit).send(m);console.log('normal email:');buildNotifier(new EmailChannel(), new PlainText(), 'normal', audit).send(m);console.log('audit:', audit); // [ 'delivered to ana' ]An urgent SMS now travels through two wrappers before it reaches the gateway:
- Color key
- Implementation
- Facade
What to notice
Section titled “What to notice”- The decorator is both is-a and has-a.
RetryingChannelimplementsChannel, so the notifier can’t tell it from a real channel. And it holds aChannel, 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.
Notifierno longer knows about priorities at all. A digest priority is a new case inNotifiers.buildand perhaps one new decorator. No channel or notifier is edited. - The
switchon priority is fine.Priorityis 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.
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.
When inheritance is the right call
Section titled “When inheritance is the right call”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:
// 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"))); }}from dataclasses import dataclassfrom typing import assert_never
# A closed set of cases. Python has no sealed classes, so the set is a union type: the three# results share no parent and no code, and a type checker (mypy, pyright) enforces the list.@dataclass(frozen=True)class Approved: auth_code: str
@dataclass(frozen=True)class Declined: reason: str
@dataclass(frozen=True)class NeedsAction: url: str
PaymentResult = Approved | Declined | NeedsAction
# assert_never makes the type checker report a missing case. Add a fourth result and this# function is flagged until it is handled: easy to add operations, hard to add cases.def describe(r: PaymentResult) -> str: match r: case Approved(auth_code=code): return f"approved, code {code}" case Declined(reason=reason): return f"declined: {reason}" case NeedsAction(url=url): return f"customer must confirm at {url}" case _: assert_never(r)
if __name__ == "__main__": print(describe(Approved("A91F"))) print(describe(Declined("insufficient funds"))) print(describe(NeedsAction("https://bank.example/3ds")))using System;using System.Diagnostics;
// A closed set of cases. C# has no "permits" list, so the usual trick is a private// constructor: only the nested classes can call it, so nobody can add a fourth result// elsewhere. (A record base would not do: records get a protected copy constructor that any// outside record could call.) This is inheritance used for the type only; the cases share no logic.abstract class PaymentResult{ private PaymentResult() { }
public sealed class Approved(string authCode) : PaymentResult { public string AuthCode { get; } = authCode; }
public sealed class Declined(string reason) : PaymentResult { public string Reason { get; } = reason; }
public sealed class NeedsAction(string url) : PaymentResult { public string Url { get; } = url; }}
static class Program{ // The compiler can't prove the set is closed, so the last arm is required. It is // unreachable as long as the private constructor holds. static string Describe(PaymentResult r) => r switch { PaymentResult.Approved a => $"approved, code {a.AuthCode}", PaymentResult.Declined d => $"declined: {d.Reason}", PaymentResult.NeedsAction n => $"customer must confirm at {n.Url}", _ => throw new UnreachableException(), };
static void Main() { Console.WriteLine(Describe(new PaymentResult.Approved("A91F"))); Console.WriteLine(Describe(new PaymentResult.Declined("insufficient funds"))); Console.WriteLine(Describe(new PaymentResult.NeedsAction("https://bank.example/3ds"))); }}package main
import "fmt"
// A closed set of cases. Go has no sealed types; the convention is an interface with an// unexported method, so only types in this package can be a PaymentResult.type PaymentResult interface{ isPaymentResult() }
type Approved struct{ AuthCode string }type Declined struct{ Reason string }type NeedsAction struct{ URL string }
func (Approved) isPaymentResult() {}func (Declined) isPaymentResult() {}func (NeedsAction) isPaymentResult() {}
// The compiler does not check that a type switch covers every case. A linter such as// go-sumtype (gochecksumtype in golangci-lint) does, and the default branch catches anything// missed at run time.func describe(r PaymentResult) string { switch v := r.(type) { case Approved: return "approved, code " + v.AuthCode case Declined: return "declined: " + v.Reason case NeedsAction: return "customer must confirm at " + v.URL default: panic(fmt.Sprintf("unhandled result %T", r)) }}
func main() { fmt.Println(describe(Approved{"A91F"})) fmt.Println(describe(Declined{"insufficient funds"})) fmt.Println(describe(NeedsAction{"https://bank.example/3ds"}))}// A closed set of cases. TypeScript writes it as a union with a "kind" tag, not a class// hierarchy: the cases share no code, and the compiler knows the full list.type PaymentResult = | { readonly kind: 'approved'; readonly authCode: string } | { readonly kind: 'declined'; readonly reason: string } | { readonly kind: 'needsAction'; readonly url: string };
// The never check makes a missing case a compile error. Add a fourth result and this// function fails to compile until it is handled: easy to add operations, hard to add cases.function describe(r: PaymentResult): string { switch (r.kind) { case 'approved': return `approved, code ${r.authCode}`; case 'declined': return `declined: ${r.reason}`; case 'needsAction': return `customer must confirm at ${r.url}`; default: { const missing: never = r; throw new Error(`unhandled result ${JSON.stringify(missing)}`); } }}
console.log(describe({ kind: 'approved', authCode: 'A91F' }));console.log(describe({ kind: 'declined', reason: 'insufficient funds' }));console.log(describe({ kind: 'needsAction', url: 'https://bank.example/3ds' }));A quick test before writing extends:
- 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. - Will the child keep every promise the parent makes? If any method would throw “not supported” or ignore its input, it fails Liskov substitution.
- 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.
- 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:
// 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 }}from dataclasses import dataclassimport json
# Python: mixins. A mixin is a small class meant to be one of several parents. It adds# behavior written in terms of attributes the final class provides (id, actor).class AuditMixin: id: str actor: str
def audit_line(self) -> str: return f"{type(self).__name__} {self.id} by {self.actor}"
class JsonMixin: def to_json(self) -> str: return json.dumps(vars(self))
@dataclassclass Order(AuditMixin): id: str actor: str total_cents: int
# Several parents are allowed. When two define the same method, the method resolution order# (MRO, C3 linearization) decides which runs: left to right, each class before its parents.@dataclassclass Refund(AuditMixin, JsonMixin): id: str actor: str order_id: str
if __name__ == "__main__": print(Order("o-17", "ana", 2599).audit_line()) # Order o-17 by ana r = Refund("r-3", "support", "o-17") print(r.audit_line(), r.to_json()) print([c.__name__ for c in Refund.__mro__]) # Refund, AuditMixin, JsonMixin, objectusing System;
// C#: a default interface method (C# 8 and later). The interface supplies behavior written// in terms of its own members, so every implementer gets AuditLine without a shared parent.// Interfaces hold no instance fields, so this works only for behavior built on other members.interface IAuditable{ string Id { get; } string Actor { get; }
string AuditLine() => $"{GetType().Name} {Id} by {Actor}";}
// Records can only extend other records, but they can implement any number of interfaces.record Order(string Id, string Actor, int TotalCents) : IAuditable;
record Refund(string Id, string Actor, string OrderId) : IAuditable;
static class Program{ static void Main() { // A default method belongs to the interface, not the class: new Order(...).AuditLine() // does not compile. Callers reach it through an IAuditable reference. IAuditable order = new Order("o-17", "ana", 2599); IAuditable refund = new Refund("r-3", "support", "o-17"); Console.WriteLine(order.AuditLine()); // Order o-17 by ana Console.WriteLine(refund.AuditLine()); // Refund r-3 by support }}package main
import "fmt"
// Go: embedding. Go has no inheritance at all. Embedding a struct promotes its fields and// methods onto the outer struct, which reads like inheritance but is composition: the outer// struct holds an Audit value, and AuditLine runs on that value.type Audit struct { Kind, ID, Actor string}
// AuditLine can see only Audit's own fields. It can't ask the outer struct for anything,// because an embedded value has no reference back to the struct that holds it.func (a Audit) AuditLine() string { return a.Kind + " " + a.ID + " by " + a.Actor }
type Order struct { Audit TotalCents int}
type Refund struct { Audit OrderID string}
// Callers depend on the behavior through an interface, which both types satisfy.type Auditable interface{ AuditLine() string }
func main() { items := []Auditable{ Order{Audit{"Order", "o-17", "ana"}, 2599}, Refund{Audit{"Refund", "r-3", "support"}, "o-17"}, } for _, it := range items { fmt.Println(it.AuditLine()) // Order o-17 by ana, then Refund r-3 by support }}// TypeScript: a mixin is a function that takes a class and returns a subclass of it with// extra behavior. It is still single inheritance underneath: each mixin adds one link to// the prototype chain, applied in the order the functions are called.type Constructor<T = object> = new (...args: any[]) => T;
function Auditable<TBase extends Constructor<{ id: string; actor: string }>>(Base: TBase) { return class extends Base { auditLine(): string { return `${this.constructor.name} ${this.id} by ${this.actor}`; } };}
class Entity { constructor( readonly id: string, readonly actor: string, ) {}}
class Order extends Auditable(Entity) { totalCents = 2599;}
class Refund extends Auditable(Entity) { orderId = 'o-17';}
console.log(new Order('o-17', 'ana').auditLine()); // Order o-17 by anaconsole.log(new Refund('r-3', 'support').auditLine()); // Refund r-3 by supportThe 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.
Tradeoffs
Section titled “Tradeoffs”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.
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| 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. |
Where it shows up
Section titled “Where it shows up”On this site:
- Notification service: the full version of this page’s
example, with every channel behind one
Channelinterface and retries with backoff behind a queue. - Library management: a borrowing policy object instead of
StudentMemberandFacultyMembersubclasses, 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
AsyncFileAppendersubclass. - Chess: a piece is a kind and a color, and each kind’s movement is a
MoveRulestrategy. 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
VipRoomsubclass.
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.
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- 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
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.