Encapsulation

Bundle state with behaviour; hide state behind methods. The basis for invariant enforcement and the SRP setup.

Concept Foundational
9 min read
oop encapsulation invariants information-hiding srp

Summary#

Encapsulation is the act of drawing a box around state and saying: nothing outside this box may touch the state directly — every change goes through a method I control. The point is not the box. The point is that because the box exists, the class can guarantee facts about itself that no caller can break: a balance is never negative, a list is never null, two related fields never disagree.

Said in one line: encapsulation is invariant enforcement, with information hiding as the mechanism. The private keyword is the cheap part; the discipline of refusing to add a setter just because a caller asked for one is the expensive part.

Get this right and the rest of object-oriented design follows. The Single Responsibility Principle is encapsulation said sharply (“one reason to change” is “one invariant to enforce”). Abstraction is encapsulation taken one floor up (“hide the decision, not just the field”). Even polymorphism leans on it: a subtype can only honour its parent’s contract if both sides agree the contract is the only observable surface.

Why it matters#

Every LLD problem is, at the level of class design, the same question: where do I put the rules that must always be true? Encapsulation is the answer. A ParkingLot that exposes List<Spot> spots as a public field has handed every caller the ability to violate its invariants — to put two cars in one spot, to insert a null, to remove a spot mid-park. A ParkingLot that exposes park(Vehicle v) and leave(Ticket t) keeps the invariants on the inside, where they can be enforced once.

Three concrete things you gain when state is properly hidden:

The class becomes a real unit. It has a surface, it has an inside, and you can reason about the inside in isolation. You can change the internal List<Spot> to a Map<SpotId, Spot> without touching a single caller.

The invariants are documentable and testable. You can write a test that says “after park(v) and leave(t), occupancy is back to zero” and trust it, because no caller can sneak past the methods.

The class becomes safe to share. Threads, callers in different modules, callers in different services — none of them can corrupt the state, because the state is not theirs to corrupt.

In interview, the reviewer reads your fields first. If every field is public, you have signalled that you do not yet think in objects — you think in records with methods bolted on. The same code with private fields and a deliberate method surface tells a different story.

How it works#

The mechanics are three small habits applied consistently.

Default everything to private. Widen on need, not on convenience. A field that starts public for “easy testing” stays public forever and grows three callers that depend on its concrete type.

Validate at the boundary. Every public method that mutates state checks its arguments and the resulting state. The check belongs in the class, not in the caller — a caller who forgets to check is a bug; a class that doesn’t check is a design flaw.

Prefer immutability where the domain allows it. A value that does not change cannot be corrupted. Money, OrderId, Coordinate, DateRange are values, not entities — they should be final with no setters, and they should reject illegal construction in the constructor.

A worked example: a ShoppingCart for an Amazon Online Shopping flow.

public final class ShoppingCart {
private final CustomerId owner;
private final Map<Sku, Integer> items; // sku -> quantity
private final Map<Sku, Money> unitPrices; // snapshotted at add time
private boolean checkedOut;
public ShoppingCart(CustomerId owner) {
this.owner = Objects.requireNonNull(owner);
this.items = new LinkedHashMap<>();
this.unitPrices = new HashMap<>();
this.checkedOut = false;
}
public void add(Sku sku, int quantity, Money unitPrice) {
requireOpen();
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
Objects.requireNonNull(sku);
Objects.requireNonNull(unitPrice);
items.merge(sku, quantity, Integer::sum);
unitPrices.putIfAbsent(sku, unitPrice);
}
public void remove(Sku sku, int quantity) {
requireOpen();
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
Integer current = items.get(sku);
if (current == null || current < quantity) {
throw new IllegalStateException("Cannot remove more than is in the cart");
}
int next = current - quantity;
if (next == 0) {
items.remove(sku);
unitPrices.remove(sku);
} else {
items.put(sku, next);
}
}
public Money subtotal() {
return items.entrySet().stream()
.map(e -> unitPrices.get(e.getKey()).times(e.getValue()))
.reduce(Money.ZERO, Money::plus);
}
public void checkout() {
requireOpen();
if (items.isEmpty()) {
throw new IllegalStateException("Cannot checkout an empty cart");
}
this.checkedOut = true;
}
public Map<Sku, Integer> snapshot() {
return Collections.unmodifiableMap(new LinkedHashMap<>(items));
}
private void requireOpen() {
if (checkedOut) throw new IllegalStateException("Cart is already checked out");
}
}

Read what the class guarantees by looking only at its public methods:

A cart belongs to exactly one customer and that ownership never changes. Quantities are always positive integers. A SKU’s price is fixed at add-time and not silently re-priced as the catalogue changes. Once a cart is checked out, no further mutation is possible. A caller cannot mutate the underlying maps even by holding a reference — snapshot() returns an unmodifiable copy.

None of those facts are visible at the field level. They are visible at the method level, which is exactly where they need to live.

Variants and trade-offs#

Encapsulation is not a uniform sliding scale from “less” to “more.” Different shapes serve different purposes.

Strong encapsulation — entity with invariants. The ShoppingCart shape above. Private state, methods enforce rules, no setters. The right default for anything that has a lifecycle or rules.

Value-object encapsulation — immutable record. Money, OrderId, Sku. All fields final, no setters, equality by value. The constructor validates; after that, there is nothing to enforce because nothing can change. Strongest possible encapsulation; trivial to reason about; safe to share across threads.

Weak encapsulation — DTO / record at a serialization boundary. A class whose only purpose is to be turned into JSON and back. Public fields or trivial accessors, no behaviour, no invariants. Defensible only when the type genuinely has no rules — when it’s a flat bag of data crossing a wire. Many codebases use this shape for entities by mistake; the symptom is logic about an entity scattered across the callers that operate on it.

A. Entity with weak encapsulation. Public fields, no methods. Validation lives in every caller — checkout validates totals, inventory validates quantities, reports validate ownership. Six callers, six chances to forget a check. Adding a new invariant means changing all six.

B. Entity with strong encapsulation. Private fields, behaviour-rich methods. Validation lives once, in the class. A new invariant is one method’s worth of code. Callers that previously violated the rule fail at the boundary instead of corrupting state silently.

The trade-off worth naming explicitly: encapsulation costs a small amount of ceremony (constructors, methods, copies on the way out) in exchange for the ability to evolve internal representation without ripple. In an LLD interview, the ceremony is the point — the reviewer is grading whether you can hold the line on a class boundary, not whether you can save eight lines of code.

A second trade-off: getter proliferation. A class that exposes getX() for every private field has reproduced the public-field anti-pattern with extra noise. The fix is not to delete the getters; it is to ask why callers need raw X at all, and to move the behaviour that operates on X into the class.

When this is asked in interviews#

The question is rarely “define encapsulation.” It is woven into how your design is judged.

The field walkthrough. When you sketch a class on the whiteboard, the reviewer looks at the fields and asks “why is currentBalance public?” A clean answer is “it isn’t — it’s private; callers use deposit and withdraw so the non-negative invariant lives in one place.” The shape of your fields is the first signal of whether you think in encapsulated objects.

The invariant question. “What can never be true about a ParkingLot?” If you cannot list two or three invariants without thinking — every spot has at most one vehicle, capacity is non-negative, a ticket maps to exactly one active park — the encapsulation discussion has nowhere to land. List the invariants first; then encapsulate to enforce them.

The “why not a setter” question. When a follow-up adds a new piece of state, the reviewer watches whether you reach for setStatus(Status) or for cancel() / confirm() / ship(). Setters are the encapsulation anti-pattern in disguise: they expose the field by another name and let the caller produce any combination of states the field type allows. State-changing operations — verbs from the domain — leave the class in control of which transitions are legal.

The “package this for another team” question. “If another service consumed this class, what would you expose?” The answer should be a short list of methods and a documented contract, not the full field set. This is where encapsulation crosses into abstraction — and where the next pillar takes over.

The strongest signal you can send across all four: when the reviewer asks “what happens if a caller does X?”, your answer points to a single method, not a chase across the codebase.

Encapsulation does not stand alone — it is the foundation the other three pillars are built on, and it shows up restated in the SOLID principles.

Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.