Skip to content

How to Create an Immutable Class in Java (Java 21)

Build an immutable Recipe class in Java 21: final class, final fields, defensive copies and with-ers, plus the leaks a final field alone lets through.

how-to-tutorials core-java java-tutorials
Gopi Gorantala
Reading Progress

On This Page

Here's where we're heading

Two objects, same treatment, very different results. This is the output of the finished recipe box:

--- BrokenRecipe ---
before: Pancakes [flour, milk] last cooked 1000
after:  Pancakes [flour, milk, sawdust] last cooked 0
--- Recipe ---
before: Recipe[Pancakes, serves 2, 2 ingredients] last cooked 1000
add() on returned list: UnsupportedOperationException
after:  Recipe[Pancakes, serves 2, 2 ingredients] last cooked 1000
original serves 2, copy serves 6
equal after withServings(2)? true

BrokenRecipe has final on every field and no setters, and somebody still got sawdust into the pancakes. Recipe took the same abuse and didn't change. If you want to know how to create an immutable class in Java that holds up against a caller who's trying to break it, the difference between those two classes is the whole article.

You should know classes, constructors and List. No build tool is needed: a JDK and a terminal will do. An immutable object is one whose state can't change after the constructor returns. That buys you thread safety without locks, safe use as a map key, and objects you can pass around without wondering who'll modify them.

I didn't time the build this run. The part that cost me thought was the legacy java.util.Date field, which I kept on purpose because real codebases still have them, and a List.copyOf null surprise I'll show you in the troubleshooting section.

Versions I used

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
Compiler javac 21.0.12.1 javac -version
Build tool none, plain javac n/a

Everything here compiles on Java 16 or later, because Ingredient is a record and Recipe.equals uses instanceof pattern matching (both final in 16). Drop the record and the pattern and it runs on Java 11, since List.copyOf arrived in 10 and isBlank in 11. On Java 8, Collections.unmodifiableList(new ArrayList<>(...)) replaces List.copyOf.

I couldn't download JUnit in my environment this run, so the tests below are a plain Java class with assertions I ran. I'm not printing a JUnit version because I didn't verify one.

Set up the recipe box project

I used the command line as the primary path again. It shows exactly which files exist, and there's nothing else to configure.

mkdir -p recipe-box/src/main/java/com/example/recipes
mkdir -p recipe-box/src/test/java/com/example/recipes
cd recipe-box

In IntelliJ IDEA

Choose File → New → Project, pick Java, set the JDK to 21 and keep the build system as IntelliJ. Create the package com.example.recipes under src. I didn't click through this myself. The menu path is from the vendor documentation, not from a run.

In Eclipse

Choose File → New → Java Project, set the execution environment to JavaSE-21, then right-click src and pick New → Package. Same caveat: not executed by me.

The tree you're aiming for:

recipe-box/
└── src/
    ├── main/java/com/example/recipes/
    │   ├── App.java
    │   ├── BrokenRecipe.java
    │   ├── Ingredient.java
    │   └── Recipe.java
    └── test/java/com/example/recipes/
        └── RecipeChecks.java

How to create an immutable class in Java: six rules, four steps

The rules are short. A class is immutable when:

  1. The class is final, so nobody can subclass it and add mutability.
  2. Every field is private and final.
  3. There are no setters, and nothing else changes state after construction.
  4. Mutable inputs are copied on the way in.
  5. Mutable outputs are copied (or already unmodifiable) on the way out.
  6. Validation happens in the constructor, before the object exists.

Most tutorials stop after rule 3. Rules 4 and 5 are where real classes leak, so we'll start with a class that follows 2 and 3 and fails the rest.

Step 1: Write the class that looks immutable

Goal: a baseline you can break.

src/main/java/com/example/recipes/BrokenRecipe.java

package com.example.recipes;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

// Looks immutable. Isn't. Every field is final and there are no setters.
public class BrokenRecipe {

    private final String name;
    private final List<String> ingredients;
    private final Date lastCooked;

    public BrokenRecipe(String name, List<String> ingredients, Date lastCooked) {
        this.name = name;
        this.ingredients = ingredients;   // keeps the caller's list
        this.lastCooked = lastCooked;     // keeps the caller's Date
    }

