Four ideas every interviewer assumes you have, and the one design rule that sits under most patterns: build behaviour out of parts you can swap, and keep inheritance for the cases where it is honestly an "is-a".
Open a payments app and look at your wallet balance. Nobody can type a new number into it. You can add money or you can pay, and if you try to pay more than you have, the app says no. That is not a screen design choice. It is how the code behind the wallet is built: the balance is only ever changed by a few operations, and each one checks the rules first.
This lesson is about that kind of design. It defines the four ideas that go by the name of object-oriented programming, each with a precise meaning and a before-and-after you can run: encapsulation, abstraction, inheritance and polymorphism. It then takes the one that is easiest to overuse, inheritance, and shows the situation where it hurts (a notifier whose subclasses multiply) and the rule that fixes it: favour composition. It ends with a distinction the case studies lean on, entities and value objects, and with where inheritance is the right tool after all.
You will see these ideas in the parking lot (where vehicles deliberately get no subclasses), in the LRU cache (where the cache has a policy) and in chess (where inheritance is used on purpose, and why that one is fine).
A cash machine gives you a keypad, not the drawer: you can deposit, withdraw and check, and the rules refuse the rest, for everyone.
The first version: a balance anyone can set#
The first wallet most people write is a class with one field. It is short, it works, and it has no rules at all:
# wallet_v1.py: the balance is a plain field, so any code can change it
class Wallet:
def __init__(self) -> None:
self.balance = 0 # rupees
def top_up(wallet: Wallet, rupees: int) -> None:
wallet.balance += rupees
def pay_for_dinner(wallet: Wallet, rupees: int) -> None:
wallet.balance -= rupees # forgot to check there is enough
wallet = Wallet()
top_up(wallet, 500)
pay_for_dinner(wallet, 800)
print(f"balance: {wallet.balance}") # -300// WalletV1.java: the balance is a plain field, so any code can change it
class WalletV1 {
static class Wallet {
int balance = 0; // rupees
}
static void topUp(Wallet wallet, int rupees) { wallet.balance += rupees; }
static void payForDinner(Wallet wallet, int rupees) { wallet.balance -= rupees; } // forgot to check there is enough
public static void main(String[] args) {
Wallet wallet = new Wallet();
topUp(wallet, 500);
payForDinner(wallet, 800);
System.out.println("balance: " + wallet.balance); // -300
}
}// wallet_v1.cpp: the balance is a plain field, so any code can change it
#include <iostream>
struct Wallet {
int balance = 0; // rupees
};
void topUp(Wallet& wallet, int rupees) { wallet.balance += rupees; }
void payForDinner(Wallet& wallet, int rupees) { wallet.balance -= rupees; } // forgot to check there is enough
int main() {
Wallet wallet;
topUp(wallet, 500);
payForDinner(wallet, 800);
std::cout << "balance: " << wallet.balance << "\n"; // -300
}// walletV1.js: the balance is a plain field, so any code can change it
class Wallet {
constructor() {
this.balance = 0; // rupees
}
}
function topUp(wallet, rupees) {
wallet.balance += rupees;
}
function payForDinner(wallet, rupees) {
wallet.balance -= rupees; // forgot to check there is enough
}
const wallet = new Wallet();
topUp(wallet, 500);
payForDinner(wallet, 800);
console.log(`balance: ${wallet.balance}`); // -300balance: -300A wallet that holds minus 300 rupees is a bug, and the bug is not in any one line. pay_for_dinner is the careless caller, but the real fault is that the wallet let it happen. The rule "never below zero" belongs to the wallet, and the wallet has no way to enforce it.
- Every caller has to remember the rule. Checkout, refunds, the promo engine and the support tool each need their own check, and the one that forgets corrupts the balance.
- A rule change is a hunt. "Cap a wallet at 10,000 rupees" means finding every line that touches
balance. - There is nowhere to look when it goes wrong. Any line in the program could have set the number, so you cannot put a log, an audit trail or a breakpoint in one spot.
- Tests have to cover every caller, not the wallet.
Where should "never below zero" live?
The wallet must never hold a negative balance. Checkout, refunds and the support tool all change it. Where do you put the rule?
One strong answer
In the wallet, in the one operation every change goes through. Not in checkout, not in each caller. If the rule lives in Wallet.withdraw, a caller cannot forget it, there is one place to test it, and a rule change is one edit. That is encapsulation, and the next section builds it.
Encapsulation: one door in#
Encapsulation means two things together: an object keeps its data to itself, and it lets that data change only through operations that enforce the object's rules. The point is not hiding for its own sake. The point is that the rules live in one place, so the object can never be in a state its own methods would refuse to create. (Another word for such a rule is an invariant: something that must be true every time the object is at rest. For a wallet: the balance is never negative.)
# wallet.py: the balance is private; every change goes through a method that checks the rule
class WalletError(Exception):
"""Base class: a caller can catch every wallet problem with one except."""
class InsufficientFunds(WalletError):
pass
class Wallet:
def __init__(self) -> None:
self._balance = 0 # rupees; the underscore means "not for callers"
@property
def balance(self) -> int: # read-only: there is no setter
return self._balance
def deposit(self, rupees: int) -> None:
if rupees <= 0:
raise WalletError(f"deposit must be positive, got {rupees}")
self._balance += rupees
def withdraw(self, rupees: int) -> None:
if rupees <= 0:
raise WalletError(f"withdrawal must be positive, got {rupees}")
if rupees > self._balance:
raise InsufficientFunds(f"need {rupees}, have {self._balance}")
self._balance -= rupees
wallet = Wallet()
wallet.deposit(500)
print(f"balance: {wallet.balance}")
wallet.withdraw(200)
print(f"balance: {wallet.balance}")
try:
wallet.withdraw(1000)
except WalletError as e: # also catches InsufficientFunds
print(f"rejected: {e}")
print(f"balance: {wallet.balance}")// WalletDemo.java: the balance is private; every change goes through a method that checks the rule
class WalletDemo {
/** Base class: a caller can catch every wallet problem with one catch. */
static class WalletError extends RuntimeException {
WalletError(String message) { super(message); }
}
static class InsufficientFunds extends WalletError {
InsufficientFunds(String message) { super(message); }
}
static final class Wallet {
private int balance = 0; // rupees
int balance() { return balance; } // read-only: there is no setter
void deposit(int rupees) {
if (rupees <= 0) throw new WalletError("deposit must be positive, got " + rupees);
balance += rupees;
}
void withdraw(int rupees) {
if (rupees <= 0) throw new WalletError("withdrawal must be positive, got " + rupees);
if (rupees > balance) throw new InsufficientFunds("need " + rupees + ", have " + balance);
balance -= rupees;
}
}
public static void main(String[] args) {
Wallet wallet = new Wallet();
wallet.deposit(500);
System.out.println("balance: " + wallet.balance());
wallet.withdraw(200);
System.out.println("balance: " + wallet.balance());
try {
wallet.withdraw(1000);
} catch (WalletError e) { // also catches InsufficientFunds
System.out.println("rejected: " + e.getMessage());
}
System.out.println("balance: " + wallet.balance());
}
}// wallet.cpp: the balance is private; every change goes through a method that checks the rule
#include <iostream>
#include <stdexcept>
#include <string>
// Base class: a caller can catch every wallet problem with one catch.
class WalletError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
class InsufficientFunds : public WalletError {
public:
using WalletError::WalletError;
};
class Wallet {
int balance_ = 0; // rupees
public:
int balance() const { return balance_; } // read-only: there is no setter
void deposit(int rupees) {
if (rupees <= 0) throw WalletError("deposit must be positive, got " + std::to_string(rupees));
balance_ += rupees;
}
void withdraw(int rupees) {
if (rupees <= 0) throw WalletError("withdrawal must be positive, got " + std::to_string(rupees));
if (rupees > balance_)
throw InsufficientFunds("need " + std::to_string(rupees) + ", have " + std::to_string(balance_));
balance_ -= rupees;
}
};
int main() {
Wallet wallet;
wallet.deposit(500);
std::cout << "balance: " << wallet.balance() << "\n";
wallet.withdraw(200);
std::cout << "balance: " << wallet.balance() << "\n";
try {
wallet.withdraw(1000);
} catch (const WalletError& e) { // also catches InsufficientFunds
std::cout << "rejected: " << e.what() << "\n";
}
std::cout << "balance: " << wallet.balance() << "\n";
}// wallet.js: the balance is private; every change goes through a method that checks the rule
class WalletError extends Error {} // base class: a caller can catch every wallet problem with one catch
class InsufficientFunds extends WalletError {}
class Wallet {
#balance = 0; // rupees; a # field is private to the class
get balance() {
return this.#balance; // read-only: there is no setter
}
deposit(rupees) {
if (rupees <= 0) throw new WalletError(`deposit must be positive, got ${rupees}`);
this.#balance += rupees;
}
withdraw(rupees) {
if (rupees <= 0) throw new WalletError(`withdrawal must be positive, got ${rupees}`);
if (rupees > this.#balance) throw new InsufficientFunds(`need ${rupees}, have ${this.#balance}`);
this.#balance -= rupees;
}
}
const wallet = new Wallet();
wallet.deposit(500);
console.log(`balance: ${wallet.balance}`);
wallet.withdraw(200);
console.log(`balance: ${wallet.balance}`);
try {
wallet.withdraw(1000);
} catch (e) {
if (!(e instanceof WalletError)) throw e; // also matches InsufficientFunds
console.log(`rejected: ${e.message}`);
}
console.log(`balance: ${wallet.balance}`);balance: 500
balance: 300
rejected: need 1000, have 300
balance: 300The balance can now only go up through deposit and down through withdraw, and the check for "more than I have" sits in exactly one place. The refused withdrawal left the balance at 300: a failed operation changes nothing.
What each language brings to the same design:
- Python: privacy is a convention.
_balancetells callers to keep out, and the property with no setter makeswallet.balance = 5fail with anAttributeError, but a determined caller can still write towallet._balance. Python trusts you; the discipline is yours. - Java and C++:
privateis enforced at compile time. A caller that writeswallet.balance = -300does not build. - JavaScript: a
#balancefield is private to the class body, and the language enforces it: code outside the class that nameswallet.#balancedoes not even parse.
Every change to the balance goes through withdraw, which checks the rule first. A refused call leaves the wallet exactly as it was.
Abstraction and polymorphism: depend on what, not how#
Abstraction is deciding what callers get to know. You describe a piece of the system as a small contract (what it does, what it needs, what it promises) and hide how it does it. The contract can be an interface, an abstract class, or just a method name that everyone agrees on. Encapsulation protects the inside of an object; abstraction shapes the outside.
Polymorphism (here, the kind the course always means: subtype polymorphism) is what you get when several classes meet the same contract: one call, channel.send(...), runs different code depending on the actual object it lands on. The caller writes the call once and never checks which class it is talking to.
# channels.py: callers know the contract (send), not the class behind it
from abc import ABC, abstractmethod
class Channel(ABC):
"""The contract: deliver one message to one user and report what was sent."""
@abstractmethod
def send(self, user: str, text: str) -> str: ...
class Email(Channel):
def send(self, user: str, text: str) -> str:
return f"email to {user}: {text}"
class Sms(Channel):
def send(self, user: str, text: str) -> str:
return f"sms to {user}: {text}"
class Push(Channel):
def send(self, user: str, text: str) -> str:
return f"push to {user}: {text}"
for channel in [Email(), Sms(), Push()]: # one call, three behaviours
print(channel.send("Asha", "Order shipped"))// Channels.java: callers know the contract (send), not the class behind it
import java.util.List;
class Channels {
/** The contract: deliver one message to one user and report what was sent. */
interface Channel {
String send(String user, String text);
}
static final class Email implements Channel {
public String send(String user, String text) { return "email to " + user + ": " + text; }
}
static final class Sms implements Channel {
public String send(String user, String text) { return "sms to " + user + ": " + text; }
}
static final class Push implements Channel {
public String send(String user, String text) { return "push to " + user + ": " + text; }
}
public static void main(String[] args) {
for (Channel channel : List.of(new Email(), new Sms(), new Push())) { // one call, three behaviours
System.out.println(channel.send("Asha", "Order shipped"));
}
}
}// channels.cpp: callers know the contract (send), not the class behind it
#include <iostream>
#include <memory>
#include <string>
#include <vector>
// The contract: deliver one message to one user and report what was sent.
class Channel {
public:
virtual ~Channel() = default;
virtual std::string send(const std::string& user, const std::string& text) const = 0;
};
class Email : public Channel {
public:
std::string send(const std::string& user, const std::string& text) const override { return "email to " + user + ": " + text; }
};
class Sms : public Channel {
public:
std::string send(const std::string& user, const std::string& text) const override { return "sms to " + user + ": " + text; }
};
class Push : public Channel {
public:
std::string send(const std::string& user, const std::string& text) const override { return "push to " + user + ": " + text; }
};
int main() {
std::vector<std::unique_ptr<Channel>> channels;
channels.push_back(std::make_unique<Email>());
channels.push_back(std::make_unique<Sms>());
channels.push_back(std::make_unique<Push>());
for (const auto& channel : channels) { // one call, three behaviours
std::cout << channel->send("Asha", "Order shipped") << "\n";
}
}// channels.js: callers know the contract (send), not the class behind it
// The contract: any object with send(user, text) that returns what was sent.
// JavaScript has no interfaces, so the contract is the method name.
class Email {
send(user, text) {
return `email to ${user}: ${text}`;
}
}
class Sms {
send(user, text) {
return `sms to ${user}: ${text}`;
}
}
class Push {
send(user, text) {
return `push to ${user}: ${text}`;
}
}
for (const channel of [new Email(), new Sms(), new Push()]) { // one call, three behaviours
console.log(channel.send("Asha", "Order shipped"));
}email to Asha: Order shipped
sms to Asha: Order shipped
push to Asha: Order shippedThe loop never asks "is this an email?". It calls send and each object answers in its own way. Adding a fourth channel changes none of the loop. That is the payoff of an abstraction: the code that uses the contract and the code that implements it can change independently.
Two things to keep apart from it. Overloading (in Java and C++: one method name with different parameter lists, picked by the compiler from the argument types) is sometimes called compile-time polymorphism; it is a different mechanism, and when this course says polymorphism it means the run-time kind above. And a long if type == ... chain is not polymorphism at all: it is the thing polymorphism replaces.
What each language uses for the contract:
- Java: an
interface. A class that saysimplements Channeland forgetssenddoes not compile. - C++: a class of pure
virtualfunctions (= 0), with avirtualdestructor. The destructor matters: deleting anEmailthrough aChannel*without it is undefined behaviour. - Python: an
ABCwith@abstractmethod. A subclass that forgetssendfails when you try to create it (TypeError). Python also accepts plain duck typing: any object with asendmethod works in the loop, ABC or not. - JavaScript: duck typing only. The contract is the method name, and a missing
sendfails only when it is called (channel.send is not a function).
Inheritance: when "is-a" is true#
Inheritance lets a class take on another class's data and behaviour, add to it and override parts of it. It does two jobs at once: it reuses code, and it makes the subclass usable anywhere the parent is expected (the "is-a" relationship). The second job is the one that matters, and it carries a condition: if code written for the parent receives a subclass, it must still work. The next-but-one lesson names that condition the Liskov substitution principle.
Inheritance is a good fit when three things hold:
- It is honestly an "is-a" and the subclass keeps every promise the parent makes.
- The hierarchy is shallow and stable. A parent and a few kinds of it, and the family does not grow every sprint.
- Things vary along one axis only.
You have already used it correctly. In the wallet above, InsufficientFunds is a WalletError: any code that handles wallet errors handles it too, and the one except WalletError caught it. In chess, Rook, Bishop and Queen are all sliders that differ only in the directions they slide, so Slider holds the shared walking code and each piece adds one line of data. Both cases are one axis, shallow, and every subclass keeps its parent's promises.
Where it hurts is when behaviour varies in more than one direction at once.
When a notifier grows a subclass per combination#
An app tells a customer their order shipped. Some customers want email, some want email and SMS, some want everything. The first design most people reach for is a subclass per case:
# senders_v1.py: a subclass for every combination of channels
class EmailSender:
def send(self, user: str, text: str) -> list:
return [f"email to {user}: {text}"]
class EmailSmsSender(EmailSender): # inherits the email line, adds sms
def send(self, user: str, text: str) -> list:
return super().send(user, text) + [f"sms to {user}: {text}"]
class EmailSmsPushSender(EmailSmsSender): # inherits both lines, adds push
def send(self, user: str, text: str) -> list:
return super().send(user, text) + [f"push to {user}: {text}"]
print("email only:", " | ".join(EmailSender().send("Asha", "Order shipped")))
print("email + sms:", " | ".join(EmailSmsSender().send("Asha", "Order shipped")))
print("all three:", " | ".join(EmailSmsPushSender().send("Asha", "Order shipped")))// SendersV1.java: a subclass for every combination of channels
import java.util.ArrayList;
import java.util.List;
class SendersV1 {
static class EmailSender {
List<String> send(String user, String text) {
return new ArrayList<>(List.of("email to " + user + ": " + text));
}
}
static class EmailSmsSender extends EmailSender { // inherits the email line, adds sms
@Override List<String> send(String user, String text) {
List<String> lines = super.send(user, text);
lines.add("sms to " + user + ": " + text);
return lines;
}
}
static class EmailSmsPushSender extends EmailSmsSender { // inherits both lines, adds push
@Override List<String> send(String user, String text) {
List<String> lines = super.send(user, text);
lines.add("push to " + user + ": " + text);
return lines;
}
}
public static void main(String[] args) {
System.out.println("email only: " + String.join(" | ", new EmailSender().send("Asha", "Order shipped")));
System.out.println("email + sms: " + String.join(" | ", new EmailSmsSender().send("Asha", "Order shipped")));
System.out.println("all three: " + String.join(" | ", new EmailSmsPushSender().send("Asha", "Order shipped")));
}
}// senders_v1.cpp: a subclass for every combination of channels
#include <iostream>
#include <string>
#include <vector>
std::string join(const std::vector<std::string>& lines) {
std::string out;
for (const auto& line : lines) out += (out.empty() ? "" : " | ") + line;
return out;
}
class EmailSender {
public:
virtual ~EmailSender() = default;
virtual std::vector<std::string> send(const std::string& user, const std::string& text) const {
return {"email to " + user + ": " + text};
}
};
class EmailSmsSender : public EmailSender { // inherits the email line, adds sms
public:
std::vector<std::string> send(const std::string& user, const std::string& text) const override {
auto lines = EmailSender::send(user, text);
lines.push_back("sms to " + user + ": " + text);
return lines;
}
};
class EmailSmsPushSender : public EmailSmsSender { // inherits both lines, adds push
public:
std::vector<std::string> send(const std::string& user, const std::string& text) const override {
auto lines = EmailSmsSender::send(user, text);
lines.push_back("push to " + user + ": " + text);
return lines;
}
};
int main() {
std::cout << "email only: " << join(EmailSender().send("Asha", "Order shipped")) << "\n";
std::cout << "email + sms: " << join(EmailSmsSender().send("Asha", "Order shipped")) << "\n";
std::cout << "all three: " << join(EmailSmsPushSender().send("Asha", "Order shipped")) << "\n";
}// sendersV1.js: a subclass for every combination of channels
class EmailSender {
send(user, text) {
return [`email to ${user}: ${text}`];
}
}
class EmailSmsSender extends EmailSender { // inherits the email line, adds sms
send(user, text) {
return [...super.send(user, text), `sms to ${user}: ${text}`];
}
}
class EmailSmsPushSender extends EmailSmsSender { // inherits both lines, adds push
send(user, text) {
return [...super.send(user, text), `push to ${user}: ${text}`];
}
}
console.log("email only:", new EmailSender().send("Asha", "Order shipped").join(" | "));
console.log("email + sms:", new EmailSmsSender().send("Asha", "Order shipped").join(" | "));
console.log("all three:", new EmailSmsPushSender().send("Asha", "Order shipped").join(" | "));email only: email to Asha: Order shipped
email + sms: email to Asha: Order shipped | sms to Asha: Order shipped
all three: email to Asha: Order shipped | sms to Asha: Order shipped | push to Asha: Order shippedIt works for the three cases shown, and inheritance even saved some typing: each class reuses its parent's lines and adds one. Now count what it takes to cover everyone. Each channel is in or out of a customer's choice, so 3 channels give 2 × 2 × 2 = 8 choices, minus "none at all": 7 classes, and the code above wrote 3 of them. A fourth channel (an in-app banner) takes it to 15. In general, n channels need 2n − 1 classes. And the reuse only works along one path: SmsPushSender has no parent that already sends SMS and push, so it copies those lines again.
- The class count roughly doubles with each channel: 3, 7, 15, 31. The code you write grows faster than the feature.
- The same line lives in many classes. Changing how SMS text is worded means finding every class that sends SMS.
- A second axis multiplies it again. Add "skip push between 11 pm and 7 am" and every class containing push needs the change, or a parallel set of
Quiet...classes. - The choice is baked into the class. A customer who turns SMS off in settings needs a different class, not a different setting.
- The "is-a" is false. An
EmailSmsSenderis not a kind ofEmailSender: code that asks anEmailSenderfor "the email" gets an SMS too. It is a sender that has an email part and an SMS part.
A subclass per combination of channels doubles with every channel, plus one: 3 channels need 7 classes, 4 need 15, 5 need 31.
Is-a, or has-a?
Each customer picks their own set of channels, and can change it in settings at any time. Is the notifier a subclass of a channel, or does it hold channels?
One strong answer
It holds them. A notifier is not a kind of email; it is something that has a list of channels and sends through each. "Has-a" with a list lets the list change while the app runs and gain a new channel without a new class. A subclass is fixed when the object is created, and every new combination needs a new class.
Composition: build with parts#
The book that named the design patterns (Design Patterns, 1994, by Gamma, Helm, Johnson and Vlissides) states the rule in one line: "Favor object composition over class inheritance." Composition means a class gets its behaviour by holding other objects and delegating to them, instead of by inheriting from a parent. The behaviour you get depends on which parts you plug in, so you can change it without writing a new class.
# notifier.py: the notifier has channels; a combination is a list, not a class
from abc import ABC, abstractmethod
class Channel(ABC):
@abstractmethod
def send(self, user: str, text: str) -> str: ...
class Email(Channel):
def send(self, user: str, text: str) -> str:
return f"email to {user}: {text}"
class Sms(Channel):
def send(self, user: str, text: str) -> str:
return f"sms to {user}: {text}"
class Push(Channel):
def send(self, user: str, text: str) -> str:
return f"push to {user}: {text}"
class InApp(Channel): # the fourth channel: one new class, no combinations
def send(self, user: str, text: str) -> str:
return f"in-app to {user}: {text}"
class Notifier:
def __init__(self, channels: list) -> None:
self.channels = channels # has-a: the parts decide the behaviour
def send(self, user: str, text: str) -> list:
return [c.send(user, text) for c in self.channels]
def show(label: str, notifier: Notifier) -> None:
print(f"{label}:", " | ".join(notifier.send("Asha", "Order shipped")))
show("email only", Notifier([Email()]))
show("email + sms", Notifier([Email(), Sms()]))
show("all three", Notifier([Email(), Sms(), Push()]))
show("all four", Notifier([Email(), Sms(), Push(), InApp()]))// NotifierDemo.java: the notifier has channels; a combination is a list, not a class
import java.util.ArrayList;
import java.util.List;
class NotifierDemo {
interface Channel {
String send(String user, String text);
}
static final class Email implements Channel {
public String send(String user, String text) { return "email to " + user + ": " + text; }
}
static final class Sms implements Channel {
public String send(String user, String text) { return "sms to " + user + ": " + text; }
}
static final class Push implements Channel {
public String send(String user, String text) { return "push to " + user + ": " + text; }
}
static final class InApp implements Channel { // the fourth channel: one new class, no combinations
public String send(String user, String text) { return "in-app to " + user + ": " + text; }
}
static final class Notifier {
private final List<Channel> channels; // has-a: the parts decide the behaviour
Notifier(List<Channel> channels) { this.channels = channels; }
List<String> send(String user, String text) {
List<String> sent = new ArrayList<>();
for (Channel c : channels) sent.add(c.send(user, text));
return sent;
}
}
static void show(String label, Notifier notifier) {
System.out.println(label + ": " + String.join(" | ", notifier.send("Asha", "Order shipped")));
}
public static void main(String[] args) {
show("email only", new Notifier(List.of(new Email())));
show("email + sms", new Notifier(List.of(new Email(), new Sms())));
show("all three", new Notifier(List.of(new Email(), new Sms(), new Push())));
show("all four", new Notifier(List.of(new Email(), new Sms(), new Push(), new InApp())));
}
}// notifier.cpp: the notifier has channels; a combination is a list, not a class
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Channel {
public:
virtual ~Channel() = default;
virtual std::string send(const std::string& user, const std::string& text) const = 0;
};
class Email : public Channel {
public:
std::string send(const std::string& user, const std::string& text) const override { return "email to " + user + ": " + text; }
};
class Sms : public Channel {
public:
std::string send(const std::string& user, const std::string& text) const override { return "sms to " + user + ": " + text; }
};
class Push : public Channel {
public:
std::string send(const std::string& user, const std::string& text) const override { return "push to " + user + ": " + text; }
};
class InApp : public Channel { // the fourth channel: one new class, no combinations
public:
std::string send(const std::string& user, const std::string& text) const override { return "in-app to " + user + ": " + text; }
};
class Notifier {
std::vector<std::shared_ptr<const Channel>> channels_; // has-a: the parts decide the behaviour
public:
explicit Notifier(std::vector<std::shared_ptr<const Channel>> channels) : channels_(std::move(channels)) {}
std::string send(const std::string& user, const std::string& text) const {
std::string out;
for (const auto& c : channels_) out += (out.empty() ? "" : " | ") + c->send(user, text);
return out;
}
};
void show(const std::string& label, const Notifier& notifier) {
std::cout << label << ": " << notifier.send("Asha", "Order shipped") << "\n";
}
int main() {
auto email = std::make_shared<Email>();
auto sms = std::make_shared<Sms>();
auto push = std::make_shared<Push>();
auto inApp = std::make_shared<InApp>();
show("email only", Notifier({email}));
show("email + sms", Notifier({email, sms}));
show("all three", Notifier({email, sms, push}));
show("all four", Notifier({email, sms, push, inApp}));
}// notifier.js: the notifier has channels; a combination is a list, not a class
// A channel is any object with send(user, text) that returns what was sent.
class Email {
send(user, text) {
return `email to ${user}: ${text}`;
}
}
class Sms {
send(user, text) {
return `sms to ${user}: ${text}`;
}
}
class Push {
send(user, text) {
return `push to ${user}: ${text}`;
}
}
class InApp { // the fourth channel: one new class, no combinations
send(user, text) {
return `in-app to ${user}: ${text}`;
}
}
class Notifier {
constructor(channels) {
this.channels = channels; // has-a: the parts decide the behaviour
}
send(user, text) {
return this.channels.map((c) => c.send(user, text));
}
}
function show(label, notifier) {
console.log(`${label}:`, notifier.send("Asha", "Order shipped").join(" | "));
}
show("email only", new Notifier([new Email()]));
show("email + sms", new Notifier([new Email(), new Sms()]));
show("all three", new Notifier([new Email(), new Sms(), new Push()]));
show("all four", new Notifier([new Email(), new Sms(), new Push(), new InApp()]));email only: email to Asha: Order shipped
email + sms: email to Asha: Order shipped | sms to Asha: Order shipped
all three: email to Asha: Order shipped | sms to Asha: Order shipped | push to Asha: Order shipped
all four: email to Asha: Order shipped | sms to Asha: Order shipped | push to Asha: Order shipped | in-app to Asha: Order shippedThe first three lines are exactly what the subclass version printed, so nothing a user sees has changed. The fourth line is the gain: the in-app channel is one new class, and all 15 combinations already exist, because a combination is just a list. Each channel's wording lives in one class. A customer's settings can build a different Notifier per customer, at run time.
%% title: Composition: the notifier has channels
classDiagram
class Notifier {
-channels list
+send(user, text) list
}
class Channel {
<<interface>>
+send(user, text) str
}
class Email
class Sms
class Push
class InApp
Notifier --> "*" Channel : sends through
Channel <|.. Email
Channel <|.. Sms
Channel <|.. Push
Channel <|.. InAppRead the arrows. The solid arrow from Notifier to Channel is "has": the notifier holds any number of channels and knows only the interface. The dashed arrows with hollow heads are "implements": each channel meets the contract. There is no arrow between Email and Sms, and that is the design: no channel knows about another, so none of the 15 combinations needs a class. (The next lesson teaches every arrow in full.)
Notice what composition did not get rid of: the Channel contract and polymorphism are still there. "Favour composition over inheritance" does not mean "no interfaces". It means that you pick behaviour by plugging in objects behind an interface, instead of by subclassing the whole class.
Inheritance fits when
- The subclass is a true kind of the parent and keeps its promises
- It varies on one axis and the family is shallow and stable
- Callers treat any subclass as the parent (
InsufficientFundsasWalletError)
Composition fits when
- Behaviour varies on more than one axis, or combinations appear
- The choice should change at run time, or per user
- You want each part tested alone, and swapped for a fake in a test
Composition has costs of its own: more small objects, a little forwarding code, and an interface to design for the parts. That is why the rule says favour, not always. The cost is worth paying when behaviour varies; it is wasted when nothing does.
The notifier holds a list of channels and knows only their contract, so a new channel is one new class and every combination already works.
Entities and value objects#
One more distinction runs through the case studies, and it follows from encapsulation. Some objects are entities: they are identified by an id that stays the same while their data changes. Others are value objects: they have no identity of their own and are defined entirely by their values.
- An entity (a member, an order, a wallet) is the same one after you change its name or its balance. Two members who share the name Asha are still two different people, because their ids differ. Equality compares the id.
- A value object (an amount of money, a date range, a recurrence rule) is interchangeable with any other with the same values. Two 100-rupee amounts are the same amount. It is not edited: you build a new one (
100 + 50is a newMoney(150)), which makes it safe to share and to use as a key. Equality compares every field.
# value_entity.py: Money is a value (equal by amount); Member is an entity (equal by id)
from dataclasses import dataclass
@dataclass(frozen=True) # frozen: a Money cannot be edited after it is made
class Money:
rupees: int
def plus(self, other: "Money") -> "Money":
return Money(self.rupees + other.rupees) # a new value, not an edit
class Member:
def __init__(self, member_id: str, name: str) -> None:
self.member_id = member_id
self.name = name
def __eq__(self, other: object) -> bool:
return isinstance(other, Member) and self.member_id == other.member_id # identity is the id
def __hash__(self) -> int:
return hash(self.member_id)
def yes(flag: bool) -> str:
return "yes" if flag else "no"
a = Money(100)
total = a.plus(Money(50))
print("two Money(100) equal:", yes(a == Money(100)))
print(f"a is still {a.rupees}, total is {total.rupees}")
print("M1 after a rename, same member:", yes(Member("M1", "Asha") == Member("M1", "Asha K")))
print("M1 and M2, both named Asha, same member:", yes(Member("M1", "Asha") == Member("M2", "Asha")))// ValueEntity.java: Money is a value (equal by amount); Member is an entity (equal by id)
class ValueEntity {
record Money(int rupees) { // a record is immutable and equal by its fields
Money plus(Money other) { return new Money(rupees + other.rupees); } // a new value, not an edit
}
static final class Member {
final String memberId;
final String name;
Member(String memberId, String name) { this.memberId = memberId; this.name = name; }
@Override public boolean equals(Object o) { return o instanceof Member m && memberId.equals(m.memberId); } // identity is the id
@Override public int hashCode() { return memberId.hashCode(); }
}
static String yes(boolean flag) { return flag ? "yes" : "no"; }
public static void main(String[] args) {
Money a = new Money(100);
Money total = a.plus(new Money(50));
System.out.println("two Money(100) equal: " + yes(a.equals(new Money(100))));
System.out.println("a is still " + a.rupees() + ", total is " + total.rupees());
System.out.println("M1 after a rename, same member: " + yes(new Member("M1", "Asha").equals(new Member("M1", "Asha K"))));
System.out.println("M1 and M2, both named Asha, same member: " + yes(new Member("M1", "Asha").equals(new Member("M2", "Asha"))));
}
}// value_entity.cpp: Money is a value (equal by amount); Member is an entity (equal by id)
#include <iostream>
#include <string>
#include <utility>
class Money {
int rupees_;
public:
explicit Money(int rupees) : rupees_(rupees) {}
int rupees() const { return rupees_; }
Money plus(const Money& other) const { return Money(rupees_ + other.rupees_); } // a new value, not an edit
bool operator==(const Money& other) const { return rupees_ == other.rupees_; }
};
class Member {
std::string id_, name_;
public:
Member(std::string id, std::string name) : id_(std::move(id)), name_(std::move(name)) {}
const std::string& name() const { return name_; }
bool operator==(const Member& other) const { return id_ == other.id_; } // identity is the id
};
std::string yes(bool flag) { return flag ? "yes" : "no"; }
int main() {
Money a(100);
Money total = a.plus(Money(50));
std::cout << "two Money(100) equal: " << yes(a == Money(100)) << "\n";
std::cout << "a is still " << a.rupees() << ", total is " << total.rupees() << "\n";
std::cout << "M1 after a rename, same member: " << yes(Member("M1", "Asha") == Member("M1", "Asha K")) << "\n";
std::cout << "M1 and M2, both named Asha, same member: " << yes(Member("M1", "Asha") == Member("M2", "Asha")) << "\n";
}// valueEntity.js: Money is a value (equal by amount); Member is an entity (equal by id)
class Money {
constructor(rupees) {
this.rupees = rupees;
Object.freeze(this); // a Money cannot be edited after it is made
}
plus(other) {
return new Money(this.rupees + other.rupees); // a new value, not an edit
}
equals(other) {
return other instanceof Money && this.rupees === other.rupees; // JavaScript does not compare objects by value, so we say how
}
}
class Member {
constructor(memberId, name) {
this.memberId = memberId;
this.name = name;
}
equals(other) {
return other instanceof Member && this.memberId === other.memberId; // identity is the id
}
}
const yes = (flag) => (flag ? "yes" : "no");
const a = new Money(100);
const total = a.plus(new Money(50));
console.log("two Money(100) equal:", yes(a.equals(new Money(100))));
console.log(`a is still ${a.rupees}, total is ${total.rupees}`);
console.log("M1 after a rename, same member:", yes(new Member("M1", "Asha").equals(new Member("M1", "Asha K"))));
console.log("M1 and M2, both named Asha, same member:", yes(new Member("M1", "Asha").equals(new Member("M2", "Asha"))));two Money(100) equal: yes
a is still 100, total is 150
M1 after a rename, same member: yes
M1 and M2, both named Asha, same member: noWhy bother with the distinction? Because it tells you what equality means, what may be mutated, and what can be shared. A value object is frozen and compared by its fields, so it is safe to put in a set, use as a dict key, and hand to ten callers. An entity is compared by its id, so renaming a member never makes it a different member. Mixing them up produces real bugs: comparing two members by name merges two people, and editing a shared Money in place changes everyone who holds it.
A value object is also the natural home for a rule that belongs to a value: a stay whose check-out comes before its check-in should not be constructible, so the constructor refuses it and nobody downstream re-checks. That is encapsulation again, applied to a tiny object.
Where it shows up in the case studies#
| Case study | The idea | Where |
|---|---|---|
| Parking lot | No subclasses for Car and Bike |
A VehicleType enum is enough until a vehicle really behaves differently; subclasses with no behaviour of their own are a classic over-design |
| LRU cache | Composition | BoundedCache has an eviction policy and LRUPolicy has a list: no LRUCache(Cache) tree where every new policy repeats the storage code |
| Chess | Inheritance, done right | Piece → Slider / Stepper → Rook, Knight: pieces differ only in directions or offsets, so each is one line of data |
| Splitwise, Snake and ladder, Hotel, Meeting rooms, Order book | Value objects | frozen Split and Transfer, Jump, Stay (refuses a check-out before check-in), Recurrence, and immutable Trades |
When not to use it#
Each of these ideas has an over-applied form, and interviewers notice it.
- A getter and a setter for every field. That is not encapsulation. If a caller can set any field to any value, you have the public field back.
- An interface with one implementation and no second in sight. An abstraction pays when something varies. A
NotifierServiceinterface with exactly one class behind it is ceremony; the lesson on YAGNI goes further. - Composition for a true, stable "is-a".
InsufficientFundsshould extendWalletError. Wrapping a base class in a holder just to avoid the word "extends" adds forwarding code and loses the shared type. - Wrapper classes that only forward a call. If a class holds one object and passes every method straight through, remove it.
- Deep hierarchies. Four or five levels, each overriding the one above, are hard to follow and break when the top changes. (Chess's three,
Piece→Slider→Rook, is about the limit.)
The question that settles most cases: does behaviour vary, and along how many axes? One axis in a shallow family is where inheritance is at its best. Two axes, or a choice that changes while the program runs, is where it starts to hurt, and that is the signal to reach for parts.
In the interview#
Say the principle with the reason, in one breath: "The channel is what varies and customers combine them, so the notifier holds a list of channels behind one interface instead of having a subclass per combination; and the wallet's balance is private, changed only by deposit and withdraw, so the rule lives in one place."
Then expect the follow-ups:
- "Why not just inherit?" Because the combinations multiply (n channels need 2n − 1 classes), the same lines repeat, and the choice cannot change at run time. Inheritance is a good fit when it is a true is-a on one axis; say you would still use it for the exception hierarchy.
- "Is
privateenough for encapsulation?" No. A setter for every field leaves the data open. Encapsulation is the operations that enforce the rules. - "What is the difference between abstraction and encapsulation?" Encapsulation protects an object's inside and enforces its rules. Abstraction chooses what the outside sees: the contract. A class can have either without the other.
- "Is a
Ticketan entity or a value object?" Ask what makes two tickets the same. If it is the ticket id (one ticket stays the same ticket while its status changes), it is an entity. A frozen record of one fact that is never edited, such as a receipt, behaves as a value. - "How is overloading different from overriding?" Overloading (in Java and C++) is one name with different parameter lists, picked by the compiler from the argument types. Overriding is a subclass replacing a method, and which version runs is picked from the object at run time. Only the second is the polymorphism this lesson means.
Common mistakes#
- Getters and setters for every field, then calling it encapsulated.
- A subclass for every variation:
EmailSmsSender,EmailSmsPushSender, and the next one arrives on Monday. - Inheriting for code reuse when it is not an "is-a", for example making
NotifierextendEmailto borrow its formatting. - Subclasses that switch off the parent's methods by throwing "not supported". The Liskov lesson explains why that breaks every caller written for the parent.
- An
if type == "sms"chain next to a class hierarchy. If you branch on the type, the polymorphism is not doing its job. - Treating a value as an entity: giving
Moneyan id and a setter, then wondering why two 100-rupee amounts are not equal. - Treating an entity as a value: comparing two members by name and merging two people.
OOP and composition
Q1/4 A Wallet has a private balance and public getBalance() and setBalance(int). Is it encapsulated?
Answer & explanation
Answer: B
Encapsulation means the data changes only through operations that enforce the rules. A setter for every field gives the open field back. Expose deposit and withdraw, which check the rule, and keep a read-only getter.
Q2/4 A sender class exists for every non-empty combination of channels. How many classes does it take to cover 4 channels?
Answer & explanation
Answer: C
Each of n channels is in or out, which is 2 to the power n choices, and "none" is not a sender. That is 7 for 3 channels and 15 for 4. Composition replaces all of them with one notifier that holds a list.
Q3/4 When is inheritance the right choice?
Answer & explanation
Answer: C
"Favour composition" is a default, not a ban. A shallow, stable family where code written for the parent works with every subclass is what inheritance is for. Sharing code alone is not enough, and changing behaviour at run time points to parts.
Q4/4 Two members share the name Asha but have ids M1 and M2. Two Money(100) amounts are compared. Which statement is right?
Answer & explanation
Answer: C
A value object has no identity and is its values, so equal values mean the same value. An entity is identified by its id, so a shared name does not make two members the same, and a rename does not make one member a different member.
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


