The Problem It Solves
Make balance a public field and three things happen immediately: any code in the codebase can set it negative; you can never change the field’s type or name without touching every reader; and no single place remains where “is this state change legal?” can be checked. Multiply by fifty fields across twenty classes and the system becomes unchangeable. Data hiding is the fix — not as ceremony, but as the mechanism that keeps one class owning its state.
Encapsulation ≠ Data Hiding
- Encapsulation: bundling state + operations on that state into one unit.
- Data hiding: restricting direct access to internal state.
- Full encapsulation needs both — a class of public fields plus helper functions has neither.
object boundary = wall
┌──────────────────────────────────────┐
│ private state │
│ balance, items, connections... │ messages in:
│ │ ◄─── deposit(x)
│ behavior methods │ ──── result out
│ invariants enforced HERE │
└──────────────────────────────────────┘
Direct field access never crosses the wall.
Everything inside is free to change shape — rename fields, swap data structures, add caches — because no outside code depends on the interior. Only the message protocol on the walls is public.
Why Hide Representation
- Hidden state changes freely; exposed state becomes API forever.
- Returning internal collections directly lets callers mutate your state behind your back — return
List.copyOf(items)instead. - JDK history lesson:
java.util.Date(mutable, leaky) caused decades of bugs;java.time.LocalDatefixed it by being immutable and final. The repair strategy was hiding and freezing state.
The Getter/Setter Trap
// Anemic: encapsulation theater — every invariant bypassable
class Account {
private long balance;
public long getBalance() { return balance; }
public void setBalance(long b) { this.balance = b; } // negative allowed!
}
// Behavioral: state changes only through meaningful operations
class Account {
private long balance;
void withdraw(Money amt) {
if (amt.isNegativeOrZero() || amt.greaterThanOf(balance)) throw new ...;
balance -= amt.inPaise();
}
}
The anemic version has private keyword but no encapsulation — setters are just public fields with extra steps. Add a setter only when an outside actor legitimately sets that value and validation genuinely belongs there; prefer operations that name intent.
Real-World Usage
- Library APIs hide everything except the contract (JDK collections expose interfaces, never internal arrays).
- Frameworks accept configuration objects rather than mutable service internals.
- Codebases with
public static final int[]constants ship a mutation bug waiting for callers — useList.of.
Failure Modes
- Representation leak:
getCoordinates()returningdouble[]; switching to aLatLongtype breaks every caller — return the domain type. - Half-hidden pairs: two related fields (
start,end) each with its own setter → transiently invalid states observable by other threads.
The Problem They Solve
Encapsulation says hide state; access modifiers are the mechanism that decides from whom. Without graduated visibility, every member is either world-visible or class-visible — no middle ground for “visible to my package collaborators but not my users,” which is exactly the granularity real modules need.
Visibility Rings
narrow ◄── private ── default(package) ── protected ── public ──► wide
│ │ │ │
same class same package + subclasses everyone
(any package)
Each step rightward adds an audience. The design instinct is to start at private and widen only when a concrete need appears — widening is cheap to do later, expensive to undo once callers exist.
Full Matrix
| Modifier | Same class | Same package | Subclass (other pkg) | World |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
| default | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
Rules That Matter
- Default to most restrictive that works.
- Package-private is a real tool: test-only access, internal APIs among classes of one feature — zero annotation cost.
- Top-level classes can be only
publicor package-private; nested classes addprivate staticcombinations.
The protected Trap
protectedmeans subclass plus entire declaring package — not subclass-only.protectedfields in extensible classes become permanent API: subclasses depend on them, representation can never change. Preferprotectedmethods (template-method hooks) over protected fields.
Bypass Channels (Edge Cases)
| Channel | Bypasses | Guard |
|---|---|---|
Reflection setAccessible(true) | any modifier | Module system: fails unless module opened to caller |
| Serialization | constructor + final fields | readObject validation; avoid serializing internals |
| Nested classes | outer’s privates (both directions) | by design — keep nesting shallow |
| Same-package placement attack | default/package-private | sealed packages / modules |
JPMS Layer
The Java Platform Module System adds visibility above public: a package not exports-ed in module-info.java is invisible outside its module even though its types are public. The JDK uses this itself — sun.misc.Unsafe is public yet normally unreachable.
Interview Traps
- “Can a subclass access private members?” No — not even through a superclass instance reference; only via accessible methods it inherits.
- Overriding cannot narrow visibility:
protected→privatein a subclass is a compile error; widening is legal.
The Problem
An object whose validity depends on caller discipline is broken by definition. Classic production failure: transfer money between accounts — debit succeeds, credit crashes — and the system now holds money that exists nowhere. Or two threads read a half-updated Range where start > end. Both bugs share one root: invalid intermediate states were representable and reachable. An invariant-driven class makes them unreachable.
An invariant is a condition that must hold in every observable state: balance >= 0, start < end, “no seat double-booked.”
valid region constructor / methods = guards
┌───────────────────┐
│ ✓ start < end │ setState() ──► [guard check] ──► accept
│ ✓ balance >= 0 │ │
└───────────────────┘ reject + exception
▲ │ (state unchanged)
└──────────┘
every mutation re-enters through a guard
The shape is the whole idea: the valid region is enclosed, every entry point validates first and mutates last, so no caller — however careless — can park the object outside the region.
Enforcement Points
| Boundary | Threat | Defense |
|---|---|---|
| Constructor | Invalid initial args | Validate before assigning; fail fast |
| Setters/mutators | Transiently invalid states | Single atomic update; validate all, then mutate |
| Getters | Internal mutable escape | Defensive copies (List.copyOf) |
this escape | Half-built object visible to others | Never pass this from constructor; no listener registration inside ctor |
| Deserialization | Constructor bypassed entirely | Validate in readObject; prefer explicit rehydration |
| Two-phase init | Object used between new and init() | Complete construction in one step |
Canonical Example
final class Range {
private final long start, end;
Range(long start, long end) {
if (start >= end) throw new IllegalArgumentException(start + ">=" + end);
this.start = start; this.end = end;
}
Range intersect(Range other) {
long s = Math.max(start, other.start), e = Math.min(end, other.end);
return s < e ? new Range(s, e) : null; // empty result explicit
}
}
// final class + final fields → no subclass or reassignment path breaks it
final everywhere is not decoration: immutable classes have the strongest possible invariant story — state fixed once at construction, guards never needed again.
Design Consequences
- Fields that change together must change in one method — two separate setters invite the gap where the invariant is broken and another thread observes it.
- Invariants justify why setters are harmful: each setter is an unguarded door into the valid region.
- Cross-class invariants (“bookings ≤ seats”) belong to whichever aggregate owns both sides — otherwise nobody enforces them under concurrency.
Failure Modes
- Broken window: one bypassing setter teaches future maintainers validation is optional everywhere.
- Homeless invariant: rule enforced by neither
ShownorBookingManageralone → race condition in production. - Recovery-less failure: mutating three fields, throwing on the fourth → half-updated object persists; validate everything first, mutate last.
Premium Content
Unlock Encapsulation and all premium lessons with a subscription.
From ₹199.99/year — See plans