Designing immutable objects
How to design immutable Java objects with final classes, private final fields, defensive copies, records, builders and safe publication, plus the production leaks interviewers look for.
On this page
🧊 What is an immutable object?
An immutable object is an object whose visible state cannot change after construction. A String is the everyday example: calling toUpperCase() returns another String; it never edits the original characters. In your own code, a Money or OrderSnapshot class is immutable only if callers cannot mutate it through setters, constructor arguments, getters, arrays or nested collections.
Immutable objects are a favourite interview topic because the beginner answer is short, but the senior answer touches design, collections, records and the Java Memory Model. Many candidates say "make fields final" and stop there. That is not enough if a Date, array or List reference leaks.
What you’ll learn
- Build a truly immutable class with a final class,
private finalfields, no setters and defensive copies - Explain why
finalfields help with safe publication under the Java Memory Model - Choose between
List.copyOf,Collections.unmodifiableList, arrays,Date, records, builders and withers - Spot production leaks where mutable inputs or getters let callers change internal state
- Explain why immutability improves thread safety without making every referenced object deep-immutable
For this lesson, we'll focus on the practical recipe: final class, private final fields, no setters, defensive copies on the way in and out, records as shallow immutable carriers, collection wrappers, safe publication and builder or wither patterns.
Below the line (out of scope)
- Persistent data structures and structural sharing are outside this lesson.
- We will use Java 21 as the baseline, with no preview features.
- We will link immutability to thread safety, but not replace the full concurrency module.
The 30-second answer
I make a class immutable by making the class final, making fields private final, validating and copying mutable constructor inputs, exposing no setters, and returning defensive copies or immutable views from getters. final fields also get special safe-publication semantics: after the constructor finishes correctly, other threads that obtain the reference are guaranteed to see the values written to those final fields. Records help because their fields are final and accessors are fixed, but records are only shallowly immutable, so a record component like List<Item> still needs List.copyOf. List.copyOf creates an unmodifiable snapshot; Collections.unmodifiableList is a read-only view over a backing list that can still change. In production, most immutability bugs are leaks: a mutable Date, array or list goes in or comes out, and someone edits the object behind your back.
Interview radar
Asked very often: How do you create an immutable class, and why are defensive copies needed for mutable fields? Senior rounds probe: What does safe publication through final fields guarantee, and why is a record not automatically deeply immutable? Production angle: Shared configuration, cache keys and request snapshots fail when a leaked array or list changes after being put in a map.
01Picture it first: a museum display case
Think of an immutable object as a museum display case. The artifact can be seen from outside, but visitors never get a key to the case. If the curator accepts a borrowed artifact in a cardboard box and leaves the box with the lender, the lender can swap it later. Defensive copies are the curator making their own sealed copy before the display opens.
The figure below shows the whole contract: build once, seal the fields, then only share values that cannot mutate the inside.
Immutable objects are built once from copied inputs, hold private final state and expose only safe outputs instead of leaking the original mutable pieces
That boundary is the thing interviewers listen for. private final protects the reference from being reassigned inside the class. It does not freeze the object that reference points to.
02Building an immutable class under the hood
Try it yourself first
Before reading on, find the two mutations that can still change this supposedly immutable object.
import java.util.*;
final class Invoice {
private final String id;
private final Date issuedAt;
private final List<String> lineItems;
Invoice(String id, Date issuedAt, List<String> lineItems) {
this.id = Objects.requireNonNull(id);
this.issuedAt = Objects.requireNonNull(issuedAt);
this.lineItems = Objects.requireNonNull(lineItems);
}
Date issuedAt() { return issuedAt; }
List<String> lineItems() { return lineItems; }
}
The Date and List references are final, but the objects are mutable. The caller can keep the original list and add a row after construction. The getter also returns the same Date, so another caller can call setTime and rewrite your invoice timestamp.
Here's how I'd write it in Java 21.
import java.time.Instant;
import java.util.*;
final class Invoice {
private final String id;
private final Instant issuedAt;
private final List<String> lineItems;
Invoice(String id, Instant issuedAt, List<String> lineItems) {
this.id = Objects.requireNonNull(id);
this.issuedAt = Objects.requireNonNull(issuedAt);
this.lineItems = List.copyOf(lineItems);
}
String id() { return id; }
Instant issuedAt() { return issuedAt; }
List<String> lineItems() { return lineItems; }
}
Notice four things:
- The class is
final. A subclass cannot add mutable state and still pretend to follow the same contract. - Fields are
private final. No caller can see the fields directly, and the constructor assigns each one once. Instantis immutable. Prefer immutable JDK types over legacy mutable types likeDate.List.copyOfis a snapshot. Later changes to the input list do not affect the invoice.
The defensive-copy decision tree below is the one I use in code review.
Decision tree for immutable fields: immutable values can be stored directly, mutable inputs need a copy, collections need List.copyOf and arrays need clone or a list conversion
List.copyOf versus Collections.unmodifiableList
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- Real world: the cache key that moved after insertion
- Advanced: final fields, records, builders and withers
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal