Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson showed why a collection key can become unreliable when its equality-defining state changes after insertion. This lesson addresses that risk at the object boundary: you’ll design a Java value object whose state cannot be changed through either its constructor arguments or its accessors.
We’ll use an order snapshot as the running example. It’s a useful interview case because declaring fields final looks like a complete solution—until one of those fields refers to a mutable list. Plan on about 40 minutes, including time to trace the code and run the checks.
Find the two ways state can escape
Suppose an order snapshot stores product IDs supplied by a caller:
final class LeakyOrderSnapshot {
private final String orderId;
private final List<String> productIds;
LeakyOrderSnapshot(String orderId, List<String> productIds) {
this.orderId = orderId;
this.productIds = productIds;
}
List<String> productIds() {
return productIds;
}
}
The field is final, so the snapshot cannot assign it a different list. But the caller can still change the list it already references:
List<String> input = new ArrayList<>(List.of("sku-1"));
LeakyOrderSnapshot snapshot = new LeakyOrderSnapshot("order-7", input);
input.add("sku-2"); // Changes the snapshot's contents.
snapshot.productIds().clear(); // Also changes its contents.
These are separate leaks. On the way in, the constructor retains an externally owned mutable object. On the way out, the accessor hands out its internal mutable object. Fixing only one side leaves the other open.
The following short video demonstrates the accessor-side failure with a mutable Date. While watching, keep the constructor argument in mind too: an accessor fix alone cannot protect against someone retaining and changing the original input.
How to Handle Immutable Class with Mutable Reference (Date)
In “How to Handle Immutable Class with Mutable Reference (Date),” Drunken Engineer works through the usual rules for an immutable class and shows why a mutable field needs additional protection.
Watch the four rules for the class and its fields. In the getter leak, notice that having getters rather than setters does not stop a caller from changing a returned Date. Then watch the copying fix. We’ll make both the input and output boundaries explicit in our implementation.
The image makes the ownership distinction visible. Its getter returns a separate, modifiable list; our implementation will instead return an unmodifiable list that is safe to share.

Build a value object with a stable meaning
For this example, two snapshots with the same order ID and the same product IDs, in the same order, represent the same value. That definition calls for equals() and hashCode() using both fields, as in the previous lesson. It also makes immutability important: those fields must not change after an object becomes a map key.
import java.util.List;
import java.util.Objects;
public final class OrderSnapshot {
private final String orderId;
private final List<String> productIds;
public OrderSnapshot(String orderId, List<String> productIds) {
this.orderId = Objects.requireNonNull(orderId, "orderId");
this.productIds = List.copyOf(
Objects.requireNonNull(productIds, "productIds"));
}
public String orderId() {
return orderId;
}
public List<String> productIds() {
return productIds;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof OrderSnapshot)) return false;
OrderSnapshot that = (OrderSnapshot) other;
return orderId.equals(that.orderId)
&& productIds.equals(that.productIds);
}
@Override
public int hashCode() {
return Objects.hash(orderId, productIds);
}
}
There are three distinct protections here. The class is final, so a subclass cannot add mutable state or undermine its behavior. Its fields are private final, and there are no setters. Most importantly, List.copyOf(productIds) captures the supplied list’s contents in an unmodifiable list; the object does not retain the caller’s mutable ArrayList.
Why can productIds() return the field directly? The stored list cannot be structurally changed, and its elements are String objects, which are immutable. A caller can hold that list reference, but cannot use it to change this snapshot’s value. A fresh getter copy would also work, but would add an allocation without providing additional protection in this design.
List.copyOf rejects null elements. That is a deliberate part of this example’s contract: a snapshot cannot contain a null product ID. Also note the difference between a copy and a wrapper. Collections.unmodifiableList(input) prevents changes through the wrapper, but if input is subsequently modified, the wrapper reflects those changes. It is not a substitute for taking ownership of the contents.
Read the official Java tutorial’s record example as a comparison. A record supplies fields and accessors, but a mutable component still needs a defensive-copy decision; record syntax does not make its contents deeply immutable.
Using Records to Model Immutable Data - Dev.java
Read this Dev.java tutorial excerpt to distinguish generated record behavior from protection of mutable components. Compare its list-of-cities example with the List.copyOf call in OrderSnapshot.
In “Using the Compact Constructor,” read the constructor explanation; focus on how replacing a parameter affects the stored component. In “Using the Canonical Constructor,” read from “In this case the constructor you write needs to assign values” through the copying guidance. Under “Defining any Constructor,” find the State example and read the list discussion. Finish in “Getting the State of a Record”: read the accessor discussion, noting when the generated accessor would need to be replaced.
Whether you choose a class or a record, the design question is the same: Can any object reachable through this value object still be changed by somebody else?
Decide how far a copy must go
List.copyOf copies the list structure, not every object in the list. It is sufficient for List<String> because changing a list element is impossible through a String reference. It would not make List<Date> deeply immutable: another reference to one of those Date elements could still change the date.
The same issue occurs without a collection. If a value object must expose a legacy mutable Date, it needs a copy at each boundary:
import java.util.Date;
import java.util.Objects;
public final class LegacyDeadline {
private final Date dueAt;
public LegacyDeadline(Date dueAt) {
Date input = Objects.requireNonNull(dueAt, "dueAt");
this.dueAt = new Date(input.getTime());
}
public Date dueAt() {
return new Date(dueAt.getTime());
}
}
Changing the original Date cannot change dueAt; changing a Date returned by dueAt() cannot change it either. By contrast, an immutable date/time type would remove the need for these copies.
The secure-coding guidance below draws the useful general boundary: a shallow copy suffices when contained objects are immutable; mutable contained objects require protection too.
OBJ05-J. Do not return references to private mutable class members
Read this SEI CERT Java guidance to sharpen the distinction between copying a container and copying mutable objects inside it.
Under “OBJ05-J. Do not return references to private mutable class members,” read the opening rationale. In “Compliant Solution (Deep Copy),” begin at the array explanation and continue through the code ending with return dates;; notice that it copies each Date, not just the array. Then, in “Compliant Solution (Shallow Copy),” read the decision rule.
A reliable design alternative for a list of domain objects is to make each element an immutable value object as well. Otherwise, account for every mutable element both when accepting it and when exposing it. “The outer list is unmodifiable” is not enough to establish that its contents are stable.
Verify both boundaries
Spend a few minutes running a regression check rather than relying on inspection alone. The test deliberately changes the original list after construction and then attempts to change the returned list:
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class OrderSnapshotTest {
@Test
void callerCannotChangeSnapshotThroughInputOrAccessor() {
List<String> input = new ArrayList<>(List.of("sku-1"));
OrderSnapshot snapshot = new OrderSnapshot("order-7", input);
int originalHash = snapshot.hashCode();
input.add("sku-2");
assertEquals(List.of("sku-1"), snapshot.productIds());
assertThrows(UnsupportedOperationException.class,
() -> snapshot.productIds().add("sku-3"));
assertEquals(List.of("sku-1"), snapshot.productIds());
assertEquals(originalHash, snapshot.hashCode());
}
@Test
void separatelyConstructedSnapshotsHaveValueEquality() {
OrderSnapshot first =
new OrderSnapshot("order-7", List.of("sku-1"));
OrderSnapshot sameValue =
new OrderSnapshot("order-7", List.of("sku-1"));
assertEquals(first, sameValue);
assertEquals(first.hashCode(), sameValue.hashCode());
}
}
The unchanged hash is not proof of immutability on its own: hashes can collide. Here it is an additional check alongside an assertion about the actual contents. The separately constructed instance checks that equality describes a value, not object identity.
For an interview explanation, state the invariant before discussing syntax: “After construction, no caller operation can change this object’s equality-defining state.” Then inspect the constructor, every accessor, and any mutable objects nested inside a collection. That sequence catches more bugs than simply reciting “make the fields final.”
Key takeaways
An immutable Java value object needs stable state, not just final references. Copy mutable inputs so callers cannot change the object through retained arguments; avoid exposing mutable internals through accessors. List.copyOf is a concise choice for a list of immutable elements, while mutable elements require their own copying or an immutable representation. Once the state is stable, value-based equals() and hashCode() remain reliable when the object is used in a collection.
Next, you’ll design a type-safe Java API using generic variance and the PECS rule.