Menu

Earn Premium with Referrals

Invite your friends and earn Premium rewards through our referral program.

See how it works and start inviting friends.

Encapsulation
LLD

Encapsulation

Learn how encapsulation bundles data and behavior while controlling access to an object's internal state.

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.LocalDate fixed 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 — use List.of.

Failure Modes

  • Representation leak: getCoordinates() returning double[]; switching to a LatLong type 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

ModifierSame classSame packageSubclass (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 public or package-private; nested classes add private static combinations.

The protected Trap

  • protected means subclass plus entire declaring package — not subclass-only.
  • protected fields in extensible classes become permanent API: subclasses depend on them, representation can never change. Prefer protected methods (template-method hooks) over protected fields.

Bypass Channels (Edge Cases)

ChannelBypassesGuard
Reflection setAccessible(true)any modifierModule system: fails unless module opened to caller
Serializationconstructor + final fieldsreadObject validation; avoid serializing internals
Nested classesouter’s privates (both directions)by design — keep nesting shallow
Same-package placement attackdefault/package-privatesealed 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: protectedprivate in 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

BoundaryThreatDefense
ConstructorInvalid initial argsValidate before assigning; fail fast
Setters/mutatorsTransiently invalid statesSingle atomic update; validate all, then mutate
GettersInternal mutable escapeDefensive copies (List.copyOf)
this escapeHalf-built object visible to othersNever pass this from constructor; no listener registration inside ctor
DeserializationConstructor bypassed entirelyValidate in readObject; prefer explicit rehydration
Two-phase initObject 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 Show nor BookingManager alone → race condition in production.
  • Recovery-less failure: mutating three fields, throwing on the fourth → half-updated object persists; validate everything first, mutate last.

My Private Notes

Notes are auto-saved locally to this device.