Immutability & Defensive Copies

An object that hands out or accepts a mutable reference without copying it isn't as encapsulated as it looks — outside code can silently change its internal state.

Encapsulation is often treated as "fields are private, so the object controls its own state" — but that guarantee breaks the moment a mutable object (an array, a List, a Date) is stored directly from a constructor argument or handed out directly from a getter. Anyone holding that same reference can mutate it, bypassing every invariant the class thinks it enforces. Defensive copying and immutability are the two complementary techniques that close this gap.

The Problem: Mutable Constructor Arguments

When a constructor stores a reference passed in by the caller without copying it, the constructed object and the caller end up sharing the same underlying mutable data. If the caller later mutates that shared object, the "already constructed" object changes too — silently, and often long after construction, in code far away from where the mutation happened.

Because Appointment stored the caller's Date reference directly, mutating meetingTime after construction silently changes appt's internal state -- appt never agreed to that, and nothing in its API warned the caller it was possible.

import java.util.Date;

public class Appointment {
    private final Date when; // Date is mutable (legacy API, shown here deliberately)

    Appointment(Date when) {
        this.when = when; // BUG: stores the caller's reference directly
    }

    Date getWhen() {
        return when; // BUG: hands out the internal reference directly
    }
}

class Demo {
    public static void main(String[] args) {
        Date meetingTime = new Date();
        Appointment appt = new Appointment(meetingTime);

        meetingTime.setTime(0L); // mutates Appointment's internal state too!
        System.out.println(appt.getWhen()); // Thu Jan 01 00:00:00 ... 1970
    }
}

The Problem: Mutable Getters

The mirror-image bug happens on the way out: if a getter returns the internal mutable field directly, the caller receives a live reference to the object's actual internal state, not a snapshot of it. Any mutation the caller performs on the returned object mutates the original object too, even though the caller may have had no intention of doing so.

The caller calls clear() on what it assumes is its own list, but it's actually Team's internal list -- Team's invariants (whatever it might enforce on membership) can now be silently violated by any caller.

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

public class Team {
    private final List<String> members = new ArrayList<>();

    void add(String name) { members.add(name); }

    List<String> getMembers() {
        return members; // BUG: exposes the live internal list
    }
}

class Demo2 {
    public static void main(String[] args) {
        Team team = new Team();
        team.add("Ada");

        List<String> snapshot = team.getMembers();
        snapshot.clear(); // caller thought this was harmless
        System.out.println(team.getMembers().size()); // 0 -- Team's real state was wiped
    }
}

Defensive Copying on the Way In

The fix for the constructor case is to copy the mutable argument into a new object the class exclusively owns, rather than storing the caller's reference. From that point forward, nothing the caller does to its original reference can affect the object under construction.

Both the constructor and the getter make an independent copy, so neither the caller's original Date nor anything the caller does with a returned Date can reach SafeAppointment's real internal state.

For collections, new ArrayList<>(caller.getList()), List.copyOf(...), or Collections.unmodifiableList(new ArrayList<>(...)) are the equivalent moves — construct a fresh collection so the class owns its own storage, independent of whatever the caller does to their original collection afterward.

import java.util.Date;

public class SafeAppointment {
    private final Date when;

    SafeAppointment(Date when) {
        this.when = new Date(when.getTime()); // defensive copy in
    }

    Date getWhen() {
        return new Date(when.getTime()); // defensive copy out
    }
}

Defensive Copying on the Way Out

The fix for the getter case is symmetric: return a copy (or an unmodifiable view backed by a copy) instead of the live internal reference, so a caller can look at the data, but cannot reach back in and mutate the object's real state through it.

List.copyOf(members) both copies the data and returns a structurally unmodifiable list -- a caller who tries to mutate it gets an immediate UnsupportedOperationException instead of silently corrupting SafeTeam's real state.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class SafeTeam {
    private final List<String> members = new ArrayList<>();

    void add(String name) { members.add(name); }

    List<String> getMembers() {
        return Collections.unmodifiableList(new ArrayList<>(members));
        // or simply: return List.copyOf(members); (also unmodifiable, Java 10+)
    }
}

