Immutable Classes

Build classes whose state cannot change after construction, with defensive copies and with-methods, and know why they make concurrent services simpler.

Syntaxfinal 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 final so a subclass cannot add mutable state or override methods.
  • Make every field private final and 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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.

Example · java
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-01

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

Example · java
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

  • Be the first to comment on this lesson.