Composition, Demeter and contracts
Build behavior by combining parts, limit what objects know, and state the promises in code.
What you will learn
- 01Composition over inheritance
- 02The Law of Demeter
- 03Tell, don't ask
- 04Favor immutability
- 05Separation of concerns
- 06Least astonishment
- 07Design by contract
Composition over inheritance
Inheritance says a thing is a kind of another thing. Composition says a thing has another thing it uses. Inheritance looks economical, but it binds a child tightly to its parent: change the parent and every child may break, and each new way of varying multiplies the classes.
// inheritance: one class per combination
class Car extends Vehicle {}
class ElectricCar extends Car {}
class HolidayElectricCar extends ElectricCar {}
// composition: plug the varying part in
class Vehicle {
constructor(readonly plate: string, private rule: PricingRule) {}
fee(minutes: number) { return this.rule.fee(minutes); }
}
With composition you swap behavior by passing a different rule, even at runtime. Use inheritance only for a true, stable is-a relationship, and prefer composition otherwise.
The Law of Demeter
An object should talk only to its direct neighbors, not to the neighbors of its neighbors. A long chain is the warning sign.
const price = lot.floors[2].slots[7].ticket.rule.fee(minutes);
This caller now knows the internal structure of lot, floor, slot, ticket and rule. Change any of them and this line breaks. The fix is to hide the walk behind one operation: lot.charge(ticketId, minutes).
Swipe sideways to pan · drag a box to move it
Tell, don't ask
A close companion rule: tell an object what to do instead of asking for its data and deciding yourself. If you read a ticket's fields to compute a fee, the fee logic has leaked out of Ticket. Ask ticket.feeAt(now) instead. Behavior stays beside the data it needs.
Swipe sideways to pan · drag a box to move it
Swipe sideways to pan · drag a box to move it
Favor immutability
Objects that cannot change after creation are easier to reason about. They can be shared freely, are safe across threads, and cannot be corrupted by a distant caller. Make fields read only, return new values instead of editing, and allow mutation only where one owner controls it. The cost is some copying, which is rarely the bottleneck.
Separation of concerns
Split a program by what it is concerned with: presentation, business rules, storage. Each part changes for its own reasons, so a new screen should not touch pricing and a new database should not touch screens. Layers with a clear direction of dependency, interface to rules to storage, make this concrete.
Least astonishment
Names and behavior should match what a reasonable reader expects. A function called getTicket should not create one. A method that looks like a query should not change state. If you have to explain a surprise in a comment, rename or redesign instead.
Design by contract
State each operation's promises explicitly. A precondition is what must be true before the call, a postcondition is what the operation guarantees after, and an invariant is what is always true of the object.
function leave(id: string, now: number): number {
const t = tickets.get(id);
if (!t) throw new Error("precondition: unknown ticket");
if (now < t.enteredAt) throw new Error("precondition: time went backwards");
const amount = fee((now - t.enteredAt) / 60000);
if (amount < 0) throw new Error("postcondition: negative fee");
return amount;
}
Failures now point at the exact broken promise, and the checks double as documentation. Contracts also make Liskov substitution checkable, since a subtype must not demand more or promise less.