Encapsulation as a Design Boundary
Encapsulation as a Design Boundary
In low-level design, encapsulation is not just private fields — it is the decision of which state a class owns and which operations are allowed to touch it. The class becomes a boundary: callers interact through behavior, and the invariant (the set of rules that must always hold) is defended inside.
Modeling steps
- List the invariants of the entity (e.g., a
Walletbalance can never be negative). - Make the state private and expose only operations that preserve the invariant.
- Reject invalid transitions with validation inside the method, not at the caller.
class Wallet {
private long balanceCents; // invariant: >= 0
// behavior preserves the invariant — callers cannot bypass it
public void credit(long amountCents) {
if (amountCents <= 0) throw new IllegalArgumentException("amount must be positive");
balanceCents += amountCents;
}
public boolean debit(long amountCents) {
if (amountCents <= 0 || amountCents > balanceCents) return false;
balanceCents -= amountCents;
return true;
}
public long balanceCents() { return balanceCents; } // read-only view
}
If balanceCents were public, every caller would become responsible for the invariant — and one missed check corrupts the whole system. Encapsulation moves that responsibility to the single owner.
Anemic model smell
A design where every class is a data bag with public getters/setters and all logic lives in Service classes is an anemic domain model. It compiles and runs, but invariants are enforced far from the state, and the design degrades into procedural code with DTOs.
Access Surface: Getters, Methods, or Nothing
Access Surface: Getters, Methods, or Nothing
Not every private field needs a getter. Choose the narrowest access surface that supports real use cases:
| Need | Prefer | Example |
|---|---|---|
| Read derived state | Method, not getter | wallet.canAfford(amount) instead of getBalance() |
| Change state | Command method with validation | order.cancel(reason) |
| Full object snapshot for display | Careful getter returning a copy | order.snapshot() returns an immutable record |
| Expose internal collection | Return unmodifiable view | return Collections.unmodifiableList(items); |
| No external need | No accessor at all | internal counters, caches |
Returning internals breaks the boundary
// LEAK: caller can mutate the list behind the class's back
class Order {
private final List<Item> items = new ArrayList<>();
public List<Item> getItems() { return items; }
}
order.getItems().clear(); // invariant destroyed from outside
Fix by returning an unmodifiable view or a defensive copy:
public List<Item> items() { return Collections.unmodifiableList(items); }
Tell, don't ask
Prefer order.cancel(reason) over order.setStatus(CANCELLED); order.setCancelledAt(now);. The first keeps the transition logic — and its invariants — inside Order; the second makes every caller a co-author of your state rules.
Encapsulation Across Object Graphs
Encapsulation Across Object Graphs
Real LLD problems chain objects: an Account holds a Wallet, a Wallet holds Transactions. Encapsulation decisions must hold at every hop of the graph.
Composition over exposure
class CheckoutService {
// good: PaymentGateway is hidden behind Checkout's behavior
private final PaymentGateway gateway;
Result pay(Order order, Card card) {
return gateway.charge(card, order.total());
}
}
Callers of CheckoutService never learn which gateway is used, so swapping Stripe for an in-memory fake in tests changes nothing outside the class.
Cross-object invariants
Some invariants span several objects — "an order can only be paid once". Encapsulate the rule in the aggregate root (Order.pay() marks itself paid before calling the gateway) instead of letting CheckoutService set flags from outside.
Interview checklist
- Who owns each piece of mutable state? One class, always.
- Which methods may change it? Are they validating the invariant?
- Does any getter return a live internal collection or reference?
- If I replace a collaborator (DB, API), which classes change? Ideally one.
- Can I describe the class in one sentence starting with "X manages ..."?
Encapsulation is the first tool you reach for in every LLD round: it sets the boundaries every later decision (patterns, persistence, concurrency) builds on.
Practice Problems
Design a scalable system for Encapsulation. Cover high-level architecture, data model, API design, and discuss tradeoffs.
Solution
// Complete system design:
// - Functional + Non-functional requirements
// - Capacity estimation
// - Data model (SQL/NoSQL choice)
// - API endpoints
// - Component architecture
// - Scaling strategy
// - Monitoring & reliability Analyze how to scale Encapsulation to handle 10x the current load. Identify bottlenecks and propose solutions.
Solution
// Scaling approach:
// 1. Load balancing
// 2. Database sharding/replication
// 3. Cache layer (Redis)
// 4. CDN for static assets
// 5. Async processing (queues)
// 6. Microservices decomposition Analyze potential failure modes in Encapsulation and design mitigation strategies to ensure reliability.
Solution
// Failure mitigation:
// 1. Redundancy (multi-AZ)
// 2. Circuit breakers
// 3. Retry with backoff
// 4. Dead letter queues
// 5. Health checks
// 6. Graceful degradation Quiz
1. In LLD, what is the primary purpose of encapsulating state inside a class?
2. A class exposes `public List<Item> getItems()` returning its internal list. What is the risk?
3. Which design is considered an anemic domain model?
4. "Tell, don't ask" supports encapsulation because ...
Flashcards
Question
Encapsulation in one sentence (LLD view)
Click to reveal answer
Answer
A class owns its state and exposes only operations that keep its invariants true.
Question
Anemic domain model
Click to reveal answer
Answer
Objects hold data with getters/setters while all logic lives in external services — invariants drift away from state.
Question
"Tell, don't ask"
Click to reveal answer
Answer
Invoke behavior on an object (`order.cancel()`) instead of reading its state and deciding for it externally.
Question
Collection getter leak — fix
Click to reveal answer
Answer
Return `Collections.unmodifiableList(...)` or a defensive copy, never the internal list.
Question
Who should enforce a cross-object invariant like "pay once"?
Click to reveal answer
Answer
The aggregate root / owner object (e.g. `Order.pay()`), not external service code flipping flags.
Revision Notes
Key Takeaways
- 1. Encapsulation = private state + validated behavior, keeping invariants in one place
- 2. Expose the narrowest access surface: derived queries over raw getters, commands over setters
- 3. Never return live internal collections; return unmodifiable views or copies
- 4. Anemic models are the classic LLD smell — put behavior back on the entities
- 5. Invariants spanning objects belong to the aggregate root
Interview Tips
- • When designing classes in an LLD round, state the invariant out loud and show which method defends it
- • If the interviewer asks for getters/setters, propose behavior methods first and justify it
- • Call out the anemic-model trap if you see services manipulating other classes' data
Cheat Sheet
private state → validated public behavior → no live internal exposure → behavior over getters/setters → aggregate root owns cross-object rules