Immutability & Defensive Copying
Reviewed & published by Brayan K
An immutable object is one whose state can never change after it is constructed, and defensive copying is how you stop mutable inputs and outputs from quietly breaking that guarantee.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should be comfortable with classes, the final keyword and access modifiers, and the Collections. Knowing records helps — they are immutable carriers by design.
What You'll Learn
- The recipe for an immutable class (final, no setters)
- Why mutable inputs/outputs leak state
- Defensive copying on the way in and out
- List.copyOf vs Collections.unmodifiableList
- Records as immutable carriers (and their shallow trap)
- Why immutability gives you free thread-safety
The Big Idea — A Signed Contract
💡 Analogy: An immutable object is like a signed, witnessed contract. Once it's signed you can't scribble new terms onto it — if you want different terms you draft a whole new contract. That's why everyone can be handed a copy without worrying someone will alter it behind their back. Defensive copying is the lawyer photocopying any attachments you bring, so that even if you later edit your attachment, the signed version stays exactly as agreed.
Without that photocopy step, your "immutable" object is only as safe as the mutable list someone handed you — and they can keep editing it from outside.
1️⃣ The Recipe for an Immutable Class
For a class with only primitive or already-immutable fields, immutability is easy: make the class final, make every field private final, set them in the constructor, and provide no setters. Any "change" returns a brand-new object — exactly how String and BigDecimal work.
public class Main {
// A simple immutable class: final fields, no setters.
static final class Temperature {
private final double celsius;
Temperature(double celsius) { this.celsius = celsius; }
public double celsius() { return celsius; }
public double fahrenheit() { return celsius * 9 / 5 + 32; }
// "Mutation" returns a NEW object instead of changing this one.
public Temperature plus(double delta) {
return new Temperature(celsius + delta);
}
}
public static void main(String[] args) {
Temperature t = new Temperature(20.0);
Temperature warmer = t.plus(5.0);
System.out.println("original : " + t.celsius() + "C"); // unchanged
System.out.println("warmer : " + warmer.celsius() + "C");
System.out.println("in F : " + warmer.fahrenheit() + "F");
}
}2️⃣ The Leak — Storing a Mutable Reference
The trouble starts when a field is mutable, like a List. If the constructor just stores the caller's list and the getter hands it straight back, your object isn't immutable at all — outside code can mutate it through the reference it still holds or through the getter.
import java.util.*;
public class Main {
// WITHOUT defensive copies — the "immutable" class leaks its insides.
static final class TeamLeaky {
private final List<String> members;
TeamLeaky(List<String> members) { this.members = members; } // stores the SAME list
public List<String> getMembers() { return members; } // hands it back
}
public static void main(String[] args) {
List<String> input = new ArrayList<>(List.of("Ana", "Ben"));
TeamLeaky team = new TeamLeaky(input);
input.add("Mallory"); // mutate the original list...
team.getMembers().add("Eve"); // ...or mutate via the getter
System.out.println("Leaky members: " + team.getMembers()); // both leaked in!
}
}3️⃣ Plugging the Leak — Defensive Copies
Copy on the way in and on the way out. List.copyOf(members) makes an independent, unmodifiable snapshot in the constructor, so later edits to the caller's list don't touch you. Because that copy is already read-only, the getter can return it directly — callers can read but never mutate.
import java.util.*;
public class Main {
// WITH defensive copies — truly immutable, even against mutable inputs.
static final class Team {
private final List<String> members;
Team(List<String> members) {
// copy on the way IN so later edits to the caller's list don't leak
this.members = List.copyOf(members); // unmodifiable copy
}
// copy on the way OUT (here, already unmodifiable, so just return it)
public List<String> getMembers() {
return members; // List.copyOf result is read-only
}
}
public static void main(String[] args) {
List<String> input = new ArrayList<>(List.of("Ana", "Ben"));
Team team = new Team(input);
input.add("Mallory"); // edit the caller's list...
System.out.println("after input edit : " + team.getMembers()); // unaffected
try {
team.getMembers().add("Eve"); // try to mutate via getter
} catch (UnsupportedOperationException e) {
System.out.println("getter add blocked: list is unmodifiable");
}
System.out.println("final members : " + team.getMembers());
}
}4️⃣ Records — and Their Shallow Trap
A record is the natural home for immutable data — final fields, no setters, free equals/hashCode. But it is only shallowly immutable: a mutable component like a List can still be changed unless you defensively copy it in the compact constructor.
🧠 Quick Recall
Predict before you peek.
Answer: no. final stops you reassigning the field to a different list, but you can still add/remove from the list it points to. Use List.copyOf for content immutability.
Answer: List.copyOf takes a true snapshot, so it is unaffected. Collections.unmodifiableList is just a view — if the underlying list changes, the view shows the change.
Answer: because no thread can change their state, there are no races to guard against — you can share one instance across threads with no locks at all.
🎯 YOUR TURN — Immutable Money
Give Money no setters and a plus that returns a new instance, leaving both operands unchanged.
public class Main {
static final class Money {
private final String currency;
private final long cents;
// YOUR TURN: make Money immutable. Give it final fields (done),
// NO setters, and a plus(Money other) method that returns a NEW Money
// with the summed cents — never mutating either operand. Add a
// toString that prints e.g. "USD 7.5".
//
// Expected output:
// a : USD 5.0
// b : USD 2.5
// sum : USD 7.5
Money(String currency, long cents) { this.currency = currency; this.cents = cents; }
// add plus(...) and toString() here
}
public static void main(String[] args) {
Money a = new Money("USD", 500);
Money b = new Money("USD", 250);
Money sum = a.plus(b);
System.out.println("a : " + a);
System.out.println("b : " + b);
System.out.println("sum : " + sum);
}
}🧩 MINI-CHALLENGE — Defensive Route
Build an immutable Route whose stops are defensively copied so neither the input list nor the accessor can alter it.
import java.util.*;
public class Main {
// MINI-CHALLENGE: build an immutable Route with a name and a list of stops.
// Defensive-copy the stops with List.copyOf in the constructor so that
// editing the caller's input list does NOT change the route, and so that
// route.stops() cannot be mutated. Add a length() returning stops.size().
//
// Expected output:
// name : Line 1
// stops : [A, B, C]
// length : 3
// add blocked: unmodifiable
public static void main(String[] args) {
List<String> input = new ArrayList<>(List.of("A", "B", "C"));
// Route r = new Route("Line 1", input);
// input.add("X"); // must NOT affect r
// print name, stops, length; then try r.stops().add("Y") in a try/catch
}
}Common Errors
- ❌ Thinking final alone makes a class immutable. A final reference to a mutable list is still mutable. Fix: copy the contents with List.copyOf.
- ❌ Storing the caller's mutable argument directly. They keep a handle to your insides. Fix: defensively copy in the constructor.
- ❌ Returning the live collection from a getter. Callers can mutate it. Fix: return a copy or an unmodifiable view.
- ❌ Assuming a record is deeply immutable. Mutable components leak. Fix: copy them in the compact constructor.
- ❌ Adding a setter "just in case". One setter destroys immutability. Fix: return a new instance from a withX-style method instead.
- ❌ Confusing unmodifiableList with a copy. It's a live view. Fix: use List.copyOf when you need a true snapshot.
Pro Tips
- 💡 Default to immutable. Make things immutable unless you have a concrete reason not to — it removes whole classes of bugs.
- 💡 Use records for data carriers and copy mutable components in the compact constructor.
- 💡 Provide withX methods (e.g. withName(...)) that return a modified copy, mirroring how the JDK's immutable types work.
- 💡 Immutable keys are safe keys. Since their hashCode never changes, they never get lost in a HashMap.
📋 Quick Reference
| Concept | Syntax | Note |
|---|---|---|
| final class | final class T | Blocks subclassing |
| final field | private final int x; | Assign once |
| no setters | (omit setX) | State fixed at build |
| copy in | List.copyOf(arg) | Snapshot of input |
| copy out | return List.copyOf(field) | Or unmodifiable view |
| unmodifiable view | Collections.unmodifiableList(l) | Wraps, doesn't copy |
| array copy | arr.clone() | For mutable arrays |
| "change" | return new T(...) | New object, not mutation |
| record | record T(...) | Shallowly immutable |
| record copy | compact constructor | Copy mutable components |
| JDK examples | String, BigDecimal, LocalDate | Immutable by design |
📚 Resources / Keep Learning
- 📘 List.copyOf — Java 21 API documentation (docs.oracle.com)
- 📘 String (an immutable class) — Java 21 API (docs.oracle.com)
- 📗 Records — dev.java tutorial
🎉 Lesson Complete!
You can now build a properly immutable class — final, no setters, "change" via new instances — spot the leak when a mutable field is stored or returned directly, plug it with defensive copies and List.copyOf, and harden a record's mutable components in its compact constructor.
Practice quiz
What makes an object immutable?
- It has setters
- It uses var
- Its state cannot change after construction
- It is static
Answer: Its state cannot change after construction. An immutable object's observable state is fixed once the constructor finishes — there is no way to alter it afterwards.
Which keyword prevents reassigning a field after construction?
- final
- static
- const
- sealed
Answer: final. A final field can be assigned once (in the field initializer or constructor) and never reassigned.
Why take a defensive copy of a mutable constructor argument?
- For speed
- To save memory
- To enable setters
- So later edits to the caller's object don't change yours
Answer: So later edits to the caller's object don't change yours. If you store the same reference, the caller can mutate the shared object later and corrupt your 'immutable' state.
What does List.copyOf(list) return?
- The same list
- An unmodifiable copy
- A mutable copy
- null
Answer: An unmodifiable copy. List.copyOf returns an unmodifiable List containing the elements — attempts to mutate it throw UnsupportedOperationException.
Returning your internal mutable list directly from a getter...
- Leaks your state so callers can mutate it
- Is fine
- Improves performance only
- Is required
Answer: Leaks your state so callers can mutate it. Handing back the live collection lets callers add/remove elements, breaking immutability. Return a copy or an unmodifiable view.
Is a record automatically deeply immutable?
- Yes, always
- Only with final
- No — it's shallowly immutable; mutable components can still be changed
- Never immutable
Answer: No — it's shallowly immutable; mutable components can still be changed. A record's references are final, but if a component is a mutable list the contents can still change unless you defensively copy.
Where do you defensively copy a record's mutable component?
- In a setter
- In the compact constructor
- In toString
- You can't
Answer: In the compact constructor. A record's compact constructor is the place to validate and copy: 'Playlist { songs = List.copyOf(songs); }'.
A key benefit of immutability is...
- More setters
- Faster I/O
- Smaller class files
- Thread-safety with no locks
Answer: Thread-safety with no locks. Immutable objects can be shared freely across threads because no thread can change them — no synchronization needed.
How do you 'change' an immutable object?
- Mutate a field
- Return a new object with the change applied
- Use reflection
- You cannot do anything
Answer: Return a new object with the change applied. Immutable APIs return a new instance (like String.toUpperCase or BigDecimal.add) instead of mutating in place.
Which is a well-known immutable class in the JDK?
- ArrayList
- StringBuilder
- String
- HashMap
Answer: String. String is immutable; methods like substring and replace return new strings. BigInteger, BigDecimal and LocalDate are immutable too.
Continue this course
- Previous: The HttpClient API
- Next: The Builder Pattern — Construct complex objects fluently and avoid telescoping constructors
- Quick reference: Java cheat sheet › Collections
Frequently asked questions
What is an immutable object and why is it useful?
An immutable object is one whose observable state cannot change after it is constructed — String, Integer, BigDecimal and LocalDate are all immutable. The payoffs are large: immutable objects are automatically thread-safe (no two threads can race to change them), they make safe map and set keys (their hashCode never shifts), they are easy to reason about and cache, and they can be shared freely without defensive copying at every call site. The cost is creating a new object for each 'change', which is usually negligible.
How do I make a class immutable?
Follow a short recipe: make the class final (so nobody subclasses it to add mutable state), make every field private and final, set all fields in the constructor with no setters, defensively copy any mutable arguments coming in, defensively copy or return unmodifiable views of any mutable fields going out, and provide 'change' methods that return a new instance rather than mutating this one. Records do most of this automatically — they only need defensive copies for mutable components.
What is defensive copying and when do I need it?
Defensive copying means making your own private copy of a mutable object instead of sharing the caller's reference. You need it whenever an immutable class accepts or returns a mutable type such as a List, array, Date, or a mutable custom object. Copy on the way in (in the constructor) so the caller can't mutate your state through the reference they still hold, and copy or wrap on the way out (in getters) so callers can't mutate your internal collection. List.copyOf, Map.copyOf, and array.clone() are common tools.
Are records automatically immutable?
Records are shallowly immutable: their component fields are final and there are no setters, so the references can't be reassigned. But that does not deep-freeze a mutable component — if a record holds a List, callers who passed it in (or who get it back) can still mutate that list's contents. To make a record truly immutable, defensively copy mutable components in the compact constructor, e.g. 'Playlist { songs = List.copyOf(songs); }', which also makes the accessor return an unmodifiable list.
What is the difference between List.copyOf and Collections.unmodifiableList?
List.copyOf(src) creates an independent, unmodifiable snapshot — later changes to src do not affect it. Collections.unmodifiableList(src) returns an unmodifiable VIEW that still wraps src, so if src is mutated elsewhere the view reflects those changes (you only blocked mutation through the view itself). For real immutability prefer List.copyOf (a true copy). Use the unmodifiable view when you deliberately want a read-only window onto a list you control.