Encapsulation: hiding state and protecting invariants
Encapsulation bundles data with the behavior that controls it. Private fields help enforce invariants when all mutation paths respect the object's rules.
Introduction
Encapsulation is often reduced to "use private fields with getters and setters." That describes access control, not the whole concept. Real encapsulation is about behavior ownership: the object enforces its own invariants, and outside code does not need to know or care what state lives inside.
TL;DR / Mental Model
Encapsulation means that the object which owns state also owns the rules for changing that state. Keep representation private, expose meaningful operations, and return values without handing out mutable ownership.
The mental model is ask for an operation, not permission to edit a field:
- Prefer
account.withdraw(amount)overaccount.getBalance()followed by caller-side subtraction. - Prefer an immutable
Moneyvalue over a mutable amount-and-currency pair shared across layers. - Prefer an unmodifiable view or snapshot over returning an internal mutable collection.
Private fields help create the boundary, but the boundary is only useful when every mutation path—including constructors, commands, deserialization, and persistence mapping—respects the invariant.
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
No invariants are enforced. Rules like "balance cannot be negative" live as comments, if at all. Every caller becomes responsible for checking them, so any missed check can admit invalid state. 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.
Definitions and Boundaries
Access control answers who can directly read or write a representation. Encapsulation goes further: it gives one object or module responsibility for the rules that keep its state valid. Information hiding is the related decision to keep implementation details out of the public contract.
Encapsulation does not mean that every value must be private or that no getter is allowed. Read-only identifiers, immutable values, DTOs, and deliberately public constants can be exposed when doing so does not give callers a way to violate an invariant. It also does not make a multi-object operation transactional; atomicity still belongs to the transaction or coordination boundary.
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 view, 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; }
// Unmodifiable view: 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 the expected success/failure outcome back to the caller.
Design Implications, Trade-offs, and Exceptions
Encapsulation moves decisions toward the state they protect. That usually makes call sites simpler and keeps rules from being duplicated, but it can also add command methods, validation code, defensive copies, and coordination between objects. A large public API of one getter and setter per field is not automatically better than a smaller command-oriented API.
Use the strongest boundary that matches the ownership question:
- A mutable aggregate should guard state-changing commands and cross-field invariants.
- An immutable value object can expose its value because callers cannot mutate the instance through it.
- A DTO or record used at an API or persistence boundary may intentionally expose data; it should not be mistaken for a domain object that owns business behavior.
- A service can coordinate multiple aggregates, but it should not bypass each aggregate's local rules. For a transfer, database transactions, ordered locks, or another coordination mechanism may still be required.
The trade-off is between local control and convenience. Encapsulation is valuable when callers would otherwise need to know representation details or repeat decisions; it is unnecessary indirection when a type is explicitly a passive data carrier.
30-Second Explanation
Encapsulation hides representation and puts behavior beside the state it protects. Instead of exposing setBalance, BankAccount exposes commands such as deposit and withdraw, validates them internally, and controls access to its history. This reduces invalid states and limits coupling, while still allowing intentional read-only views and data-transfer types.
5-Minute Explanation
Start with the invariant: an account cannot withdraw a non-positive amount, spend more than its balance, or mutate while frozen. If callers read fields and enforce those rules themselves, every caller becomes another mutation path. Make the fields private, put the checks in BankAccount, and expose commands that represent valid domain actions. Keep collections owned by the account, use immutable value objects for shared values, and let a service coordinate two accounts without reaching into their representation. Finally, distinguish local encapsulation from system-wide atomicity: the objects can protect their own rules, but a transfer still needs an appropriate transaction or concurrency boundary.
Common Mistakes and Misconceptions
| 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 a 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 leaves the domain rules in procedural service code. The account cannot protect its own invariants.
How to Decide on Visibility
When deciding how strongly to encapsulate a field, follow this decision tree:
One strong form of encapsulation is immutability: if a field cannot change after construction, there is less mutable state to protect. Java records provide final components with little boilerplate, although referenced objects still need their own immutability or defensive copying.
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 the immutable types in java.time.*) are immutable value objects. Before Java 8, java.util.Date was mutable and could cause bugs when passed between layers. The redesigned API makes the value-object boundary clearer.
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: Some JPA mappings expose setters for framework use, which can make every service a potential invariant violator. Domain-Driven Design aggregates address this by having objects enforce their own consistency rules before state is persisted.
Practical Review Checks
Mistake 1: "Encapsulation means private fields with public getters and setters."
A concise explanation: "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; }
A concise explanation: "Return Collections.unmodifiableList(history) or List.copyOf(history). Callers get a view or snapshot, 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
}
A concise explanation: "This is 'ask' style. Call account.withdraw(amount) instead. The account checks the balance and reports insufficient funds. The invariant check lives in one place."
Mistake 4: Treating an anemic domain model as an encapsulated design. Name the pattern, identify that the service owns the domain rules, and explain why the domain object cannot protect its own invariants.
Design check: preventing direct mutation
To prevent setBalance(-1000), remove the setter and expose only deposit(amount) and withdraw(amount) commands that validate their arguments. Adding validation inside a broad setter still lets callers express an operation that may not be valid in the domain; command methods make the allowed transitions explicit.
Test Your Understanding
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. - Do not return a mutable internal collection from a getter. Return a defensive copy or an unmodifiable view.
- Immutability is one strong form of encapsulation. Use Java records for value objects and data carriers, with care for referenced mutable objects.
- The anemic domain model (data bag plus service doing all logic) can be encapsulation failure at the architecture level. Push behavior into domain objects when they own the invariants.
- Tell, Don't Ask: if you find yourself reading state and deciding outside the object, that decision belongs inside the object.
- When explaining invariant ownership, emphasize removing broad setters and using command methods, not merely adding validation inside setters.
Related OOP Concepts
- Abstraction: exposes the operations callers need while hiding representation details.
- Information hiding: keeps implementation choices out of the public contract.
- Immutability and value objects: reduce the number of mutation paths and make shared values safer to pass around.
- Composition: lets a domain object delegate to collaborators while keeping its own boundary intact.
- Enums: model closed sets such as status values that can participate in an encapsulated state transition.
Related Articles
Abstraction separates what an object can do from how it does it. Callers depend on contracts, not implementations, which makes systems extensible and independently testable.
Inheritance models IS-A relationships and enables polymorphism, but it creates tight coupling. Use composition when in doubt.
Inheritance models IS-A but couples tightly. Composition models HAS-A and stays flexible. Learn when each is right and how to migrate from one to the other.