Encapsulation: hiding state and protecting invariants
Encapsulation bundles data with the behavior that controls it. Private fields enforce invariants so that objects can't enter invalid states.
Encapsulation is one of those concepts every developer claims to understand and most misapply. The common answer: "use private fields with getters and setters." That describes access control, not encapsulation. Real encapsulation is about behavior ownership: the object enforces its own invariants, and outside code never needs to know or care what state lives inside.
The Problem
Without encapsulation, any class can corrupt your object's state:
// Zero encapsulation: public fields invite corruption
public class BankAccount {
public String id;
public double balance; // anyone can set to -99_000
public boolean frozen; // freeze can be bypassed trivially
public List<String> txHistory; // history can be fabricated
}
// All of these compile and corrupt your domain:
account.balance = -99_000.00;
account.frozen = false; // bypasses a legal fraud freeze
account.txHistory.add("manual"); // injects a fake transaction record
There are no invariants. Rules like "balance cannot be negative" live as comments, if at all. Every caller becomes responsible for checking them. In practice, most won't. Here is what changes when you apply encapsulation.
Core Concept
Encapsulation packages state together with the methods that have the right to change it. Outside code calls commands (deposit, withdraw), not setters. The object alone decides whether the operation is valid.
AccountService sends commands to BankAccount -- it never reads raw fields and applies logic itself. Transaction is a record, immutable by design. Money bundles amount and currency so they cannot be separated by accident.
Implementation
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/**
* BankAccount owns its state completely.
* No direct field access from outside: callers issue commands.
* Invariants are enforced here, not scattered across services.
*/
public class BankAccount {
private final String id;
private double balance;
private boolean frozen;
// Internal list: callers receive an unmodifiable copy, never this list.
// This blocks: account.getTransactionHistory().clear()
private final List<Transaction> history = new ArrayList<>();
public BankAccount(String id, double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.id = id;
this.balance = initialBalance;
record(TransactionType.OPENING, initialBalance);
}
// Tell, Don't Ask: callers don't read balance then do math themselves.
public void deposit(double amount) {
requireNotFrozen();
if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive: " + amount);
balance += amount;
record(TransactionType.DEPOSIT, amount);
}
public void withdraw(double amount) {
requireNotFrozen();
if (amount <= 0) throw new IllegalArgumentException("Withdrawal must be positive: " + amount);
if (amount > balance) throw new IllegalStateException("Insufficient funds");
balance -= amount;
record(TransactionType.WITHDRAWAL, amount);
}
public void freeze() { this.frozen = true; }
public void unfreeze() { this.frozen = false; }
public String getId() { return id; }
public double getBalance() { return balance; }
// Defensive copy: callers get a view, not ownership of the internal list.
// A caller doing getTransactionHistory().clear() won't corrupt state.
public List<Transaction> getTransactionHistory() {
return Collections.unmodifiableList(history);
}
private void requireNotFrozen() {
if (frozen) throw new IllegalStateException("Account " + id + " is frozen");
}
private void record(TransactionType type, double amount) {
history.add(new Transaction(
UUID.randomUUID().toString(), type, amount, Instant.now(), balance));
}
}How It Works
This diagram follows a transfer between two accounts. AccountService never reads a balance directly. Both withdraw and deposit check their own invariants internally.
AccountService.transferlocates both accounts but issues commands only, never reads fields.FromAcct.withdrawchecks its frozen flag and balance entirely within itself.ToAcct.depositchecks its own frozen flag independently.- Both accounts record immutable
Transactionobjects internally. - A
TransferResultvalue object carries success/failure back to the caller without exceptions leaking up.
Common Pitfalls
| Anti-Pattern | What Goes Wrong | Fix |
|---|---|---|
| Getter + setter for every field | setBalance(-1000) compiles. No invariant protection at all. | Remove setters. Add command methods that validate before mutating. |
| Returning the internal collection | getTransactions().clear() corrupts state silently. | Return Collections.unmodifiableList(copy) or use List.copyOf(). |
| Caller reads state then decides | Logic scatters; race condition between read and write in concurrent code. | Move the decision into the object. Tell, don't ask. |
| Anemic domain model | Data bag with a service doing all logic. Object has no behavior. | Push behavior into the domain object. Services orchestrate, not decide. |
| Mutable value objects shared across owners | Money object mutated by one owner surprises the other. | Make value objects immutable. Java records work here. |
Anemic domain model
The anemic domain model is the most common encapsulation failure in enterprise Java. A BankAccount with 15 getters and setters and a BankAccountService that reads all the fields and applies all the logic is procedural code wearing an OOP costume. The account cannot protect its own invariants.
How to Decide on Visibility
When deciding how strongly to encapsulate a field, follow this decision tree:
The strongest form of encapsulation is immutability: if a field can never change, there is nothing to protect. Java records give you immutability with almost no boilerplate.
Real-World Examples
java.lang.String is the canonical immutable class. Every "mutation" method (replace, substring, toUpperCase) returns a new instance. String is freely shareable between threads because its state can never change.
java.time.LocalDate (and all java.time.*) are immutable value objects. Before Java 8, java.util.Date was mutable and caused countless bugs when passed between layers. The lesson was baked into the redesigned API.
Optional<T> encapsulates the "present or absent" state. You cannot reach inside and change what it holds. You must work through map, flatMap, orElse; the object decides what to do when empty.
JPA entity anti-pattern: Hibernate encourages exposing all setters for framework use, which makes every service a potential invariant violator. Domain-Driven Design aggregates address this by having objects enforce their own consistency rules before state is persisted.
Interview Tips
Mistake 1: "Encapsulation means private fields with public getters and setters."
What to say: "Encapsulation means the object owns its invariants. A getter/setter pair for every field gives zero protection. setBalance(-1000) compiles just fine. Real encapsulation exposes commands like withdraw(amount) that validate before mutating."
Mistake 2: Returning the internal collection from a getter.
// Leaks the internal list: caller can corrupt it
public List<Transaction> getTransactionHistory() { return history; }
What to say: "I return Collections.unmodifiableList(history) or List.copyOf(history). Callers get a view, not ownership of the internals."
Mistake 3: Reading state in the caller to make decisions.
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount); // race condition + scattered logic
}
What to say: "This is 'ask' style. I call account.withdraw(amount) instead. The account checks the balance and throws if insufficient. The invariant check lives in one place."
Mistake 4: Not knowing the anemic domain model by name. At senior level, interviewers ask explicitly. Know the term, identify the pattern, and explain why it violates encapsulation.
Killer interview moment
If asked "how would you prevent setBalance(-1000)?" the answer is not "add validation inside the setter." The answer is: remove the setter entirely and expose only deposit(amount) and withdraw(amount) commands that validate their arguments. The setter itself is the design mistake.
Test Your Understanding
Quick Recap
- Encapsulation is about invariant ownership, not just visibility. Private fields are the mechanism; protected invariants are the goal.
- Replace getter/setter pairs with command methods (
deposit,withdraw) that enforce rules before mutating state. - Never return a mutable internal collection from a getter. Return a defensive copy or an unmodifiable view.
- Immutability is the strongest form of encapsulation. Use Java records for value objects and data carriers.
- The anemic domain model (data bag plus service doing all logic) is encapsulation failure at the architecture level. Push behavior into domain objects.
- Tell, Don't Ask: if you find yourself reading state and deciding outside the object, that decision belongs inside the object.
- In interviews: the signal answer to "how do you enforce invariants" is removing setters and using command methods, not adding validation inside setters.