W3docs

Java Immutability Best Practices

Why immutability is a good default in Java, and patterns for building immutable types safely.

Java Immutability Best Practices

An immutable object is one whose state cannot change after construction. That single property removes a whole class of bugs: there are no surprise mutations from another thread, no aliasing surprises where two variables share state, and no need to defensively copy on every read. In Java, immutability isn't automatic — you have to build it deliberately. This chapter covers the patterns that make a type truly immutable and the habits that keep it that way.

Why immutability is a good default

Mutable shared state is the root of most concurrency bugs and a surprising number of single-threaded ones too. When an object can't change, you can pass it freely, cache it, and reason about it without tracking who else holds a reference.

PropertyMutable objectImmutable object
Thread safetyNeeds locks or careInherently thread-safe
Safe to shareNo — callers may mutateYes — hand out the same instance
Safe as a map keyRisky — hashCode can driftYes — identity is stable
CachingMust invalidate on changeCache forever
ReasoningTrack every writeValue is fixed at construction

The cost is allocation: changing one field means creating a new object. For most code that cost is negligible and the safety is worth it. Reach for mutability only when profiling proves you need it.

The five rules for an immutable class

A class is immutable when all of the following hold. Miss one and a caller can reach in and change state.

  1. The class is final (or all constructors are private) so it can't be subclassed with mutable behavior.
  2. All fields are private final.
  3. There are no setters — and no other methods that change a field.
  4. Mutable fields are defensively copied on the way in so the caller's reference can't be used to mutate your state.
  5. Getters never expose a mutable internal object directly — return a copy or an unmodifiable view.
public final class Money {
    private final long cents;
    private final String currency;

    public Money(long cents, String currency) {
        this.cents = cents;
        this.currency = currency;
    }

    public long cents() { return cents; }
    public String currency() { return currency; }

    // "Change" returns a new object instead of mutating this one.
    public Money plus(Money other) {
        return new Money(this.cents + other.cents, currency);
    }
}

Defensive copies for mutable fields

Primitives and String are already immutable, so storing them is safe. The danger is mutable fields — arrays, collections, dates. If you store the caller's reference directly, they keep a handle to your internals.

public final class Schedule {
    private final List<String> slots;

    public Schedule(List<String> slots) {
        // Copy IN: the caller can't mutate our list later.
        this.slots = List.copyOf(slots);
    }

    public List<String> slots() {
        // copyOf already returns an unmodifiable list, so this is safe to hand out.
        return slots;
    }
}

List.copyOf, Set.copyOf, and Map.copyOf (Java 10+) do both jobs at once: they copy the data and return an unmodifiable view. For arrays, use array.clone() on the way in and clone() again on the way out, since arrays are always mutable and have no read-only wrapper.

Records: immutability by construction

A record (Java 16+) is the most concise way to declare an immutable carrier of data. The compiler generates private final fields, a canonical constructor, accessors, and value-based equals/hashCode/toString.

public record Point(int x, int y) {
    // Compact constructor for validation and defensive copying.
    public Point {
        if (x < 0 || y < 0) {
            throw new IllegalArgumentException("coordinates must be non-negative");
        }
    }
}

Records cover the common case beautifully, but they are not a magic shield: if a record component is a mutable type (like List), you still must defensively copy it in the compact constructor, because the generated accessor returns the stored reference as-is.

Producing changed copies: the "wither" pattern

Since you can't mutate an immutable object, you create a modified copy. The convention is a withX method that returns a new instance with one field changed and the rest carried over.

public final class User {
    private final String name;
    private final String email;

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public User withEmail(String newEmail) {
        return new User(this.name, newEmail); // new object, original untouched
    }
}

This keeps the original safe to share while letting callers build variations. It's the same model the JDK uses internally — LocalDate.plusDays, String.replace, and BigDecimal.add all return new instances rather than mutating the receiver.

A complete runnable example

The program below builds a small immutable Account, then tries every trick a caller might use to mutate it — passing in a list and mutating the original, mutating the returned list, and renaming. It proves each defense holds, then shows why immutable values make safe map keys.

java— editable, runs on the server

What to take from the run:

  • Roles after mutating source: [read, write] proves the defensive copy worked — adding admin to the original list never reached the Account.
  • The UnsupportedOperationException on acc.roles().add("hacker") shows the getter returned an unmodifiable view, so callers can't mutate internals through it.
  • Original name still: Ada next to New object name: Grace proves withName produced a copy and left the original untouched.
  • Different instance: true confirms the wither returned a genuinely new object rather than the same reference.
  • Records equal by value: true and Key still found: origin-ish show that immutable values compare and hash by content, making them dependable HashMap keys.

Practice

Practice

When an immutable class stores a mutable field such as a List, why must you make a defensive copy in the constructor?