Immutable Classes
Build classes whose state cannot change after construction, with defensive copies and with-methods, and know why they make concurrent services simpler.
final class Name {
private final Type field;
Name(Type field) { this.field = copyIfMutable(field); }
Name withField(Type v) { return new Name(v); }
}An immutable object never changes after its constructor finishes. String, Integer, BigDecimal, LocalDate and UUID all work this way. Any operation that looks like a change returns a new object instead.
Why bother
In a server, many threads handle requests at once. An object that cannot change can be shared between all of them with no locks and no surprises. It is safe as a HashMap key, safe to cache, and safe to hand to code you do not control. When something is wrong with it, it was wrong from the moment it was built, which narrows the search for the bug to one constructor.
The recipe
- Make the class
finalso a subclass cannot add mutable state or override methods. - Make every field
private finaland set it in the constructor. - Provide no setters. For changes, add methods that return a new object, often named
withX. - Copy mutable inputs on the way in and never return mutable internals on the way out.
The last point is where most attempts fail. final only stops the field from pointing at a different object; it says nothing about the object it points at. A final List<String> field holding an ArrayList can still be added to by anyone with a reference, including the caller who passed it in. List.copyOf(list) makes an unmodifiable copy, and skips the copy when the list already came from List.of or List.copyOf. A Collections.unmodifiableList view is still copied.
private final List<String> tags;
Article(List<String> tags) {
this.tags = List.copyOf(tags); // also rejects null elements
}The classic mistake
Because operations return new objects, ignoring the result does nothing. date.plusDays(1); on its own line, total.add(tax); or name.trim(); compile without complaint and change nothing. Assign the result: total = total.add(tax);.
Records and cost
A record is a concise way to write an immutable data class: final, with final fields and generated equals, hashCode and toString. It still needs a compact constructor with List.copyOf if a component is a collection. Creating new objects for every change costs allocations, but short-lived objects are cheap for the JVM; reach for mutable objects only when a profiler shows a real hot spot.
Example
When to use it
- Share a parsed configuration object between all request threads without any locking.
- Use an immutable value object as a cache key so entries are never stranded after a change.
- Keep audit snapshots of an order that later changes cannot rewrite.
More examples
final does not mean immutable
The field is final, yet the team changed twice after construction, once through the caller's own reference. Storing List.copyOf(members) and keeping the field private closes both doors.
import java.util.ArrayList;
import java.util.List;
final class Team {
final List<String> members;
Team(List<String> members) {
this.members = members; // no copy
}
}
void main() {
List<String> names = new ArrayList<>(List.of("ada"));
Team team = new Team(names);
names.add("lin"); // the caller still holds the list
team.members.add("sam"); // final field, mutable contents
IO.println(team.members);
}
// Output:
// [ada, lin, sam]Ignoring the returned object
Methods on immutable types return a new value and leave the original alone. A line that calls add or plusDays without using the result is a bug that compiles cleanly.
import java.math.BigDecimal;
import java.time.LocalDate;
void main() {
BigDecimal total = new BigDecimal("100.00");
total.add(new BigDecimal("20.00")); // result thrown away
IO.println("ignored: " + total);
total = total.add(new BigDecimal("20.00")); // keep the new object
IO.println("assigned: " + total);
LocalDate due = LocalDate.of(2026, 1, 31);
due.plusDays(1);
IO.println("ignored: " + due);
IO.println("used: " + due.plusDays(1));
}
// Output:
// ignored: 100.00
// assigned: 120.00
// ignored: 2026-01-31
// used: 2026-02-01An immutable record with a list component
The compact constructor copies the list so the record is immutable all the way down. withSku builds a new snapshot, so the earlier one can still be shared safely, for example in a cache or an audit log.
import java.util.ArrayList;
import java.util.List;
record OrderSnapshot(String id, List<String> skus) {
OrderSnapshot {
skus = List.copyOf(skus);
}
OrderSnapshot withSku(String sku) {
List<String> next = new ArrayList<>(skus);
next.add(sku);
return new OrderSnapshot(id, next);
}
}
void main() {
OrderSnapshot first = new OrderSnapshot("ord-3", List.of("mug"));
OrderSnapshot second = first.withSku("pen");
IO.println(first);
IO.println(second);
IO.println(first.equals(new OrderSnapshot("ord-3", List.of("mug"))));
}
// Output:
// OrderSnapshot[id=ord-3, skus=[mug]]
// OrderSnapshot[id=ord-3, skus=[mug, pen]]
// true
Discussion