    public String getName() { return name; }
    public List<String> getIngredients() { return ingredients; }   // hands out the real list
    public Date getLastCooked() { return lastCooked; }             // hands out the real Date

    @Override
    public String toString() {
        return name + " " + ingredients + " last cooked " + lastCooked.getTime();
    }

    static BrokenRecipe sample() {
        return new BrokenRecipe("Pancakes", new ArrayList<>(List.of("flour", "milk")), new Date(1000L));
    }
}

Look at the constructor and the two getters. The constructor stores the caller's List and the caller's Date. The getters hand out those same objects. final stops you reassigning the field. It says nothing about what the object behind the field does.

Checkpoint: javac -d out src/main/java/com/example/recipes/BrokenRecipe.java. No output means it compiled.

Step 2: Write Ingredient as a record

Goal: an element type that's immutable without any extra work.

src/main/java/com/example/recipes/Ingredient.java

package com.example.recipes;

// A record is already immutable, and a String and a double are too,
// so this one needs no defensive copying.
public record Ingredient(String name, double quantity, String unit) {

    public Ingredient {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("ingredient name must not be blank");
        }
        if (unit == null || unit.isBlank()) {
            throw new IllegalArgumentException("unit must not be blank");
        }
        if (!(quantity > 0)) {   // also rejects NaN
            throw new IllegalArgumentException("quantity must be positive, got " + quantity);
        }
    }
}

A record gives you private final fields, accessors, equals, hashCode and toString. Its three components are two Strings and a double, all immutable. The compact constructor validates. The !(quantity > 0) form is deliberate: quantity <= 0 lets NaN through, because every comparison with NaN is false. I found that out when my own check for it failed on the first draft. If records are new to you, how to use Java records covers them from scratch.

A caution about element types, because it's the next trap: if Ingredient were an ordinary class with a public name field, the immutable Recipe we're about to write would still be mutable through its elements. An immutable container is only as good as what's inside it.

Step 3: Write the immutable Recipe

Goal: the fixed version, with each rule marked in the code.

src/main/java/com/example/recipes/Recipe.java

package com.example.recipes;

import java.util.Date;
import java.util.List;
import java.util.Objects;

public final class Recipe {                       // 1. final: nobody can subclass and add mutability

    private final String name;                    // 2. every field private and final
    private final int servings;
    private final List<Ingredient> ingredients;
    private final Date lastCooked;                // legacy mutable type, kept on purpose; may be null

    public Recipe(String name, int servings, List<Ingredient> ingredients, Date lastCooked) {
        // 3. validate first, so a bad object never exists
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name must not be blank");
        }
        if (servings < 1) {
            throw new IllegalArgumentException("servings must be at least 1, got " + servings);
        }
        Objects.requireNonNull(ingredients, "ingredients");
        this.name = name;
        this.servings = servings;
        this.ingredients = List.copyOf(ingredients);                       // 4. copy mutable input (rejects null elements)
        this.lastCooked = lastCooked == null ? null : new Date(lastCooked.getTime());
    }

    public String name() { return name; }
    public int servings() { return servings; }
    public List<Ingredient> ingredients() { return ingredients; }          // already unmodifiable, safe to return
    public Date lastCooked() {                                             // 5. copy mutable output
        return lastCooked == null ? null : new Date(lastCooked.getTime());
    }

    // 6. "change" means "make a new one"
    public Recipe withServings(int newServings) {
        return new Recipe(name, newServings, ingredients, lastCooked);
    }

    public Recipe withIngredient(Ingredient extra) {
        var all = new java.util.ArrayList<>(ingredients);
        all.add(extra);
        return new Recipe(name, servings, all, lastCooked);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Recipe other)) return false;
        return servings == other.servings
                && name.equals(other.name)
                && ingredients.equals(other.ingredients)
                && Objects.equals(lastCooked, other.lastCooked);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, servings, ingredients, lastCooked);
    }

    @Override
    public String toString() {
        return "Recipe[" + name + ", serves " + servings + ", " + ingredients.size() + " ingredients]";
    }
}

The numbered comments match the six rules. Four lines carry most of the weight.

List.copyOf(ingredients) makes an unmodifiable snapshot at construction time. Later changes to the caller's list don't reach it, and since the snapshot is unmodifiable, ingredients() can return it directly without another copy.

new Date(lastCooked.getTime()) is the same idea for a type that has no unmodifiable form. Date has setTime, so you copy on the way in and again on the way out. If you control the code, use java.time.Instant instead and skip all this. It's immutable already. I kept Date because you'll meet it in older code and the copy-twice pattern applies to any mutable type.

The with methods are how you "change" something. withServings(6) builds a new Recipe, runs the same validation, and leaves the original alone. In withIngredient I copy into an ArrayList, add, and let the constructor take its own List.copyOf. It's one more copy than strictly needed, and I'd take that over a second code path that skips validation.

I wrote equals and hashCode by hand. They follow the usual contract, and how to implement equals() and hashCode() properly walks through it.

Checkpoint: compile all three main files.

javac -d out src/main/java/com/example/recipes/{BrokenRecipe,Ingredient,Recipe}.java

On Windows PowerShell, list the three paths with commas or spaces instead of braces. You should see no output.

Step 4: Write the demo that attacks both classes

Goal: abuse each class the same way and compare.

src/main/java/com/example/recipes/App.java

package com.example.recipes;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class App {

    public static void main(String[] args) {
        System.out.println("--- BrokenRecipe ---");
        var broken = BrokenRecipe.sample();
        System.out.println("before: " + broken);
        broken.getIngredients().add("sawdust");
        broken.getLastCooked().setTime(0L);
        System.out.println("after:  " + broken);

        System.out.println("--- Recipe ---");
        List<Ingredient> input = new ArrayList<>();
        input.add(new Ingredient("flour", 200, "g"));
        input.add(new Ingredient("milk", 300, "ml"));
        Date cooked = new Date(1000L);
        var recipe = new Recipe("Pancakes", 2, input, cooked);
        System.out.println("before: " + recipe + " last cooked " + recipe.lastCooked().getTime());

        input.add(new Ingredient("sawdust", 1, "pinch"));   // caller changes their own list
        cooked.setTime(0L);                                   // caller changes their own Date
        try {
            recipe.ingredients().add(new Ingredient("sawdust", 1, "pinch"));
        } catch (UnsupportedOperationException e) {
            System.out.println("add() on returned list: UnsupportedOperationException");
        }
        recipe.lastCooked().setTime(0L);                      // changes a copy only
        System.out.println("after:  " + recipe + " last cooked " + recipe.lastCooked().getTime());

        var bigger = recipe.withServings(6);
        System.out.println("original serves " + recipe.servings() + ", copy serves " + bigger.servings());
        System.out.println("equal after withServings(2)? " + recipe.equals(bigger.withServings(2)));
    }
}

Each attack in main is something a caller could do by accident. Adding to a list you were handed, resetting a date on a getter result, or changing the original list after passing it in. None of it is malicious, which is the point.

Take it for a spin

javac -d out $(find src/main -name '*.java')
java -cp out com.example.recipes.App

On Windows PowerShell, list the files explicitly instead of using find.

--- BrokenRecipe ---
before: Pancakes [flour, milk] last cooked 1000
after:  Pancakes [flour, milk, sawdust] last cooked 0
--- Recipe ---
before: Recipe[Pancakes, serves 2, 2 ingredients] last cooked 1000
add() on returned list: UnsupportedOperationException
after:  Recipe[Pancakes, serves 2, 2 ingredients] last cooked 1000
original serves 2, copy serves 6
equal after withServings(2)? true

Read the "after" lines. The broken recipe grew an ingredient and its date went back to 1970. The real one didn't move, even though I changed input and cooked after constructing it. The last line shows withServings made a real copy: serve it to six, serve it back to two, and you get an equal object.

If you see all eight lines, the hard part's done.

Check every rule with code

src/test/java/com/example/recipes/RecipeChecks.java