Unmodifiable Views Versus True Immutability

Collections.unmodifiableList(list) (and its unmodifiableSet/unmodifiableMap relatives) returns a wrapper that throws UnsupportedOperationException on any mutating call — but it is only a view over the original backing list. If the original list is still mutated directly (through a reference someone else holds), the "unmodifiable" view reflects that change, because it isn't a copy, only a read-only window onto the same data.

unmodifiableList prevents mutation THROUGH the view, but does not prevent the underlying list from being mutated through a different reference -- true immutability requires the class to also control every reference to the backing data, typically via a defensive copy.

List.of(...), Set.of(...), Map.of(...), and List.copyOf(...) (Java 9/10+) return genuinely immutable collections backed by their own private storage — a stronger and simpler guarantee than wrapping a still-mutable backing collection, and generally preferred for new code.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class UnmodifiableViewCaveat {
    public static void main(String[] args) {
        List<String> original = new ArrayList<>(List.of("a", "b"));
        List<String> view = Collections.unmodifiableList(original);

        original.add("c"); // legal: mutating the backing list directly
        System.out.println(view); // [a, b, c] -- the "unmodifiable" view still changed

        // view.add("d"); // would throw UnsupportedOperationException
    }
}

Deep Versus Shallow Immutability

Making a field final and copying the top-level collection or array only achieves shallow immutability if the elements inside are themselves mutable. A final List<StringBuilder> copied defensively still lets a caller mutate the StringBuilder objects it contains, because the copy duplicates the list's structure, not the mutable objects referenced from it.

List.copyOf makes the LIST structure immutable (you can't add/remove/replace elements), but it copies references, not the objects those references point to -- true deep immutability requires every referenced mutable object to also be immutable or independently copied.

True deep immutability requires every field, and every object transitively reachable from it, to be immutable. This is why designs favoring immutability (records with immutable field types, String, boxed primitives, LocalDate) compose so much more safely than designs mixing immutable containers with mutable elements.

import java.util.List;

public class ShallowVsDeep {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder("hello");
        List<StringBuilder> shallowCopy = List.copyOf(List.of(sb));

        sb.append(" world"); // mutates the shared StringBuilder itself
        System.out.println(shallowCopy.get(0)); // "hello world" -- the list is immutable, its element is not
    }
}

Immutability by Design: Records and Final Fields

