Skip to main content

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.

Car- crankshaft: Engine- fuelMixture: double- ignitionTiming: double+ start()+ steer(angle)+ accelerate(pedal)
Three public methods are the whole contract. Everything below the line can change without anyone noticing.

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.

NotificationCenter- batchQueue: List<Message>- currentProvider: PushProvider- retryWithBackoff(msg)+ send(message)
One public method. Batching, retries and provider failover all happen behind it, and can all change independently.

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.

BankAccount- balance: double+ deposit(amount)+ withdraw(amount)
No setBalance anywhere. The only doors in are deposit and withdraw, and withdraw is the one that checks the invariant.

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

ModifierSame classSame packageSubclass, different packageEverywhere
privateyesnonono
(package / default)yesyesnono
protectedyesyesyesno
publicyesyesyesyes

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 protected for convenience. A field made protected "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 - NotificationCenter above 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 BankAccount makes 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

Question 1 of 4

A car exposes start(), steer() and three pedals. The crankshaft, ignition timing and fuel mixture are private. What does that buy you specifically?