Encapsulation as Invariant Protection
Why encapsulation is about invariants no external code can break rather than about private fields, why the getter/setter pair defeats it, and how interface width versus functionality — deep versus shallow modules — decides whether hiding actually bought anything.
Encapsulation is routinely taught as "make fields private and add accessors", which is a syntactic ritual that can leave a class exactly as fragile as it was. The actual purpose is older and sharper: Parnas argued in 1972 that a module should hide a design decision likely to change, so that callers cannot come to depend on it. In object terms, a class exists to hold an invariant — a statement about its state that is always true from the outside — and encapsulation is whatever it takes to make that statement unbreakable by code outside the class.
The test: can external code make the object invalid? #
class Order {
private List<Item> items; // private
private Money total; // private
public List<Item> getItems() { return items; } // ← invariant is now public
public void setTotal(Money t) { this.total = t; } // ← and unenforceable
}Every field is private and the class has no encapsulation at all. The invariant that matters — total == sum(items) — can be broken by any caller, in two independent ways. Private fields with public accessors are just fields with extra steps.
class Order {
private final List<Item> items = new ArrayList<>();
private Money total = Money.ZERO;
public void add(Item item) { // the only way in
items.add(item);
total = total.plus(item.price()); // invariant restored before returning
}
public Money total() { return total; }
public List<Item> items() { return List.copyOf(items); } // unmodifiable snapshot
}Now the invariant is a property of the type. There is no sequence of public calls that leaves an Order whose total disagrees with its items, and any future code that needs that guarantee gets it for free.
Tell, don't ask #
The behavioural version of the same rule. When a caller reads state, decides something, and writes state back, the decision has leaked out of the object that owns the data — and it will be duplicated, inconsistently, at the next call site.
// ask: the rule lives in the caller, and now in every caller
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount);
}
// tell: the rule lives with the data it constrains
account.withdraw(amount); // throws InsufficientFunds — one place, always enforcedThe ask version is also a race: between the read and the write, another thread can change the balance. Encapsulating the operation makes atomicity possible; scattering it makes atomicity impossible to add later without finding every call site.
Deep versus shallow modules #
Hiding something only pays if the interface is genuinely smaller than what is behind it. Ousterhout's framing is the most useful measure here: a deep module offers a small interface over substantial functionality; a shallow one has an interface nearly as complicated as its implementation, so it adds a name and a layer without removing anything from the caller's head.
| Deep | Shallow | |
|---|---|---|
| Example | Unix file I/O — open, read, write, close, lseek over buffering, permissions, drivers, filesystems |
a class whose five methods are getX/setX pairs delegating to one field |
| Caller must know | the five calls | everything the class knows, plus the class |
| Change cost | implementation can be rewritten | any change ripples through callers |
Ousterhout's term for the habit of manufacturing many tiny shallow classes is classitis: each one is defensible on its own, and collectively they raise total complexity, because every additional interface is more surface for a reader to hold. The question to ask of a new class is not "is it small?" but "does it let callers stop thinking about something?"
Where it is deliberately absent #
Not every type should encapsulate. A data transfer object — a deserialized request body, a row projection, a config struct — has no invariant to protect; it is a bag of fields in transit, and wrapping it in accessors adds ceremony that hides nothing. Records, data class, and structs exist for exactly this case.
The productive discipline is to keep the two kinds apart: a wide-open DTO at the boundary, parsed once into a domain type that enforces its invariants (preconditions and boundaries). Trouble comes from the hybrid — a "domain object" with a no-arg constructor and setters for the ORM, which therefore cannot have an invariant, because the framework can produce one in any state.