Object-Oriented Design That Earns Its Keep
Objects are worth having when they guard invariants and localize decisions — not when they're data bags wearing a class keyword.
Encapsulation Is Invariant Protection
Most engineers learn encapsulation as "make fields private, add getters and setters." That version of the idea is worthless. A class with a private balance field and a public setBalance() method is public data with extra steps: every caller can still put the object into any state, legal or not, and the class has no say in the matter.
The real point of encapsulation is invariant protection. An invariant is a rule that must hold for the object to be meaningful: a bank account's balance never goes below its overdraft limit; a canceled subscription always has a cancellation timestamp; an order that has shipped cannot lose line items. A class earns its existence when it makes illegal states unrepresentable, or at least makes every state transition pass through code that enforces the rules atomically.
The test is simple: if a caller can reach in and mutate state directly, the invariant doesn't live in the class — it lives in the discipline of every caller, forever. One controller that sets status = "canceled" without also stamping canceledAt and stopping billing, and you have a subscription that is canceled in one query and active in another. The bug isn't in any one line; it's in the design that let the rule scatter.
This failure mode has a name: the anemic domain model. You see classes that are pure data bags — fields, getters, setters, nothing else — and beside them a pile of FooService classes holding all the logic. The data and the rules about the data live in different places, so nothing stops a third place from mutating the data while ignoring the rules. It looks object-oriented because there are classes. It is procedural code with worse ergonomics.
// Anemic: a data bag plus a service that hopes callers behave
class Subscription {
status: string = "active";
canceledAt: Date | null = null;
plan: string = "pro";
}
class SubscriptionService {
cancel(sub: Subscription): void {
if (sub.status === "canceled") {
throw new Error("Already canceled");
}
sub.status = "canceled";
sub.canceledAt = new Date();
}
}
// Meanwhile, in a controller written six months later:
function handleChurnWebhook(sub: Subscription): void {
sub.status = "canceled"; // forgot canceledAt, skipped the guard
}The rich version moves the transition into the object. There is exactly one way to cancel a subscription, and it is impossible to do it halfway. Note that the fields are genuinely private and there are no setters — the public surface is the set of legal operations, not the set of fields.
type SubscriptionStatus = "active" | "past_due" | "canceled";
class Subscription {
private status: SubscriptionStatus = "active";
private canceledAt: Date | null = null;
cancel(now: Date): void {
if (this.status === "canceled") {
throw new Error("Subscription is already canceled");
}
// One atomic transition: both fields change together or not at all
this.status = "canceled";
this.canceledAt = now;
}
isBillable(): boolean {
return this.status === "active" || this.status === "past_due";
}
}Getter/setter pairs are not encapsulation
If a class exposes getX() and setX() for every field, you have re-implemented public fields with more typing. Ask instead: what operations does this object support, and what must be true before and after each one? If the answer is "any caller can set anything to anything," the class is not protecting an invariant and probably shouldn't exist as a class.
Interviewer's Perspective
When I ask a candidate to design a BankAccount or an Order, I'm not checking whether they know the word "encapsulation." I'm watching whether their first instinct is fields-plus-setters or operations-plus-rules. A strong candidate says "withdraw is a method because the overdraft check has to happen in exactly one place" without being prompted. An even stronger one asks what the invariants are before writing any code.
Tell, Don't Ask
Anemic models produce a characteristic caller-side pattern: pull state out of an object, make a decision about that state, then act on the object based on the decision. The object is treated as a passive record; the intelligence lives in whoever happens to be calling.
The problem is the same scattering problem as before, one level up. If shipping an order requires checking that it's paid, that it's not already shipped, and that it has a delivery address, then every call site that ships orders must repeat those three checks. The rules are correct only as long as every caller stays in sync — and callers never stay in sync.
"Tell, Don't Ask" says: instead of asking the object for state and deciding on its behalf, tell the object what you want and let it decide. The caller's if (order.getStatus() === "paid" && ...) collapses into order.ship(), and the checks move inside ship(), next to the state they inspect. When the rules change — say, digital goods skip the address check — one method changes instead of five call sites.
A useful way to spot violations: look for method chains that read state and then feed it back into decisions about the same object.if (account.getBalance() >= amount) followed by account.setBalance(account.getBalance() - amount) is the classic form — and it's also a race condition, because the check and the mutation aren't atomic. Telling the object (account.withdraw(amount)) fixes both the coupling and the atomicity in one move.
The honest caveat
Tell, Don't Ask targets decision logic, not all reads. Reporting, serialization, mapping to DTOs, rendering a template, writing to an audit log — these legitimately ask objects for their state, because the object shouldn't know about CSV formats or React props. The smell isn't "caller reads state." The smell is "caller reads state in order to make a decision the object could have made itself, and then reaches back in to act on it."
Primitive Obsession and Value Objects
Passing money around as a number is a design decision, and it's usually the wrong one. A float named amount doesn't know its currency, happily adds dollars to euros, accumulates rounding error, and can be confused with any other float in the signature — applyDiscount(price, percent) compiles just fine with the arguments reversed. The same goes for emails as strings, IDs as strings, durations as ints. This is primitive obsession: encoding domain concepts in types that carry none of the domain's rules.
The fix is the value object: a small immutable type that bundles the data with its rules. Money holds an amount and a currency and refuses to add across currencies. EmailAddress validates in its constructor, so the mere existence of an EmailAddress instance anywhere in the system proves it's well-formed — validation happens once, at the boundary, instead of defensively everywhere.
Two properties define value objects and distinguish them from entities:
- Equality by value. Two
Moneyobjects with the same amount and currency are the same money — there's no meaningful identity beyond the values. An entity likeCustomeris the opposite: two customers named Jane Smith are different people, and one customer stays the same entity even as her email changes. Entities have identity (usually an ID) and a lifecycle; value objects are just values. - Immutability. Operations on a value object return a new instance rather than mutating. This kills an entire bug class: aliasing. If
Moneywere mutable and an invoice and a refund both held a reference to the same instance, discounting the invoice would silently corrupt the refund. Immutable values can be shared, cached, and used as map keys with zero risk, because nothing can change out from under anyone.
class Money {
private constructor(
readonly cents: number,
readonly currency: string
) {}
static of(cents: number, currency: string): Money {
if (!Number.isInteger(cents)) {
throw new Error("Money is stored in integer minor units");
}
return new Money(cents, currency);
}
add(other: Money): Money {
if (other.currency !== this.currency) {
throw new Error(
"Cannot add " + other.currency + " to " + this.currency
);
}
return new Money(this.cents + other.cents, this.currency);
}
equals(other: Money): boolean {
return this.cents === other.cents && this.currency === other.currency;
}
}Floats and money don't mix
Note that all three implementations store integer minor units (cents), not floating-point amounts. Binary floats cannot represent 0.1 exactly, so repeated arithmetic drifts — the classic symptom is an invoice that's off by a cent after tax and discount rounds. The value object is the natural place to enforce this once, instead of trusting every calculation site to remember.
Polymorphism Replaces Type-Switching — Sometimes
Here is the pattern polymorphism was invented to kill: a switch on a type code, duplicated across the codebase. Sending a notification switches on channel type in the sender, in the retry handler, in the preview renderer, in the analytics tracker, and in the settings page. Adding a new channel — say, WhatsApp — means finding all five switches, and the compiler won't help you find them. Miss one and you ship a channel that sends but never retries.
Polymorphic dispatch inverts the layout. Instead of five switches each containing one branch per variant, you get one class per variant containing all of that variant's behavior. Adding WhatsApp becomes writing one new class and registering it; nothing existing changes. The axis of change flips from "touch every operation" to "touch one variant."
// Before: this switch exists in 5 files. After: one interface.
interface NotificationChannel {
send(userId: string, message: string): Promise<void>;
maxRetries(): number;
}
class EmailChannel implements NotificationChannel {
async send(userId: string, message: string): Promise<void> {
const address = await this.lookupEmail(userId);
await this.smtp.deliver(address, message);
}
maxRetries(): number {
return 5; // email is durable, retry aggressively
}
// ...
}
class SmsChannel implements NotificationChannel {
async send(userId: string, message: string): Promise<void> {
await this.gateway.sendSms(userId, message.slice(0, 160));
}
maxRetries(): number {
return 1; // duplicate SMS is worse than a dropped one
}
}When a Switch Is Better
Now the senior counterpoint, because "replace all conditionals with polymorphism" is junior advice. Modern type systems give you exhaustive switches over closed sets, and for closed sets they beat class hierarchies.
Consider order states: placed, paid, shipped, refunded. This set is closed — it changes only when the domain itself changes, which is rare and deliberate. With a TypeScript discriminated union, a Java sealed interface with pattern matching, or a Python match over a tagged union, the compiler (or type checker) verifies that every switch handles every variant. Add a fifth state and every switch in the codebase becomes a compile error until you handle it. That's the exact guarantee the five-switches problem lacked — the switches were dangerous because they failed silently, not because they were switches.
Exhaustive switches also keep related logic readable in one place. Computing a refund amount across four order states is four lines in one function; spread across four classes it becomes a scavenger hunt, and cross-variant logic (comparing states, transition tables) gets awkward. Data-oriented code — serializers, reducers, state machines, compilers — almost always reads better as exhaustive matching over closed variants.
So the real question is not "switch or polymorphism" but is the variant set open or closed?
- Open set — plugins, notification channels, payment providers, storage backends, anything where new variants arrive without touching the core: use an interface. New variants must be addable without editing existing code.
- Closed set — domain states, event kinds in a fixed protocol, AST node types: use a sealed/discriminated type and exhaustive switches. New variants should force a review of every operation, and the compiler runs that review for you.
- One-off branch — a single
ifin a single place is just code. Introducing an interface for one conditional in one location is speculative generality, not design.
Count the switches before refactoring
The refactoring trigger is the same type-switch repeated in multiple places over an open set. One switch in one place over a closed set is often the best possible design. Refactoring it to a class hierarchy adds indirection and removes exhaustiveness checking — a strict downgrade.
Composition Over Inheritance
"Prefer composition over inheritance" is usually recited as dogma. Here's the actual argument. Implementation inheritance — extending a class to reuse its code — is the strongest coupling relationship you can write. A subclass depends not just on its parent's public interface but on its implementation details: which methods call which other methods, in what order, with what intermediate state. None of that is in the contract, and all of it can break you.
This is the fragile base class problem, and it bites through a specific mechanism: a base-class method calling another method that a subclass has overridden. The base class author refactors internal call structure — a pure "implementation detail" — and subclasses in other repos silently change behavior. Watch it happen:
class OrderNotifier {
notify(order: Order): void {
this.sendMessage(this.formatMessage(order));
}
notifyBatch(orders: Order[]): void {
// v1: implemented via notify(), one message per order
for (const order of orders) {
this.notify(order);
}
}
protected sendMessage(msg: string): void { /* email */ }
protected formatMessage(order: Order): string { /* ... */ return ""; }
}
class AuditedNotifier extends OrderNotifier {
protected sendMessage(msg: string): void {
this.audit.record(msg); // relies on notifyBatch calling notify
super.sendMessage(msg);
}
}
// v2: base author "optimizes" notifyBatch to send one bulk message
// directly, bypassing notify() and sendMessage(). AuditedNotifier
// still compiles. Batch notifications silently stop being audited.The second problem is combinatorial. Suppose you have a Repository and you want caching and auditing. With inheritance you write CachedRepository extends Repository, then AuditedRepository extends CachedRepository — and now auditing is welded to caching. Want auditing without caching? That's a new class. Caching without auditing? Another. Add a third feature (metrics, encryption, read-replicas) and the hierarchy explodes: every combination of features needs its own class, and shared features get duplicated across branches of the tree. Single inheritance forces features into a fixed stacking order that the type system then enforces forever.
Composition dissolves the explosion. Each feature becomes a wrapper implementing the same interface and delegating to an inner instance. Features stack in any order, in any combination, chosen at construction time rather than at class-definition time — and each wrapper depends only on the interface, never on anyone's implementation details.
interface AccountRepository {
find(id: string): Promise<Account | null>;
save(account: Account): Promise<void>;
}
class CachingRepository implements AccountRepository {
constructor(
private inner: AccountRepository,
private cache: Cache
) {}
async find(id: string): Promise<Account | null> {
const hit = await this.cache.get(id);
if (hit) return hit;
const account = await this.inner.find(id);
if (account) await this.cache.set(id, account);
return account;
}
async save(account: Account): Promise<void> {
await this.inner.save(account);
await this.cache.invalidate(account.id);
}
}
// Any stack, any order, decided at wiring time:
// new AuditingRepository(new CachingRepository(pgRepo, cache), log)
// new CachingRepository(new AuditingRepository(pgRepo, log), cache)The classic real-world casualty of getting this wrong is java.util.Stack extends Vector: because Stack inherits from a list instead of containing one, every Stack in existence has insertElementAt() — callers can violate the LIFO invariant through the inherited API, and the mistake is frozen into the type forever. Inheriting for reuse published a contract nobody intended to publish.
One crucial distinction: this argument targets implementation inheritance. Interface inheritance — implementing an interface, satisfying a protocol, subtyping without inheriting code — is fine, and it's what makes the composition pattern above work at all. The problem was never subtyping; it was reusing code by welding yourself to another class's internals.
Design for inheritance or prohibit it
If you do ship an extendable class, you are committing to a second, mostly invisible contract: which methods call which overridable methods, and when. Effective Java's advice stands: document that self-use pattern explicitly, or mark the class final and offer composition points instead. An undocumented extendable class is a trap with a public API.
The Law of Demeter Is a Coupling Rule
order.getCustomer().getAddress().getCountry() looks harmless — it's one line. But count the structural knowledge it embeds: the caller knows that orders have customers, that customers have addresses, and that addresses have countries. Three object shapes, welded into one expression. When the team normalizes addresses into a separate service, or makes customers optional on guest orders, every one of these chains breaks — and they're scattered across the codebase precisely because they were so easy to write.
The Law of Demeter compresses to: only talk to your immediate collaborators. A method should call methods on its own fields, its parameters, and objects it created — not on objects it dug out of other objects. Instead of navigating three levels into an order, ask the order a question at its own level: order.shippingCountry() or better, order.requiresCustomsForm() if a decision is what you actually wanted (notice this is Tell, Don't Ask again — Demeter violations and Ask violations usually travel together). Now one class knows the internal structure, and structural changes ripple one level instead of everywhere.
Like every coupling rule, it needs honest boundaries or it turns into cargo cult:
- It's about object navigation, not method chaining. Counting dots is not the rule. A fluent builder —
OrderBuilder.create().withItem(sku).withRush().build()— returns the same builder each call; you never leave your immediate collaborator. Stream and LINQ pipelines likewise transform values through a sequence of the pipeline's own types. Demeter is violated by reaching through distinct objects' structures, not by long expressions. - Plain data is exempt. A DTO, a parsed JSON payload, a config tree, a database row — these are structures, not collaborators. Navigating
response.data.items[0].priceis just reading a document you asked for. Demeter protects you from coupling to other objects' designs; data at a boundary has no design to encapsulate, only a schema — and schema coupling is managed with versioning, not wrapper methods.
Wrap the navigation once, at the source
When you find the same chain in several places, add the higher-level method to the first object in the chain and delete the navigation from the callers. The information you needed was evidently the order's business to provide — the chain was every caller doing the order's job by hand.
How the Language Changes the Calculus
Everything above holds across TypeScript, Python, and Java, but the three languages price the moves differently, and senior judgment includes knowing the local prices.
Java is nominally typed. A class is a subtype only if it declares implements NotificationChannel by name. The consequence: abstractions must exist before the classes that satisfy them, so Java pushes you to design interfaces up front, and retrofitting an interface onto a class you don't own requires an adapter. In exchange, intent is explicit — a type conforms because someone meant it to, and sealed interfaces give you compiler-checked closed sets.
TypeScript is structurally typed. Any object with the right shape conforms to an interface, whether or not it has heard of it — a plain object literal with a send and a maxRetries is a NotificationChannel, no declaration needed. And interfaces are compile-time only: they erase entirely, so there's no runtime instanceof for an interface and no reflection over it. Conformance is free, which is powerful and slightly dangerous — a type can conform by coincidence.
Python is duck typed, with optional structure. At runtime, anything with a send method works — no types consulted at all. typing.Protocol layers static structural checking on top: define the protocol, and mypy or pyright verifies conformance by shape, Java-style safety with TypeScript-style freedom, enforced only when the type checker runs.
The practical consequence is bigger than it sounds: in TypeScript and Python, you don't need the interface up front to get polymorphism. Concrete classes with matching shapes are already substitutable; you can write two implementations today and introduce the Protocol or interface next month, when a third variant proves the abstraction real — and existing classes conform retroactively without being touched. So the Java-bred habit of starting every design with an interface is, in structural languages, mostly ceremony. Introduce abstractions on demand, at the moment they start paying rent. In Java, where retrofitting costs an adapter, buying the interface early is rational insurance. Same principles, different prices — judgment is knowing the local market.
Knowledge Check
Test your understanding before moving on.