package com.example.recipes;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class RecipeChecks {

    static int run = 0;

    static void check(String name, boolean ok) {
        run++;
        if (!ok) throw new AssertionError("FAILED: " + name);
        System.out.println("ok   " + name);
    }

    static void throwsIae(String name, Runnable r) {
        try { r.run(); } catch (IllegalArgumentException e) { check(name + " -> " + e.getMessage(), true); return; }
        check(name, false);
    }

    static Recipe sample(List<Ingredient> items, Date d) {
        return new Recipe("Pancakes", 2, items, d);
    }

    public static void main(String[] args) {
        check("class is final", Modifier.isFinal(Recipe.class.getModifiers()));
        boolean allFinal = true;
        for (Field f : Recipe.class.getDeclaredFields()) {
            allFinal &= Modifier.isFinal(f.getModifiers()) && Modifier.isPrivate(f.getModifiers());
        }
        check("every field private final", allFinal);

        List<Ingredient> input = new ArrayList<>(List.of(new Ingredient("flour", 200, "g")));
        Date d = new Date(1000L);
        var r = sample(input, d);

        input.add(new Ingredient("milk", 300, "ml"));
        check("input list change does not leak in", r.ingredients().size() == 1);
        d.setTime(0L);
        check("input Date change does not leak in", r.lastCooked().getTime() == 1000L);
        r.lastCooked().setTime(0L);
        check("returned Date change does not leak out", r.lastCooked().getTime() == 1000L);
        try { r.ingredients().add(new Ingredient("x", 1, "g")); check("returned list unmodifiable", false); }
        catch (UnsupportedOperationException e) { check("returned list unmodifiable", true); }

        check("null lastCooked allowed", sample(List.of(), null).lastCooked() == null);
        throwsIae("blank name", () -> new Recipe(" ", 2, List.of(), null));
        throwsIae("zero servings", () -> new Recipe("x", 0, List.of(), null));
        throwsIae("NaN quantity", () -> new Ingredient("x", Double.NaN, "g"));
        try { new Recipe("x", 1, null, null); check("null list rejected", false); }
        catch (NullPointerException e) { check("null list rejected -> " + e.getMessage(), true); }

        var bigger = r.withServings(6);
        check("withServings returns a copy", bigger.servings() == 6 && r.servings() == 2);
        check("withIngredient adds to the copy only",
                r.withIngredient(new Ingredient("egg", 1, "pc")).ingredients().size() == 2 && r.ingredients().size() == 1);
        check("equal values are equal", r.equals(sample(List.of(new Ingredient("flour", 200, "g")), new Date(1000L)))
                && r.hashCode() == sample(List.of(new Ingredient("flour", 200, "g")), new Date(1000L)).hashCode());
        System.out.println(run + " checks passed");
    }
}

The first two checks use reflection to confirm rules 1 and 2 directly, so removing a final later fails the build instead of waiting for a code review to notice.

javac -d out $(find src -name '*.java')
java -ea -cp out com.example.recipes.RecipeChecks
ok   class is final
ok   every field private final
ok   input list change does not leak in
ok   input Date change does not leak in
ok   returned Date change does not leak out
ok   returned list unmodifiable
ok   null lastCooked allowed
ok   blank name -> name must not be blank
ok   zero servings -> servings must be at least 1, got 0
ok   NaN quantity -> quantity must be positive, got NaN
ok   null list rejected -> ingredients
ok   withServings returns a copy
ok   withIngredient adds to the copy only
ok   equal values are equal
14 checks passed

This harness is hand-rolled because I couldn't pull in JUnit. When you have a build tool, each check becomes an @Test with AssertJ. See how to write unit tests with JUnit 5.

Why a final field isn't enough

final on a field means the reference can't be re-pointed. That's all it means. The List behind it still has add, and the Date behind it still has setTime. So an object is immutable only if everything reachable from its fields is also immutable or hidden.

That's why the rules split into "copy in" and "copy out". The constructor is the only moment you can stop a caller keeping a live handle to your internals. If you store their list, they still own it. If a getter returns your field, they now own that too. Either way, "your" state is shared state.

Rule 1 matters for a less obvious reason. If Recipe weren't final, a subclass could override ingredients() to return a mutable list and every caller holding a Recipe would see a mutable one. I'll show that failure below.

The payoff for all this care is that an immutable object can be shared across threads without synchronisation, because there's no state to race on. For the memory-model reasoning behind that, see the Java volatile keyword and what immutability means in functional programming. One caveat from the Java memory model: this guarantee leans on all fields being final, which is another reason rule 2 has no exceptions.

Common mistakes, forced on purpose

I broke each of these deliberately so the messages are exact.

Leaving the class non-final. I removed final from Recipe and wrote a subclass whose ingredients() returns a live list. It compiled and printed:

