Protecting Internal Mutable State

16 min read

Object-Oriented Design and SOLID Review — review Java classes that expose mutable collections, arrays, or objects and apply defensive copies to prevent unintended external mutation.

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:

  • ArrayList
  • HashSet
  • HashMap
  • Arrays
  • StringBuilder
  • java.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:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
public UserAccessPolicy(Set<String> permissions) {
    this.permissions = permissions;
}

The caller retains the original reference.

The safer approach is:

JAVA
this.permissions = new HashSet<>(permissions);

or, when mutation is not required:

JAVA
this.permissions = Set.copyOf(permissions);

Getter Outputs

This code exposes internal state:

JAVA
public Set<String> getPermissions() {
    return permissions;
}

Possible safer approaches include:

JAVA
return Set.copyOf(permissions);

or:

JAVA
return Collections.unmodifiableSet(permissions);

These approaches are similar but not identical.

Immutable Copy

JAVA
return Set.copyOf(permissions);

The caller receives a separate immutable representation.

Later internal changes do not change the previously returned collection.

Unmodifiable View

JAVA
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 private field 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, and Map.copyOf when 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.

JAVA
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:

JAVA
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.

JAVA
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:

JAVA
policy.getAllowedPaymentMethods().add("UNKNOWN");

No validation occurs.

Transaction Limits Can Be Corrupted

A caller can execute:

JAVA
policy.getTransactionLimits().put("DAILY", -1);

or:

JAVA
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:

JAVA
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

JAVA
this.allowedPaymentMethods = allowedPaymentMethods;

The class does not establish ownership of the collection.

The caller can continue modifying it.

Finding 2: Getter Returns Internal Collection

JAVA
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:

JAVA
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 final protects the field reference, not the contents of the collection. The collection itself is still mutable.
  • Could we use List.copyOf() and Map.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:

JAVA
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:

JAVA
policy.getAllowedPaymentMethods().add("CASH");

will receive an UnsupportedOperationException.

If the object must support controlled mutation, another implementation is preferable:

JAVA
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:

JAVA
this.allowedPaymentMethods = allowedPaymentMethods;

the mutable version uses:

JAVA
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:

JAVA
return allowedPaymentMethods;

the class returns:

JAVA
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:

JAVA
policy.getTransactionLimits().put("DAILY", amount);

the caller uses:

JAVA
policy.updateTransactionLimit("DAILY", amount);

The owning class now controls validation.

Domain Invariants Remain Local

Validation remains near the state being protected.

For example:

JAVA
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

AreaBad CodeImproved Code
EncapsulationMutable references exposedState remains owned by class
Constructor safetyStores caller referencesCreates defensive copies
Getter safetyCallers modify collectionsCallers receive immutable data
ValidationEasily bypassedCentralized in domain methods
MaintainabilityMutation can happen anywhereMutation occurs through known methods
DebuggingDifficult to trace state changesState transitions are explicit
TestabilityHard to isolate mutation pathsDomain methods can be tested directly
ReliabilityInvalid state can be insertedValidation protects invariants
Concurrency riskShared references increase riskReduced 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:

JAVA
MerchantPaymentPolicy policy = cache.get(merchantId);

Another component uses:

JAVA
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:

JAVA
private final List<String> permissions = new ArrayList<>();

final only prevents this:

JAVA
permissions = anotherList;

It does not prevent:

JAVA
permissions.add("ADMIN");

Mistake 2: Returning Collections Directly

JAVA
public List<OrderItem> getItems() {
    return items;
}

This allows external mutation.

Mistake 3: Defensive Copy Only in Getter

A developer may write:

JAVA
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

JAVA
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:

JAVA
List<CustomerAddress> addresses;

Using:

JAVA
List.copyOf(addresses);

protects the list structure, but not necessarily each CustomerAddress object.

If CustomerAddress is mutable, callers may still modify:

JAVA
addresses.get(0).setCity("Mumbai");

The ownership model of collection elements must also be considered.

Mistake 6: Exposing Arrays

JAVA
public byte[] getDocument() {
    return document;
}

Arrays are mutable.

Return a copy instead:

JAVA
return document.clone();

Mistake 7: Returning Mutable Legacy Date Objects

java.util.Date is mutable.

Prefer immutable java.time types such as:

  • Instant
  • LocalDate
  • LocalDateTime
  • OffsetDateTime

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.

