Definition
Making a field private is not enough for encapsulation if the getter returns a reference to the live internal collection.
- The problem: the caller receives the same list object that the class uses internally.
account.getTransactions().add(fakeTransaction)adds a fake transaction bypassing all validation. - Fix 1:
Collections.unmodifiableList(transactions)returns a read-only view.add()throwsUnsupportedOperationException. - Fix 2:
new ArrayList<>(transactions)returns a defensive copy. The caller modifies the copy, not the internal list. - Fix 3:
List.copyOf(transactions)(Java 10) creates an unmodifiable copy (best of both worlds, no nulls allowed). - Full protection: make the elements immutable too (records, final fields).
unmodifiableListprevents structural changes (add/remove) but not element mutation. - Stream alternative: return
transactions.stream()so the caller can only consume, not modify.
The vault (private field) at National Bank of India is locked. But the bank gives Rajesh a key to the vault (reference via getter). Rajesh walks in and adds fake gold bars (fakeTransaction). The vault is private but its contents are modified through the leaked key. Fix 1: give Rajesh a photo of the vault contents (unmodifiable view). Fix 2: give Rajesh a copy of the contents (defensive copy). The vault stays safe.
Understanding the Concepts
Why does returning a live collection break encapsulation?
The private field is protected, but the reference to the mutable collection is leaked.
What does the problem look like?
package com.bank.core;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
public class Account {
private final List<Transaction> transactions = new ArrayList<>();
public void addTransaction(Transaction txn) {
validate(txn); // validation: amount > 0, status = ACTIVE
transactions.add(txn);
auditLog("ADDED: " + txn);
}
// WRONG: leaky getter returns live internal list
public List<Transaction> getTransactions() {
return transactions; // reference to the same object
}
}
// Caller bypasses all validation:
account.getTransactions().add(new Transaction("FAKE", new BigDecimal("-999999")));
// No validate() called. No audit log. Private field modified from outside.
account.getTransactions().clear();
// All transactions wiped. No authorization. Catastrophic.
What are the three fixes?
// Fix 1: Collections.unmodifiableList (read-only view)
public List<Transaction> getTransactions() {
return java.util.Collections.unmodifiableList(transactions);
// add/remove/set/clear throw UnsupportedOperationException
}
// Fix 2: Defensive copy (new ArrayList)
public List<Transaction> getTransactions() {
return new ArrayList<>(transactions);
// Caller modifies the copy. Internal list unaffected.
// Downside: creates new list every call.
}
// Fix 3: List.copyOf (Java 10): unmodifiable copy
public List<Transaction> getTransactions() {
return List.copyOf(transactions);
// Unmodifiable AND separate from internal list.
// Downside: no nulls allowed.
}
// Fix 4: Stream (caller can only consume)
public java.util.stream.Stream<Transaction> transactionStream() {
return transactions.stream();
// Caller can filter, map, collect. Cannot modify internal list.
}
Why is unmodifiableList not enough for mutable elements?
// If Transaction is mutable:
public class Transaction {
private BigDecimal amount;
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
List<Transaction> view = java.util.Collections.unmodifiableList(account.getTransactions());
view.get(0).setAmount(new BigDecimal("-999")); // modifies the actual Transaction!
// unmodifiableList blocks add/remove, not element mutation.
// Full fix: make Transaction immutable (record)
public record Transaction(String txnId, BigDecimal amount, String status) {}
// view.get(0).setAmount(...) -> compile error: no setter on a record.
What is the complete protection recipe?
- Private field: controls who declares the reference.
- No direct setter for the collection: only validated methods like addTransaction().
- Getter returns unmodifiable or copy: prevents structural mutations.
- Immutable elements (records): prevents element-level mutations.
What are common mistakes?
The most common mistake is assuming private means fully encapsulated.
What should a senior engineer know?
- In Spring Boot, always return List.copyOf() or Collections.unmodifiableList() from getters that expose internal collections.
- Records automatically make all fields private final with no setters, providing encapsulation by default.
- JPA entities often have mutable collections; use defensive copies in DTO conversions to avoid leaking JPA-managed lists.
Visual Overview
BROKEN ENCAPSULATION
┌────────────────────────┐
│ Account │
│ private List<Txn> txns │
│ │
│ getTransactions() │
│ return txns; ←─────── │── LEAKED REFERENCE
└────────────────────────┘
│
▼
caller.getTransactions().add(fake) ← bypasses validation
caller.getTransactions().clear() ← wipes all data
FIXED ENCAPSULATION
┌────────────────────────┐
│ Account │
│ private List<Txn> txns │
│ │
│ getTransactions() │
│ return List.copyOf() │── SAFE: unmodifiable copy
│ │
│ addTransaction(txn) │── ONLY way to modify
│ validate(txn) │
│ txns.add(txn) │
│ auditLog(txn) │
└────────────────────────┘
Key Takeaway
A private field with a getter that returns the live internal collection is fake encapsulation. The caller receives a reference to the same mutable object: getTransactions().add(fake) bypasses all validation, and getTransactions().clear() wipes the internal state. The field is private but its contents are fully accessible through the leaked reference.
Three fixes: Collections.unmodifiableList() returns a read-only view (add and clear throw UnsupportedOperationException). new ArrayList<>(transactions) returns a defensive copy (caller modifies the copy only). List.copyOf() combines both (unmodifiable and a separate copy). For full protection, make the elements immutable too: records with final fields ensure that even element-level mutations are impossible.
Remember one sentence: private field plus a getter returning the live collection is fake encapsulation; return List.copyOf() or an unmodifiable view, and make elements immutable with records for true protection.
---