A record (Java 16+) generates final fields, a canonical constructor, accessors, equals/hashCode/toString — but it does not automatically defensively copy mutable components. If a record component is a mutable type (an array, a List that isn't already immutable), the same defensive-copy discipline applies inside a compact constructor.

The compact constructor reassigns the component before the implicit field assignment happens, so every Roster ends up backed by its own immutable copy regardless of what List the caller passed in.

final on a field prevents reassigning the reference, not mutation of the object it points to (see the Immutability language rule) -- a final List<String> members field can still have elements added to it unless the list itself is immutable.

import java.util.List;

public record Roster(List<String> players) {
    public Roster {
        players = List.copyOf(players); // defensive copy + immutable wrapper, in the compact constructor
    }
}

Practical Usage: When This Matters Most

Defensive copying matters most for value-like objects meant to be shared safely (configuration objects, domain value objects, anything passed across module or thread boundaries), and it becomes essential once concurrency enters the picture: a shared mutable object with no copying and no synchronization is a data race waiting to happen; an immutable one can be shared freely across threads with no synchronization at all.

For purely internal, short-lived, single-threaded code where a class and its caller are tightly coupled and clearly under one team's control, defensive copying every field can be excessive — it is a judgment call weighed against the cost of copying (allocation, and for large data structures, real performance cost) versus the risk of the aliasing bug.

Edge Cases

Arrays are always mutable in Java, with no built-in immutable-array type — a final int[] field still allows individual elements to be reassigned, and returning it directly from a getter exposes the same aliasing hazard as a mutable List. Cloning (array.clone()) or copying with Arrays.copyOf is the array equivalent of a defensive copy.

Defensive copying an argument inside a constructor should happen before validating it, not after — otherwise a time-of-check/time-of-use gap exists where the caller could mutate the object between validation and copying (relevant mainly under concurrency, but a good habit regardless).

Copying is not free: for very large collections or in hot code paths, defensive copying has a real, measurable cost, which is one more argument for designing with genuinely immutable types from the start rather than repeatedly copying mutable ones.

Mental Model

Think of a mutable object like a shared whiteboard: handing someone a reference to it (rather than a photograph of its current state) means they can walk up and erase or rewrite what's on it, and everyone else holding that same reference sees the change instantly. A defensive copy is the photograph — a snapshot no one else can alter. An immutable object is a whiteboard that's been laminated: even the person who "owns" the reference can't write on it, so handing it out freely is always safe.

Quick Reference

Constructor: this.field = new X(param);        // copy in
Getter:      return new X(this.field);          // copy out
Collections: List.copyOf(list) / new ArrayList<>(list)  // immutable / independent copy
Unmodifiable view: Collections.unmodifiableList(list)   // still backed by original -- can still change
final field: prevents reassignment, NOT mutation of the referenced object
Records:     use a compact constructor to defensively copy mutable components
Arrays:      always mutable; use clone()/Arrays.copyOf for a defensive copy

Key Takeaways

Storing or returning a mutable object's reference directly exposes internal state that outside code can silently mutate -- copy in at the constructor, copy out at the getter.

Collections.unmodifiableList (and friends) only blocks mutation through that specific view; the backing collection can still change if another reference to it exists. List.of/List.copyOf give a genuinely independent, immutable collection.

final prevents a field from being reassigned -- it does not make the object it references immutable.

A defensive copy of a collection or array is only shallow: it duplicates structure, not the mutable objects the elements reference -- true deep immutability requires every reachable object to be immutable too.

Records don't defensively copy mutable components automatically; do it explicitly in a compact constructor.

Immutability matters most for objects shared across threads or module boundaries, where it eliminates a whole class of aliasing and data-race bugs at no synchronization cost.

class Range { private final Date start; Range(Date start) { this.start = start; } } class Range { private final Date start; Range(Date start) { this.start = new Date(start.getTime()); } }

Storing the caller's Date reference directly lets any later mutation of the caller's object silently change Range's internal state; copying breaks that link.

List<String> getTags() { return tags; } List<String> getTags() { return List.copyOf(tags); }

Returning the live internal list lets any caller mutate the object's real state through the returned reference; returning an immutable copy prevents that entirely.

List<String> readOnly = Collections.unmodifiableList(mutableSource); // assume readOnly can never change

List<String> readOnly = List.copyOf(mutableSource); // genuinely independent and immutable unmodifiableList only wraps the original list; if code elsewhere still holds and mutates mutableSource, readOnly reflects those changes too, despite its name.

record Config(final List<String> hosts) {} // no defensive copy record Config(List<String> hosts) { public Config { hosts = List.copyOf(hosts); } }

A record's canonical constructor stores whatever reference it's given; without an explicit compact constructor copy, a caller's mutable List remains directly reachable and mutable through Config.

Why does storing a caller-supplied mutable object directly in a constructor break encapsulation, even though the field is private?

Because the caller retains its own reference to the same object; mutating that object after construction changes the private field's referenced data too, since private only restricts direct field access, not mutation of an object two references point to.

What is the key difference between Collections.unmodifiableList(list) and List.copyOf(list)?

unmodifiableList returns a view that blocks mutation through itself but is still backed by (and reflects changes to) the original list if it's mutated via another reference; List.copyOf creates an independent, genuinely immutable copy that no external mutation can affect.

Explain why 'final' does not make an object immutable, using a final List field as an example.

final only prevents the field itself from being reassigned to point to a different object; it says nothing about the object's internal mutability, so a 'final List<String> items' field can still have elements added, removed, or replaced via items.add(...) etc.

What is the difference between shallow and deep immutability, and why does it matter for a List<StringBuilder>?

Shallow immutability means the container's structure (which elements exist, in which order) can't change, but the elements themselves can still be mutated; a List.copyOf of a List<StringBuilder> is structurally immutable, but each StringBuilder inside it remains fully mutable, so deep immutability additionally requires every referenced object to itself be immutable.

Why must defensive copying be done explicitly in a record's compact constructor, rather than relying on the record feature itself?

A record's generated canonical constructor simply assigns whatever value it receives to the final field; it performs no automatic copying, so a mutable component (like a List) passed to a record remains the caller's live reference unless the compact constructor explicitly reassigns it to a copy, e.g. via List.copyOf.

In what situation is skipping defensive copying a reasonable engineering trade-off rather than a bug?

For tightly-coupled, single-threaded, internal code where the class and its callers are clearly controlled by the same team and the aliasing risk is well understood and intentional (or performance-critical code operating on large data where copying cost is prohibitive) -- it's a deliberate trade-off, not something to do by default or without documenting the shared-ownership contract.

Why are arrays a special case for defensive copying compared to List?

Java has no built-in immutable array type -- even a final array field's elements remain individually reassignable, and there's no equivalent of List.of()/List.copyOf() for arrays, so any defensive copy of an array must be done explicitly with clone() or Arrays.copyOf(), and a getter returning an array always needs to return a copy to avoid exposing internal state.

Write an ImmutablePoint3D class with three double fields (x, y, z) that is genuinely, fully immutable, including its constructor and any accessors.

public final class ImmutablePoint3D { private final double x, y, z; public ImmutablePoint3D(double x, double y, double z) { this.x = x; this.y = y; this.z = z; } public double x() { return x; } public double y() { return y; } public double z() { return z; } }

All three fields are primitive doubles (not reference types), so there is nothing mutable to copy defensively -- primitives are copied by value automatically on assignment, making the class trivially, fully immutable once the class itself is final (preventing a mutable subclass from breaking the invariant) and every field is final with no setters.

What does this print, and what two separate aliasing bugs does it demonstrate?

It prints [-1, 0, 99999, 100]. Bug 1: the constructor stored the caller's array reference directly, so mutating b[2] after construction changed Window's internal state (99999 appears). Bug 2: getBounds() returned the live internal array, so calling getBounds()[0] = -1 mutated Window's real state through the returned reference (the -1 appears). Both should be fixed with defensive copies: 'this.bounds = bounds.clone();' in the constructor and 'return bounds.clone();' in the getter.

Write a Schedule class holding a List<String> of event names that is fully protected against both constructor-argument and getter aliasing bugs.

import java.util.List; public final class Schedule { private final List<String> events; public Schedule(List<String> events) { this.events = List.copyOf(events); // defensive copy in, immutable } public List<String> getEvents() { return events; // already immutable -- safe to return directly } }

List.copyOf(events) in the constructor both copies the caller's list into independent storage and makes it structurally immutable, so no caller mutation (before or after construction) can reach Schedule's real state; because the stored list is already immutable, getEvents() can safely return it directly without needing a second copy.

What does this print, and how would you fix the Cart record to prevent it?

It prints 2, not 1 -- because Cart's canonical constructor stored myItems directly with no copy, cart.items() is the exact same List instance as myItems, so adding "pen" to myItems after construction is visible through cart too. Fix: add a compact constructor 'public Cart { items = List.copyOf(items); }' so Cart owns an independent, immutable copy of the items list from the moment it's constructed.

public class Window {
    private int[] bounds;
    Window(int[] bounds) { this.bounds = bounds; }
    int[] getBounds() { return bounds; }
}

int[] b = {0, 0, 100, 100};
Window w = new Window(b);
b[2] = 99999;
w.getBounds()[0] = -1;
System.out.println(java.util.Arrays.toString(w.getBounds()));
record Cart(List<Item> items) {}

List<Item> myItems = new ArrayList<>();
myItems.add(new Item("book"));
Cart cart = new Cart(myItems);
myItems.add(new Item("pen"));
System.out.println(cart.items().size());