JAVA
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:

JAVA
Map<String, List<String>>

Map.copyOf() protects the map structure.

It does not automatically make each nested list immutable.

You may need:

JAVA
Map<String, List<String>> safeCopy =
        source.entrySet()
                .stream()
                .collect(Collectors.toUnmodifiableMap(
                        Map.Entry::getKey,
                        entry -> List.copyOf(entry.getValue())
                ));

Arrays

Use copying:

JAVA
this.secretBytes = secretBytes.clone();

and:

JAVA
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:

JAVA
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:

JAVA
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:

JAVA
this.items = List.copyOf(items);

Then this is safe:

JAVA
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:

JAVA
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:

JAVA
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

JAVA
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

JAVA
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

JAVA
@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:

JAVA
getItems().add(...)
getItems().remove(...)
getItems().clear(...)
getConfig().put(...)

These callers will break if the getter becomes immutable.

Step 3: Introduce Domain Methods

Replace:

JAVA
order.getItems().add(item);

with:

JAVA
order.addItem(item);

Replace:

JAVA
account.getRoles().remove(role);

with:

JAVA
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:

JAVA
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:

JAVA
cart.addItem(product, quantity);

over:

JAVA
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:

JAVA
return internalList;

unless direct mutation is intentionally part of the API contract.

Storing Mutable Constructor Arguments Directly

Avoid:

JAVA
this.items = items;

when the class is supposed to own its state.

Returning Arrays Directly

Avoid:

JAVA
return bytes;

Use a defensive copy where required.

Using Getters as Mutation APIs

Avoid:

JAVA
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 final being incorrectly treated as immutability?
  • Should List.copyOf(), Set.copyOf(), or Map.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

  1. This getter exposes the internal list directly. Could we return an immutable collection so callers cannot modify the object's state?
  1. We're retaining the mutable collection provided by the caller. Please make a defensive copy in the constructor to avoid aliasing.
  1. The field is final, but the HashMap itself is still mutable. External callers can currently modify it through this getter.
  1. Could we replace getItems().add(...) with an addItem(...) method so the aggregate can enforce its own business rules?
  1. Collections.unmodifiableList() creates a read-only view rather than an independent snapshot. Is that the intended behavior here?
  1. List.copyOf() protects the collection structure, but PaymentRule is mutable. Do we also need to protect or redesign the elements?
  1. This byte[] is returned directly. Please return a defensive copy because callers can modify the underlying array.
  1. This configuration object is cached across requests, so exposing its mutable map creates a risk of cross-request state corruption.
  1. Please add a test that modifies the original constructor collection after object creation and verifies that the object remains unchanged.
  1. 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 OrderItem objects?
  • 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

JAVA
this.items = items;

The caller can modify the list after creating the order.

Issue 2: Getter Returns Internal List

JAVA
return items;

Any caller can add or remove order items.

For example:

JAVA
order.getItems().clear();

Issue 3: OrderItem Is Mutable

Even if we changed the outer list to:

JAVA
List.copyOf(items);

a caller could still execute:

JAVA
order.getItems().get(0).setQuantity(999999);

Therefore protecting only the list is insufficient.

Issue 4: Invoice Array Is Stored Directly

JAVA
this.invoiceDocument = invoiceDocument;

The caller retains a reference.

Issue 5: Invoice Array Is Returned Directly

JAVA
return invoiceDocument;

A caller can modify the internal array.

Improved Design

A better approach is to make OrderItem immutable and defensively copy mutable containers.

JAVA
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

JAVA
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

JAVA
return items;

Returning the list is safe because the list and its elements are immutable.

Array Defensive Copy on Input

JAVA
invoiceDocument.clone();

The caller cannot modify the order by changing the array originally supplied to the constructor.

Array Defensive Copy on Output

JAVA
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:

JAVA
public List<String> getRoles() {
    return roles;
}

and ask:

"What problems do you see?"

A strong candidate should explain:

  • The internal collection is exposed.
  • private does not guarantee encapsulation.
  • final does 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() and List.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:

JAVA
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:

JAVA
private final List<String> roles = new ArrayList<>();

This is still legal:

JAVA
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?

JAVA
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:

JAVA
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:

JAVA
account.assignRole(role);

is better than:

JAVA
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:

  • private does not protect mutable objects returned through getters.
  • final does 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:

JAVA
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.