1. Introduction
Protecting internal mutable state means preventing code outside a class from directly modifying the objects, collections, arrays, or other mutable values that the class owns internally.
This is an important object-oriented design concern in Java because a class should control how its state changes.
Consider a service configuration object that maintains a set of allowed payment methods. If its getter directly returns the internal Set, another class can modify that set without calling any business method on the configuration object.
That creates hidden state changes.
The class may look well encapsulated because its fields are private, but its internal state is still effectively exposed.
In code review, reviewers should therefore look beyond private fields and ask:
- Can callers obtain a mutable reference to internal data?
- Can constructor arguments later modify internal state?
- Can collections returned from getters be changed?
- Are arrays copied before being returned?
- Are mutable objects shared between unrelated components?
- Does the class control all state transitions?
Protecting mutable state helps keep object behavior predictable and prevents difficult production bugs caused by unexpected modification.
2. What This Topic Means
A mutable object is an object whose contents can change after creation.
Common mutable types in Java include:
ArrayListHashSetHashMap- Arrays
StringBuilderjava.util.Date- Most JPA entities
- Many DTOs
- Custom domain objects with setters
A class exposes its internal mutable state when it gives external code a direct reference to one of these internal objects.
For example:
public class UserAccessPolicy {
private final Set<String> permissions;
public UserAccessPolicy(Set<String> permissions) {
this.permissions = permissions;
}
public Set<String> getPermissions() {
return permissions;
}
}The field is private, but the state is not protected.
A caller can execute:
policy.getPermissions().clear();The caller has now changed the internal state of UserAccessPolicy without the class controlling the operation.
Another problem exists in the constructor:
Set<String> permissions = new HashSet<>();
permissions.add("READ");
UserAccessPolicy policy = new UserAccessPolicy(permissions);
permissions.add("DELETE");The UserAccessPolicy object now indirectly receives the DELETE permission because it stores the same mutable Set.
Protecting internal mutable state usually requires defensive copying, immutable representations, controlled mutation methods, or carefully chosen read-only views.
3. Why It Matters in Real Projects
Maintainability
When any caller can modify an object's internal state, developers cannot easily identify where a value changed.
Instead of searching for methods such as:
addPermission(...)
removePermission(...)developers may need to search the entire application for code modifying a collection reference.
Debugging
Unexpected shared mutation creates difficult bugs.
For example:
customer.getNotificationChannels().clear();If this reference points directly to internal state, a completely unrelated method may change the customer object's behavior.
The resulting bug may appear much later in the request.
Reliability
Domain invariants can be bypassed.
Suppose a business rule says every administrative account must contain the READ permission.
A controlled method could enforce that rule.
Direct collection access bypasses it completely.
Team Development
Different developers frequently work on the same domain model.
One developer may assume a returned collection is safe to modify while another assumes the object controls the collection.
Clear ownership avoids this ambiguity.
Concurrency
Shared mutable state becomes particularly dangerous when an object is accessed by multiple threads.
Protecting state does not automatically make a class thread-safe, but reducing exposed mutable references significantly reduces accidental shared modification.
Security
Some mutable state directly affects authorization, configuration, or sensitive business rules.
Examples include:
- User roles
- Permissions
- Allowed account operations
- Security headers
- Approved destinations
- Payment limits
Unexpected mutation of these values can become a security issue.
4. Core Concept
The central rule is:
A class should not expose mutable objects that it owns unless external mutation is intentionally part of its contract.
There are two major exposure points.
Constructor and Setter Inputs
This code is unsafe:
public UserAccessPolicy(Set<String> permissions) {
this.permissions = permissions;
}The caller retains the original reference.
The safer approach is:
this.permissions = new HashSet<>(permissions);or, when mutation is not required:
this.permissions = Set.copyOf(permissions);Getter Outputs
This code exposes internal state:
public Set<String> getPermissions() {
return permissions;
}Possible safer approaches include:
return Set.copyOf(permissions);or:
return Collections.unmodifiableSet(permissions);These approaches are similar but not identical.
Immutable Copy
return Set.copyOf(permissions);The caller receives a separate immutable representation.
Later internal changes do not change the previously returned collection.
Unmodifiable View
return Collections.unmodifiableSet(permissions);The caller cannot modify the returned collection directly, but the returned object is still backed by the original collection.
If the class later changes the internal collection, the caller sees those changes.
That distinction matters when designing APIs.
5. Important Rules
- Do not assume a
privatefield automatically provides encapsulation. - Do not store caller-provided mutable collections directly unless shared ownership is intentional.
- Do not return internal mutable collections directly from getters.
- Prefer immutable Java types when the state does not need to change.
- Prefer
List.copyOf,Set.copyOf, andMap.copyOfwhen immutable copies are appropriate. - Copy arrays before storing or returning them.
- Be careful with mutable elements inside otherwise immutable collections.
- Avoid exposing JPA entity collections directly through API DTOs.
- Use domain methods for meaningful state transitions.
- Validate state changes inside the owning object when business invariants exist.
- Document intentionally shared mutable objects.
- Do not create expensive deep copies blindly without understanding object ownership and performance requirements.
6. Bad Code Example
Consider a payment-processing system that maintains the payment methods and risk rules allowed for a merchant.
import java.util.List;
import java.util.Map;
public class MerchantPaymentPolicy {
private final List<String> allowedPaymentMethods;
private final Map<String, Integer> transactionLimits;
public MerchantPaymentPolicy(
List<String> allowedPaymentMethods,
Map<String, Integer> transactionLimits) {
this.allowedPaymentMethods = allowedPaymentMethods;
this.transactionLimits = transactionLimits;
}
public List<String> getAllowedPaymentMethods() {
return allowedPaymentMethods;
}
public Map<String, Integer> getTransactionLimits() {
return transactionLimits;
}
}Application code may use it like this:
MerchantPaymentPolicy policy = policyService.getPolicy(merchantId);
policy.getAllowedPaymentMethods().clear();
policy.getTransactionLimits().put("DAILY", Integer.MAX_VALUE);No business method on MerchantPaymentPolicy was called.
The caller directly changed important payment rules.
There is also another exposure path.
List<String> methods = new ArrayList<>();
methods.add("CARD");
Map<String, Integer> limits = new HashMap<>();
limits.put("DAILY", 100000);
MerchantPaymentPolicy policy =
new MerchantPaymentPolicy(methods, limits);
methods.add("UNSUPPORTED_METHOD");
limits.put("DAILY", Integer.MAX_VALUE);The object's internal state changes because the constructor stored the original references.
7. Problems in the Bad Code
Encapsulation Is Broken
The class does not control its own state.
Although the fields are private, external code can directly modify their contents.
Business Rules Can Be Bypassed
Suppose payment methods should only be enabled after configuration validation.
Direct access allows:
policy.getAllowedPaymentMethods().add("UNKNOWN");No validation occurs.
Transaction Limits Can Be Corrupted
A caller can execute:
policy.getTransactionLimits().put("DAILY", -1);or:
policy.getTransactionLimits().put("DAILY", Integer.MAX_VALUE);This may violate business assumptions elsewhere.
Hidden Side Effects
A method may receive the policy only for reading but accidentally modify it.
For example:
private void logPolicy(MerchantPaymentPolicy policy) {
policy.getAllowedPaymentMethods().remove("CARD");
}The modification is not obvious from the calling code.
Constructor Aliasing
The constructor stores external references.
Therefore the object's state can change even without calling any method on the object.
Difficult Debugging
When the daily payment limit unexpectedly changes, developers cannot simply inspect methods on MerchantPaymentPolicy.
Any code holding the map reference may be responsible.
Security Risk
If mutable state controls authorization or transaction limits, uncontrolled mutation can affect enforcement of security-sensitive business policies.
Concurrency Risk
If the same object is shared across threads, one thread may modify collections while another reads them.
Possible effects include:
- Inconsistent behavior
- Race conditions
ConcurrentModificationException- Unexpected validation results
8. Code Review Findings
A senior reviewer should notice several issues.
Finding 1: Constructor Stores Mutable References
this.allowedPaymentMethods = allowedPaymentMethods;The class does not establish ownership of the collection.
The caller can continue modifying it.
Finding 2: Getter Returns Internal Collection
return allowedPaymentMethods;This gives callers direct mutation access.
Finding 3: Map Containing Business Limits Is Exposed
The transaction limit map represents important domain configuration.
It should not be arbitrarily editable by callers.
Finding 4: No Controlled Mutation API Exists
If modification is allowed, the class should expose meaningful methods such as:
enablePaymentMethod(...)
updateTransactionLimit(...)These methods can validate inputs and enforce business rules.
Finding 5: Class Invariants Cannot Be Guaranteed
Because external code can mutate the collections directly, the class cannot guarantee that its own data remains valid.
9. Reviewer Comment Example
Possible PR review comments:
getAllowedPaymentMethods()currently exposes the internal mutable list. Could we return an immutable copy so callers cannot modify policy state directly?- We are storing the constructor-provided map by reference. Please create a defensive copy to prevent later caller modifications from changing this object.
- Transaction limits are domain state and should not be updated through the getter. Consider providing a validated update method instead.
private finalprotects the field reference, not the contents of the collection. The collection itself is still mutable.- Could we use
List.copyOf()andMap.copyOf()here if this policy is intended to be immutable?
10. Improved Code
If the payment policy is intended to be immutable, a simple implementation is:
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class MerchantPaymentPolicy {
private final List<String> allowedPaymentMethods;
private final Map<String, Integer> transactionLimits;
public MerchantPaymentPolicy(
List<String> allowedPaymentMethods,
Map<String, Integer> transactionLimits) {
Objects.requireNonNull(allowedPaymentMethods, "allowedPaymentMethods must not be null");
Objects.requireNonNull(transactionLimits, "transactionLimits must not be null");
this.allowedPaymentMethods = List.copyOf(allowedPaymentMethods);
this.transactionLimits = Map.copyOf(transactionLimits);
}
public List<String> getAllowedPaymentMethods() {
return allowedPaymentMethods;
}
public Map<String, Integer> getTransactionLimits() {
return transactionLimits;
}
}Because the stored collections are immutable, returning them directly is safe.
A caller attempting this:
policy.getAllowedPaymentMethods().add("CASH");will receive an UnsupportedOperationException.
If the object must support controlled mutation, another implementation is preferable:
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public class MerchantPaymentPolicy {
private final Set<String> allowedPaymentMethods;
private final Map<String, Integer> transactionLimits;
public MerchantPaymentPolicy(
Set<String> allowedPaymentMethods,
Map<String, Integer> transactionLimits) {
Objects.requireNonNull(allowedPaymentMethods, "allowedPaymentMethods must not be null");
Objects.requireNonNull(transactionLimits, "transactionLimits must not be null");
this.allowedPaymentMethods = new HashSet<>(allowedPaymentMethods);
this.transactionLimits = new HashMap<>(transactionLimits);
}
public Set<String> getAllowedPaymentMethods() {
return Set.copyOf(allowedPaymentMethods);
}
public Map<String, Integer> getTransactionLimits() {
return Map.copyOf(transactionLimits);
}
public void enablePaymentMethod(String paymentMethod) {
Objects.requireNonNull(paymentMethod, "paymentMethod must not be null");
if (paymentMethod.isBlank()) {
throw new IllegalArgumentException("paymentMethod must not be blank");
}
allowedPaymentMethods.add(paymentMethod);
}
public void updateTransactionLimit(String limitType, int amount) {
Objects.requireNonNull(limitType, "limitType must not be null");
if (amount <= 0) {
throw new IllegalArgumentException("Transaction limit must be greater than zero");
}
transactionLimits.put(limitType, amount);
}
}11. Improved Code Explanation
Defensive Copy in the Constructor
Instead of:
this.allowedPaymentMethods = allowedPaymentMethods;the mutable version uses:
this.allowedPaymentMethods = new HashSet<>(allowedPaymentMethods);The object now owns its collection.
Future modifications to the constructor argument do not affect the policy.
Immutable Output
Instead of returning the internal collection:
return allowedPaymentMethods;the class returns:
return Set.copyOf(allowedPaymentMethods);The caller can inspect the current state but cannot modify internal state through the returned reference.
Business Mutation Methods
Instead of:
policy.getTransactionLimits().put("DAILY", amount);the caller uses:
policy.updateTransactionLimit("DAILY", amount);The owning class now controls validation.
Domain Invariants Remain Local
Validation remains near the state being protected.
For example:
if (amount <= 0) {
throw new IllegalArgumentException(...);
}Every caller receives the same validation behavior.
Reduced Coupling
External code no longer depends on the implementation detail that transaction limits happen to be stored in a Map.
The implementation can change later without forcing callers to rewrite mutation logic.
12. Bad Code vs Improved Code
| Area | Bad Code | Improved Code |
|---|---|---|
| Encapsulation | Mutable references exposed | State remains owned by class |
| Constructor safety | Stores caller references | Creates defensive copies |
| Getter safety | Callers modify collections | Callers receive immutable data |
| Validation | Easily bypassed | Centralized in domain methods |
| Maintainability | Mutation can happen anywhere | Mutation occurs through known methods |
| Debugging | Difficult to trace state changes | State transitions are explicit |
| Testability | Hard to isolate mutation paths | Domain methods can be tested directly |
| Reliability | Invalid state can be inserted | Validation protects invariants |
| Concurrency risk | Shared references increase risk | Reduced accidental shared mutation |
13. Real Project Scenario
Consider an e-commerce microservice that loads merchant payment configuration from a database.
The configuration controls:
- Supported payment methods
- Maximum transaction amount
- Daily settlement limit
- Allowed currencies
- Fraud-check settings
The service caches the configuration because it is read frequently.
A developer writes:
MerchantPaymentPolicy policy = cache.get(merchantId);Another component uses:
policy.getAllowedPaymentMethods().remove("CARD");The developer only intended to temporarily filter payment methods for one API response.
However, because the returned list is the cached object's internal list, the cached configuration itself is changed.
Every subsequent request for that merchant may now behave as if card payments are disabled.
The problem becomes particularly difficult because:
- The database still contains
CARD - The cache still contains the same policy object
- No configuration-update endpoint was called
- No audit log exists
- Restarting the application may temporarily fix the issue
A defensive copy or immutable policy prevents this entire class of bug.
14. Production Impact
If exposed mutable state reaches production, realistic consequences include:
Incorrect Business Behavior
Payment methods, permissions, pricing rules, feature flags, or processing configuration may change unexpectedly.
Intermittent Bugs
The bug may depend on which request mutates the shared object first.
Cache Corruption
A cached domain object can be modified unintentionally and affect many later requests.
Difficult Debugging
Database values may appear correct while in-memory state is corrupted.
Concurrency Problems
Multiple requests can observe different versions of shared mutable data.
Security Problems
If roles, permissions, allowed operations, or transaction limits are exposed, unauthorized state changes may become possible inside application code.
Maintenance Problems
Future developers may be afraid to change seemingly unrelated code because object ownership is unclear.
15. Common Developer Mistakes
Mistake 1: Assuming final Makes a Collection Immutable
This does not make the list immutable:
private final List<String> permissions = new ArrayList<>();final only prevents this:
permissions = anotherList;It does not prevent:
permissions.add("ADMIN");Mistake 2: Returning Collections Directly
public List<OrderItem> getItems() {
return items;
}This allows external mutation.
Mistake 3: Defensive Copy Only in Getter
A developer may write:
public List<String> getPermissions() {
return new ArrayList<>(permissions);
}but forget that the constructor still stores the caller's list directly.
Both input and output boundaries must be reviewed.
Mistake 4: Using Collections.unmodifiableList() and Assuming It Is a Copy
return Collections.unmodifiableList(items);This prevents the caller from mutating through the returned reference, but it is still a view of the original list.
Internal changes remain visible.
Mistake 5: Performing Only a Shallow Copy
Consider:
List<CustomerAddress> addresses;Using:
List.copyOf(addresses);protects the list structure, but not necessarily each CustomerAddress object.
If CustomerAddress is mutable, callers may still modify:
addresses.get(0).setCity("Mumbai");The ownership model of collection elements must also be considered.
Mistake 6: Exposing Arrays
public byte[] getDocument() {
return document;
}Arrays are mutable.
Return a copy instead:
return document.clone();Mistake 7: Returning Mutable Legacy Date Objects
java.util.Date is mutable.
Prefer immutable java.time types such as:
InstantLocalDateLocalDateTimeOffsetDateTime
when appropriate.
Mistake 8: Exposing JPA Collections Directly
Entity collections may be mutable and may also involve persistence-context behavior.
Direct exposure can cause:
- Unexpected entity modifications
- Dirty checking
- Lazy-loading queries
- Accidental persistence changes
Mistake 9: Deep Copying Everything
Deep copying every object can create unnecessary complexity and allocation overhead.
The correct solution depends on ownership and mutation requirements.
16. Edge Cases
Null Collections
List.copyOf(null) throws NullPointerException.
Decide explicitly whether null should be:
- Rejected
- Converted to an empty collection
- Supported as meaningful domain state
Usually, empty collections are easier to work with than nullable collections.
Null Elements
List.copyOf() rejects null elements.
If legacy data allows null, migration may require validation or cleanup.
Empty Collections
Empty collections are valid in many cases.
Prefer immutable empty values rather than returning null.
Duplicate Values
A conversion from List to Set changes semantics by removing duplicates.
Do not use Set only for immutability if ordering or duplicates matter.
Mutable Elements
This is critical.
List<PaymentRule> copy = List.copyOf(paymentRules);The list structure is immutable.
But if PaymentRule is mutable, its objects may still be changed.
Nested Collections
For:
Map<String, List<String>>Map.copyOf() protects the map structure.
It does not automatically make each nested list immutable.
You may need:
Map<String, List<String>> safeCopy =
source.entrySet()
.stream()
.collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry -> List.copyOf(entry.getValue())
));Arrays
Use copying:
this.secretBytes = secretBytes.clone();and:
return secretBytes.clone();For subranges or transformations, Arrays.copyOf() may be appropriate.
Concurrency
Defensive copies reduce accidental sharing but do not automatically make an internally mutable class thread-safe.
If the object is intentionally mutated concurrently, synchronization or another concurrency strategy may still be required.
17. Performance Considerations
Protecting mutable state can involve additional object allocation.
For example:
return List.copyOf(items);may create a new immutable collection depending on the supplied object.
Small Domain Collections
For small collections such as:
- Roles
- Permissions
- Supported currencies
- Payment methods
- Configuration flags
the correctness benefit usually outweighs the copy cost.
Large Collections
Repeatedly copying very large collections in frequently executed getters can become expensive.
For example:
public List<Transaction> getTransactions() {
return List.copyOf(transactions);
}If this is called thousands of times per request and contains hundreds of thousands of elements, allocation costs may matter.
Possible alternatives include:
- Making the internal collection immutable once
- Returning a stable immutable collection
- Returning purpose-specific read methods
- Returning paginated data
- Returning streams carefully
- Redesigning ownership
Copy Once Instead of Every Read
For immutable objects:
this.items = List.copyOf(items);Then this is safe:
public List<Item> getItems() {
return items;
}There is no need to copy on every getter call because the internal collection itself cannot change.
Deep Copies
Deep copies can be expensive because every nested mutable object may require another object allocation.
Do not deep copy without a clear ownership requirement.
Database Calls
Defensive copying itself does not cause database calls.
However, with JPA lazy-loaded collections, touching or copying a collection may initialize it and trigger a query.
For example:
List.copyOf(order.getItems());may initialize a lazy association.
Review transaction boundaries and fetching behavior.
18. Security Considerations
Protecting mutable state can be security-relevant when internal data affects authorization or sensitive operations.
Permission Collections
Unsafe:
user.getRoles().add("ADMIN");If the role collection is internal authorization state, direct mutation should not be possible.
Transaction Limits
A mutable map containing payment limits should not be exposed to arbitrary application components.
Sensitive Byte Arrays
Security-sensitive values may be stored in byte arrays.
Examples include:
- Cryptographic material
- Tokens
- Binary credentials
Returning the original array exposes the data to modification.
Use defensive copies where required.
Data Exposure vs Mutation
Defensive copying prevents modification but does not prevent reading.
If data is sensitive, access control is still required.
Logging
Do not solve mutable-state problems by logging entire sensitive objects.
For example, avoid logging:
- Authentication tokens
- Full payment data
- Secret configuration
- Personal information
Encapsulation and logging security are separate concerns.
19. Testing Considerations
Tests should verify both normal behavior and protection against external mutation.
Constructor Defensive Copy Test
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
class MerchantPaymentPolicyTest {
@Test
void shouldNotChangePolicyWhenOriginalSetIsModified() {
Set<String> methods = new HashSet<>();
methods.add("CARD");
MerchantPaymentPolicy policy =
new MerchantPaymentPolicy(methods, Map.of("DAILY", 10000));
methods.add("CASH");
assertEquals(Set.of("CARD"), policy.getAllowedPaymentMethods());
}
}Getter Protection Test
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void shouldNotAllowCallerToModifyReturnedPaymentMethods() {
MerchantPaymentPolicy policy =
new MerchantPaymentPolicy(
Set.of("CARD"),
Map.of("DAILY", 10000)
);
Set<String> methods = policy.getAllowedPaymentMethods();
assertThrows(
UnsupportedOperationException.class,
() -> methods.add("CASH")
);
}Validation Test
@Test
void shouldRejectInvalidTransactionLimit() {
MerchantPaymentPolicy policy =
new MerchantPaymentPolicy(
Set.of("CARD"),
Map.of("DAILY", 10000)
);
assertThrows(
IllegalArgumentException.class,
() -> policy.updateTransactionLimit("DAILY", 0)
);
}Useful Test Cases
Test:
- Valid collections
- Empty collections
- Null collection arguments
- Invalid business values
- Mutation of original constructor arguments
- Mutation attempts on returned collections
- Nested mutable elements
- Large collections where performance matters
- Concurrent behavior if the object is intentionally shared across threads
20. Refactoring Guidelines
When fixing exposed mutable state in an existing system, avoid changing behavior blindly.
Step 1: Identify Ownership
Determine:
- Who creates the collection?
- Who currently modifies it?
- Does the class own it?
- Is shared mutation intentional?
Step 2: Search for Getter Mutations
Look for patterns such as:
getItems().add(...)
getItems().remove(...)
getItems().clear(...)
getConfig().put(...)These callers will break if the getter becomes immutable.
Step 3: Introduce Domain Methods
Replace:
order.getItems().add(item);with:
order.addItem(item);Replace:
account.getRoles().remove(role);with:
account.removeRole(role);Step 4: Add Validation
Move relevant rules into the domain method.
Step 5: Protect Constructor Inputs
Create copies when taking ownership.
Step 6: Protect Getter Outputs
Return immutable state or copies.
Step 7: Add Regression Tests
Verify business behavior remains unchanged.
Step 8: Review Persistence Behavior
For JPA entities, verify:
- Dirty checking
- Cascade behavior
- Lazy loading
- Orphan removal
- Transaction boundaries
Changing collection handling can affect ORM behavior.
21. Best Practices
Prefer Immutable Value Objects
If state never needs to change after construction, make the object immutable.
Use Modern Collection Factory Methods
Useful APIs include:
List.copyOf(...)
Set.copyOf(...)
Map.copyOf(...)Use Immutable Java Time Types
Prefer java.time classes over mutable legacy date APIs where applicable.
Expose Behavior Instead of Data Structure Mutation
Prefer:
cart.addItem(product, quantity);over:
cart.getItems().add(item);Establish Clear Ownership
A class should know whether it:
- Owns the data
- Shares the data
- Only reads the data
Copy at Boundaries
External input entering a domain object is a common place for defensive copying.
Protect Nested Mutable State
Do not stop at the outer collection when elements are mutable.
Prefer Read-Only DTOs for API Responses
Do not expose live domain entities merely because the API needs to display their current state.
22. Practices to Avoid
Returning Mutable Internal Collections
Avoid:
return internalList;unless direct mutation is intentionally part of the API contract.
Storing Mutable Constructor Arguments Directly
Avoid:
this.items = items;when the class is supposed to own its state.
Returning Arrays Directly
Avoid:
return bytes;Use a defensive copy where required.
Using Getters as Mutation APIs
Avoid:
order.getItems().add(item);The intent is weak and invariants are difficult to enforce.
Assuming final Means Immutable
final protects assignment of the reference, not mutation of the referenced object.
Blind Deep Copying
Avoid large manual copy frameworks unless actual ownership requirements justify them.
Using Immutable Wrappers Without Understanding Their Semantics
An unmodifiable view and an immutable copy are different concepts.
Choose deliberately.
23. Code Review Checklist
- Does this class store any mutable collection, array, or mutable object?
- Are constructor arguments copied before being stored?
- Can the caller modify the original input after construction?
- Does any getter directly return an internal mutable collection?
- Does any getter return an internal array?
- Is
finalbeing incorrectly treated as immutability? - Should
List.copyOf(),Set.copyOf(), orMap.copyOf()be used? - Is an unmodifiable view sufficient, or is an independent immutable copy required?
- Are collection elements themselves mutable?
- Are nested collections also protected?
- Are business invariants bypassed through collection getters?
- Should mutation happen through domain-specific methods?
- Could exposed state affect roles, permissions, limits, or other security-sensitive behavior?
- Could the object be cached and unintentionally modified by another request?
- Is the object shared between threads?
- Could copying a JPA collection trigger lazy loading?
- Is defensive copying being performed repeatedly in a performance-sensitive path?
- Are unit tests verifying that constructor inputs cannot mutate internal state?
- Are tests verifying that returned collections cannot mutate internal state?
- Is object ownership clear from the design?
24. Common Pull Request Review Comments
This getter exposes the internal list directly. Could we return an immutable collection so callers cannot modify the object's state?
We're retaining the mutable collection provided by the caller. Please make a defensive copy in the constructor to avoid aliasing.
The field is final, but the HashMap itself is still mutable. External callers can currently modify it through this getter.
Could we replace getItems().add(...) with an addItem(...) method so the aggregate can enforce its own business rules?
Collections.unmodifiableList() creates a read-only view rather than an independent snapshot. Is that the intended behavior here?
List.copyOf() protects the collection structure, but PaymentRule is mutable. Do we also need to protect or redesign the elements?
This byte[] is returned directly. Please return a defensive copy because callers can modify the underlying array.
This configuration object is cached across requests, so exposing its mutable map creates a risk of cross-request state corruption.
Please add a test that modifies the original constructor collection after object creation and verifies that the object remains unchanged.
This JPA collection is being copied outside the transaction boundary. Please verify whether accessing it can trigger lazy initialization issues.
25. Code Review Exercise
Review the following code from an order-processing service.
Identify:
- Problems
- Code smells
- Risks
- Possible production impact
- Appropriate improvements
import java.util.ArrayList; import java.util.List;
public class Order { private final String orderId; private final List<OrderItem> items; private byte[] invoiceDocument;
``` public Order( String orderId, List<OrderItem> items, byte[] invoiceDocument) { this.orderId = orderId; this.items = items; this.invoiceDocument = invoiceDocument; }
public String getOrderId() { return orderId; }
public List<OrderItem> getItems() { return items; }
public byte[] getInvoiceDocument() { return invoiceDocument; } ```
}
public class OrderItem { private String productCode; private int quantity;
``` public OrderItem(String productCode, int quantity) { this.productCode = productCode; this.quantity = quantity; }
public String getProductCode() { return productCode; }
public void setProductCode(String productCode) { this.productCode = productCode; }
public int getQuantity() { return quantity; }
public void setQuantity(int quantity) { this.quantity = quantity; } ```
}
Consider questions such as:
- Can callers modify the original item list?
- Can callers modify the list returned from
getItems()? - Can callers modify individual
OrderItemobjects? - Can callers modify the invoice array?
- What happens if this object is cached?
- What business rules could be bypassed?
26. Exercise Solution
The implementation exposes mutable state through multiple paths.
Issue 1: Constructor Stores Item List Directly
this.items = items;The caller can modify the list after creating the order.
Issue 2: Getter Returns Internal List
return items;Any caller can add or remove order items.
For example:
order.getItems().clear();Issue 3: OrderItem Is Mutable
Even if we changed the outer list to:
List.copyOf(items);a caller could still execute:
order.getItems().get(0).setQuantity(999999);Therefore protecting only the list is insufficient.
Issue 4: Invoice Array Is Stored Directly
this.invoiceDocument = invoiceDocument;The caller retains a reference.
Issue 5: Invoice Array Is Returned Directly
return invoiceDocument;A caller can modify the internal array.
Improved Design
A better approach is to make OrderItem immutable and defensively copy mutable containers.
import java.util.List;
import java.util.Objects;
public final class Order {
private final String orderId;
private final List<OrderItem> items;
private final byte[] invoiceDocument;
public Order(
String orderId,
List<OrderItem> items,
byte[] invoiceDocument) {
this.orderId = Objects.requireNonNull(orderId, "orderId must not be null");
this.items = List.copyOf(
Objects.requireNonNull(items, "items must not be null")
);
this.invoiceDocument =
Objects.requireNonNull(
invoiceDocument,
"invoiceDocument must not be null"
).clone();
}
public String getOrderId() {
return orderId;
}
public List<OrderItem> getItems() {
return items;
}
public byte[] getInvoiceDocument() {
return invoiceDocument.clone();
}
}
import java.util.Objects;
public final class OrderItem {
private final String productCode;
private final int quantity;
public OrderItem(String productCode, int quantity) {
this.productCode =
Objects.requireNonNull(
productCode,
"productCode must not be null"
);
if (quantity <= 0) {
throw new IllegalArgumentException(
"quantity must be greater than zero"
);
}
this.quantity = quantity;
}
public String getProductCode() {
return productCode;
}
public int getQuantity() {
return quantity;
}
}Why These Changes Help
Immutable OrderItem
OrderItem no longer contains setters.
The object cannot be modified after creation.
Therefore an immutable list of OrderItem objects is genuinely much safer.
Immutable Item Collection
this.items = List.copyOf(items);Changes to the original caller list do not affect the order.
The stored list cannot be structurally modified.
Safe Getter
return items;Returning the list is safe because the list and its elements are immutable.
Array Defensive Copy on Input
invoiceDocument.clone();The caller cannot modify the order by changing the array originally supplied to the constructor.
Array Defensive Copy on Output
return invoiceDocument.clone();The caller receives another array.
Changes to that array do not affect the order.
27. Interview Perspective
This topic often appears indirectly in Java and senior developer interviews.
Instead of asking only:
"What is defensive copying?"
an interviewer may provide code such as:
public List<String> getRoles() {
return roles;
}and ask:
"What problems do you see?"
A strong candidate should explain:
- The internal collection is exposed.
privatedoes not guarantee encapsulation.finaldoes not make the collection immutable.- Callers can bypass validation.
- Shared mutable state creates debugging problems.
- Defensive copying or immutable state can solve the issue.
Collections.unmodifiableList()andList.copyOf()have different semantics.- A shallow copy does not protect mutable elements.
- Domain methods may be better than exposing collection mutation.
Spring Boot Perspective
An interviewer may ask about cached configuration.
Example:
A singleton Spring bean stores mutable configuration and returns the same list to every request.
What can happen?
A strong answer should mention cross-request modification and potential concurrency problems.
JPA Perspective
An interviewer may ask whether entity associations should be exposed directly.
The answer should consider:
- Encapsulation
- Persistence behavior
- Dirty checking
- Lazy loading
- Business invariants
- DTO boundaries
Senior Developer Perspective
Senior-level discussion usually focuses on ownership.
The important question is not merely:
"Should every getter return a copy?"
The stronger question is:
"Who owns this data, who is allowed to mutate it, and what behavior should the API expose?"
28. Interview Questions and Answers
Basic Question
Question: Why is returning an internal ArrayList directly from a getter risky?
Answer:
Because callers receive the same mutable object stored inside the class.
They can execute methods such as:
add(...)
remove(...)
clear(...)and modify the class's internal state without going through controlled business methods.
Intermediate Question
Question: Does declaring a collection field private final make it immutable?
Answer:
No.
private prevents direct field access from outside the class.
final prevents the field reference from being reassigned.
Neither prevents mutation of the referenced collection.
For example:
private final List<String> roles = new ArrayList<>();This is still legal:
roles.add("ADMIN");Advanced Question
Question: What is the difference between Collections.unmodifiableList() and List.copyOf()?
Answer:
Collections.unmodifiableList(original) generally provides an unmodifiable view backed by the original list.
The caller cannot mutate through that view, but changes made to the original list may still become visible.
List.copyOf(original) creates an unmodifiable collection representing the elements at copy time.
The two therefore have different ownership and visibility semantics.
Scenario-Based Question
Question: A Spring Boot service caches a UserPermissionConfig object. Its getPermissions() method returns the internal HashSet. What production problem could occur?
Answer:
Any request receiving the cached object can modify the permission set.
Because the same cached object may be reused by later requests, one request can unintentionally change behavior for other users or requests.
This may create incorrect authorization behavior, intermittent failures, and difficult debugging.
Code-Review Question
Question: What would you review in this code?
public class Account {
private final List<String> roles;
public Account(List<String> roles) {
this.roles = roles;
}
public List<String> getRoles() {
return new ArrayList<>(roles);
}
}Answer:
The getter protects the internal list from direct output mutation, but the constructor still stores the original caller-owned list.
The caller can modify the original list after constructing Account.
The constructor should also make a defensive copy.
If the collection never needs internal mutation, List.copyOf(roles) is an even simpler option.
Real-Project Question
Question: Should every collection getter always return a new collection?
Answer:
No.
The correct approach depends on ownership and mutability.
If the class stores an immutable collection:
this.roles = List.copyOf(roles);then returning that immutable collection directly is normally safe.
Creating another copy on every getter call may add unnecessary allocation.
The goal is controlled ownership, not copying for its own sake.
Mutable-Element Question
Question: Is List.copyOf() sufficient if the collection contains mutable objects?
Answer:
Not necessarily.
List.copyOf() prevents callers from adding or removing list entries, but it does not automatically clone the objects stored in the list.
If the elements themselves are mutable, callers may still modify those objects.
Possible solutions include:
- Immutable element types
- Defensive element copying
- Read-only DTOs
- Different ownership boundaries
Design Question
Question: Why can domain methods be better than exposing mutable collections?
Answer:
Domain methods communicate intent and enforce invariants.
For example:
account.assignRole(role);is better than:
account.getRoles().add(role);because assignRole() can:
- Validate the role
- Reject duplicates
- Check authorization rules
- Maintain audit information
- Trigger related business behavior
29. Quick Rule to Remember
If callers can obtain your mutable object and change it, they effectively own part of your object's state.
30. Final Takeaway
Protecting internal mutable state is a practical Java encapsulation technique, not merely an academic object-oriented rule.
Developers should remember that:
privatedoes not protect mutable objects returned through getters.finaldoes not make collections immutable.- Constructor arguments can expose state through shared references.
- Arrays require defensive copying when ownership must remain internal.
- Immutable collection factories are useful for stable domain state.
- An immutable outer collection does not automatically make mutable elements immutable.
- Domain-specific mutation methods are often better than exposing collections for modification.
- Object ownership should be explicit.
During Pull Request review, reviewers should check every boundary where mutable state enters or leaves a class.
Question code such as:
this.items = items;
return items;
return data;
entity.getChildren().add(child);These patterns are not automatically incorrect, but they require an intentional ownership decision.
Production code should avoid accidental shared mutation because it creates hidden dependencies, bypassed validation, difficult debugging, concurrency risks, and corrupted business state.
The practical review principle is simple:
Let the owning class control its state, and expose only the level of mutation that the rest of the application actually needs.