1 ingredient(s) in a 'immutable' Recipe

With final restored, the same subclass fails at compile time:

a/com/example/recipes/Sneaky.java:3: error: cannot inherit from final Recipe
public class Sneaky extends Recipe {
                            ^
1 error

Assigning a final field later.

B.java:1: error: cannot assign a value to final variable n
public final class B { private final int n = 1; void bump() { n = 2; } }
                                                              ^
1 error

That's the compiler enforcing rule 2. You'll see it when you try to add a convenience setter.

Removing the defensive copy. I changed List.copyOf(ingredients) to ingredients and reran the checks. The very check built for it caught it:

ok   every field private final
Exception in thread "main" java.lang.AssertionError: FAILED: input list change does not leak in
	at com.example.recipes.RecipeChecks.check(RecipeChecks.java:15)
	at com.example.recipes.RecipeChecks.main(RecipeChecks.java:41)

Notice that the reflection check for final still passed. A final field with a leaking reference is exactly what rule 2 alone can't detect.

Mutable elements. List.copyOf copies the list, not what's in it. I put a plain object with a non-final field into a list, copied it, changed the original, and read the copy:

sawdust

That's why Ingredient is a record. Make elements immutable, or copy each one too.

A null inside the list. List.copyOf(Arrays.asList("a", null)) throws a bare NullPointerException with no message, from inside ImmutableCollections:

Exception in thread "main" java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:233)
	at java.base/java.util.ImmutableCollections$List12.<init>(ImmutableCollections.java:571)

That's List.copyOf refusing null elements, which is usually what you want. If your callers pass lists with nulls, check for them in the constructor and throw an IllegalArgumentException with a message. A bare NPE from library internals is unkind to whoever has to debug it. To read traces like this one, see reading Java stack traces.

Variations worth knowing

  • Use a record when it fits. If all you need is a value holder, record Recipe(String name, int servings, List<Ingredient> ingredients) with a compact constructor doing ingredients = List.copyOf(ingredients) gets you most of this in a fraction of the lines. Records are shallowly immutable, so the copy-in rule still applies. They also can't extend a class. For anything with a legacy mutable field or custom equality, the hand-written class above gives you full control.
  • Builders for wide objects. Past four or five fields, constructors get unreadable. How to implement the builder pattern in Java shows a builder that validates in build() and produces an immutable result.
  • Lombok. @Value marks the class final and fields private final, and generates the getters. It does not copy your mutable inputs for you, so rules 4 and 5 stay your job. A dedicated walkthrough is planned: how to build immutable objects with Lombok.
  • Prefer java.time. Instant and LocalDate are immutable, which removes a whole class of copies. Reach for Date only when an old API forces it.
  • JPA entities. Entities generally can't be immutable in this sense, because the provider needs a no-argument constructor and mutable fields. Keep this style for value objects and DTOs.
  • Serialization. If the class implements Serializable, deserialization bypasses your constructor. I didn't test that here. Check the Java Object Serialization specification before relying on invariants surviving it.

The short version

  • Make the class final, every field private final, and add no setters.
  • Copy mutable arguments in the constructor, and copy or wrap mutable values you return.
  • Validate in the constructor so an invalid object can't exist.
  • Make the things inside collections immutable too, because List.copyOf is shallow.
  • "Change" an object by building a new one with a with method that runs the same validation.
  • Test the rules, not only the behaviour. A reflection check on final is cheap.

Immutable class FAQ

Is a class with all final fields immutable?
Not necessarily. final fixes the reference, not the object behind it. A final List field is still mutable if the list came from the caller or is returned directly. You need defensive copies or unmodifiable collections on top of final.

Why does an immutable class have to be final?
A subclass can override methods and add mutable state, so code holding a reference to your type would no longer be able to trust it. Making the class final closes that door. A private constructor with static factories also works, since nobody outside can extend the class.

Is String immutable in Java, and why does it matter here?
Yes. A String can be stored and returned as-is, with no copying. That's why Recipe copies the List and the Date but not the name. For more on strings, see understanding string immutability.

Are Java records immutable?
Their fields are final and there are no setters, so they're shallowly immutable. A record holding a List or Date still needs the copy-in rule. See how to use Java records.

how-to-tutorialscore-javajava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments