> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Create an Immutable Class in Java (Java 21)
- URL: https://www.ggorantala.dev/how-to-create-immutable-class-java/
- Published: 2026-03-27T04:30:00.000Z
- Updated: 2026-10-31T06:35:58.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, core-java, java-tutorials

## Here's where we're heading

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

```log
--- 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.

```bash
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:

```log
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`

```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`

```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 `String`s 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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) 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`

```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](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev) walks through it.

Checkpoint: compile all three main files.

```bash
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`

```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

```bash
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`.

```log
--- 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`

```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.

```bash
javac -d out $(find src -name '*.java')
java -ea -cp out com.example.recipes.RecipeChecks

```

```log
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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev).

## 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](https://ggorantala.dev/java-volatile-keyword/?ref=ggorantala.dev) and [what immutability means in functional programming](https://ggorantala.dev/what-is-immutability-in-java-functional-programming/?ref=ggorantala.dev). 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:

```log
1 ingredient(s) in a 'immutable' Recipe

```

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

```log
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.**

```log
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:

```log
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:

```log
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`:

```log
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](https://ggorantala.dev/reading-java-stack-traces/?ref=ggorantala.dev).

## 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](https://ggorantala.dev/how-to-implement-builder-pattern-java/?ref=ggorantala.dev) 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](https://ggorantala.dev/how-to-build-immutable-objects-lombok/?ref=ggorantala.dev).
- **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](https://ggorantala.dev/understanding-string-immutability/?ref=ggorantala.dev).

**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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev).