Encapsulation
Encapsulation is a small, promised public surface in front of private internals you reserve the right to rewrite whenever you want. It is not about hiding data for its own sake - it's about drawing a line between "what you can depend on" and "what I can change without asking."
What breaks without it
Without encapsulation, every field on every class is fair game for every caller. A
BankAccount.balance field, if public, can be set to any value by any code anywhere in the
system - not just decremented by a legitimate withdrawal, but slammed to a negative number
by a typo three files away that nobody will trace back to this class. Once callers touch
raw fields directly, the class's invariants (the rules that are supposed to always hold,
like "balance never goes negative") stop being enforceable, because enforcement only
happens inside methods the class controls. And because every caller now depends on the
exact shape of the internals, the class can never change its representation - even a purely
internal refactor becomes a breaking change for code that has no business knowing the
representation existed.
Worked example: the car you drive vs. the one under the hood
The interface a car gives you: a start button, a wheel, three pedals. The crankshaft is real, and it is none of your business. In code, that's a public surface you promise to keep stable and private internals you reserve the right to rewrite on a Tuesday.
Worked example: a subsystem hidden behind one method
A NotificationCenter used by a mobile app has a public surface of exactly one method,
send(message). Behind that method it privately batches messages to avoid rate limits,
retries failed pushes with exponential backoff, and swaps providers when one goes down for
maintenance.
None of that machinery is exposed, so the on-call engineer can swap the push provider next month without touching a single call site anywhere in the app.
Worked example: a method shaped around the operation, not the field
A BankAccount needs exactly one invariant to hold forever: balance never goes negative.
The only way to guarantee that is to never let anything outside the class touch balance
directly - every change has to go through a method that can check the invariant first.
withdraw(amount) checks amount <= balance before subtracting a cent. There is
deliberately no setBalance(amount) - a setter for the raw field would hand the caller the
exact same freedom to corrupt state that a public field would, just spelled with
parentheses instead of a dot.
Language mechanics: access modifiers
| Modifier | Same class | Same package | Subclass, different package | Everywhere |
|---|---|---|---|---|
private | yes | no | no | no |
| (package / default) | yes | yes | no | no |
protected | yes | yes | yes | no |
public | yes | yes | yes | yes |
protected is really "package access, plus subclasses" - it's the modifier for members
meant to be extended, not members meant to be hidden. A field marked protected becomes
part of the contract every current and future subclass can depend on, which is a much
bigger promise than it looks like at first glance.
Not every language enforces this at compile time - Python and JavaScript rely on a leading
underscore (_balance) as a convention rather than a rule the interpreter checks, so
nothing stops a caller who ignores the convention. The pillar is the same either way: the
discipline of not reaching past the promised surface is what encapsulation actually asks
of you, whether or not the language backs it with a compiler error.
Language mechanics: getters and setters
A getter that returns a copy or an immutable view rarely breaks an invariant, because
reading balance can't corrupt balance. A setter is the dangerous one: setBalance(x)
gives the caller the exact same power a public field would, just with extra ceremony in
between. The only thing that actually protects an invariant is a method shaped around the
operation the domain actually performs - withdraw, deposit - not a method shaped
around the field - balance. A setter earns a place on the public surface only when
"just overwrite this value, unconditionally" is itself a legitimate operation, and even
then it should validate the new value before accepting it.
Common mistakes:
- A getter and setter for every field, out of habit. Marking every field private and
then adding
getRetryCount()/setRetryCount(n)for each one hands back the same freedom to corrupt state as a public field would, just with more typing. If a setter exists, it should exist because the domain has a real, named reason for that value to be externally settable - not because "private fields need accessors" is a reflex. - Marking fields
protectedfor convenience. A field madeprotected"just in case a subclass needs it" becomes part of the inherited contract forever - every subclass that reads it is now depending on the base class's internal representation, and that representation can never change again without a coordinated update across every subclass that touched it.
Where this shows up in the patterns catalogue
- Facade is encapsulation drawn
around an entire subsystem rather than one class -
NotificationCenterabove is a one-class facade in miniature. - Proxy controls or mediates access to an object without changing its public surface at all - the caller can't tell it isn't talking to the real thing.
- Repository encapsulates
persistence details behind domain-shaped methods, the same move
BankAccountmakes for its balance, applied to an entire storage layer.
Try it yourself: NotificationCenter currently exposes send(message) and, out of
habit, a setRetryCount(n) setter too - decide whether that setter earns its place on the
public surface, and what you would offer instead if a caller genuinely needs to tune
retries. Then do the same exercise for BankAccount: the billing team wants an
applyLatePenalty(percent) operation - would you add it as a new method, or as a public
penaltyRate field the caller sets before calling chargeAll()? Defend the choice against
what happens when two callers configure the rate differently for the same run.
Check yourself
A car exposes start(), steer() and three pedals. The crankshaft, ignition timing and fuel mixture are private. What does that buy you specifically?