LLD Dojo

Syllabus · D3

Immutability boundaries at API edges

The idea

Where your object stops and the caller's begins

An object you built and think of as immutable can still be mutated by someone else. That happens at exactly two moments: when a value comes in through a constructor, and when a value goes back out through an accessor. At either moment, whoever holds that reference next is free to mutate it, unless you cut the handle to your own copy first.

corpus/parking-lot's Stay does exactly that. It copies the list it is given, inside its own compact constructor, and the comment right there states why: "the caller cannot keep a handle on where this vehicle is." movie-booking's Booking and HoldResult follow the identical rule for every list either of them carries. Copy on the way in, and copy again on the way out. Skipping either half means the object you built is only immutable until somebody touches the list they handed you, or the one you handed back.

It matters which copying method you reach for, because List.copyOf and Collections.unmodifiableList are not the same guarantee, even though they read similarly at the call site. List.copyOf takes a snapshot of the contents once, right then. Collections.unmodifiableList only wraps whatever list you give it and refuses writes made through the wrapper itself. The list underneath can still change, though, and every change shows straight through it. A view is not a copy, no matter how it is spelled.

Records only protect you one level deep, which is a separate trap. `record Appointment(String title, Date when) cannot have its when` field reassigned to point at a different object, but when.setTime(0), called from outside after construction, still mutates the very object the field already points to. Instant and LocalDate have no method like that at all, which is why the corpus uses them everywhere and never uses Date. A mutable builder handed out mid-build falls into the same trap.

An array leaks the same way a mutable list does, only worse, because there is no unmodifiable array type to reach for. An int[] returned from a getter is always a live handle straight into your internals, whether the field holding it is final or not.

worked/ runs both leaks end to end, with their fixes, and shows the real output each produces.


Worked walkthrough

NOTES — three leaks at the boundary, and the one-line fix for each

Run it first

.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main

javac reports zero errors and zero warnings. Real output, verbatim:

--- 1. Defensive copies in and out ---
leaky, after mutating the caller's own source: [keys, wallet, phone] weights [999, 250]
leaky, after mutating what itemIds()/weights() returned: [keys, wallet, phone, burglar's crowbar] weights [999, -1]
safe,  after mutating the caller's own source:  [keys, wallet]
safe,  itemIds().add(...) threw UnsupportedOperationException
safe,  locker's own contents, unchanged:          [keys, wallet]

--- 2. List.copyOf vs Collections.unmodifiableList ---
backing mutated after both were taken: [A1, A2, A3]
unmodifiableList view sees the change: [A1, A2, A3]
List.copyOf snapshot does not:         [A1, A2]
view.add(...) still throws, but only blocks writes through itself

--- 3. Records give shallow immutability only ---
appointment built at epoch 0:      0
same record, after mutating Date:  999999999   (nothing reassigned appt.when, and it still moved)
safe appointment built at epoch 0: 0
Instant has no setter to call; 1970-01-01T00:00:00Z cannot move after construction

Block 1 — LeakyLocker vs Locker: copy in, copy out

public LeakyLocker(List<String> itemIds, int[] weights) {
    this.itemIds = itemIds;
    this.weights = weights;
}

public List<String> itemIds() {
    return itemIds;
}

Two aliases, and the run shows both firing independently. source.add("phone") runs after LeakyLocker was already built, and the locker's own itemIds() reports the addition anyway, because itemIds never held anything of its own. Then leaky.itemIds().add("burglar's crowbar") mutates the locker's real internal list through the reference it handed out, with no constructor call anywhere near it. Same story for the array: sourceWeights[0] = 999 after construction, and leaky.weights()[1] = -1 through the returned array, both land.

Locker closes both holes with one line each:

public Locker(List<String> itemIds) {
    this.itemIds = new ArrayList<>(itemIds);
}

public List<String> itemIds() {
    return List.copyOf(itemIds);
}

corpus/parking-lot/reference/src/Stay.java copies its own list the same way, in its compact constructor, with the reason stated inline: "the caller cannot keep a handle on where this vehicle is." In movie-booking/contract/, both Booking.java and HoldResult.java run seatIds = List.copyOf(seatIds) in their compact constructors for the identical reason. The javadoc on worked/src/Locker.java states it again there.

Two separate copies, because they defend against two separate callers. The constructor's copy stops a caller from reaching back through the list they handed you. The accessor's copy stops a caller from reaching in through the list you hand them. Skipping either one leaves the boundary open on that side.

Block 2 — a view is not a copy

List<String> view = Collections.unmodifiableList(backing);
List<String> copy = List.copyOf(backing);
backing.add("A3");

view still shows [A1, A2, A3] after the mutation, because Collections.unmodifiableList wraps backing rather than copying it — it forwards every read straight through. copy still shows [A1, A2], because List.copyOf took a snapshot the moment it ran and never looks at backing again. Both refuse .add(...) called on themselves directly; that part of "unmodifiable" is real for both. What differs is whether the collection can change out from under you through some other reference, and only one of the two makes that impossible.

corpus/shopping-cart/contract/ShoppingCartApi.java states both as acceptable implementations of the same promise: "unmodifiable collections — List.copyOf or Collections.unmodifiableList over a copy." The qualifier "over a copy" is doing the entire job. Collections.unmodifiableList(backing) alone is not that; it is a view over whatever backing is doing this millisecond.

Block 3 — a record is immutable exactly one level deep

public record Appointment(String title, Date when) {}

appt.when() cannot be reassigned to point somewhere else; the run never does that. Instead meetingTime.setTime(999_999_999L) mutates the very Date object the record already holds a reference to, and appt.when().getTime() reports the new value because it is the same object, asked again. The record kept its promise: nothing reassigned a field, and the observable state still moved.

SafeAppointment swaps Date for Instant and changes nothing else. Instant has no method that mutates the instant it represents; every operation on it returns a new one. There is no line of code that can be written against SafeAppointment to reproduce block 3's second line. LocalDate gives the same guarantee for a calendar date, for the same reason.


The choice, stated once

BoundaryWrongRightWhy
Constructor argumentstore the referenceList.copyOf/new ArrayList<>(arg)a caller's later edit to their own list must not reach you
Accessor returnthe live internal fieldList.copyOf(field)a caller's edit to what you handed back must not reach you
"Unmodifiable" collectionCollections.unmodifiableList(mutable)List.copyOf(mutable)a view forwards every future write; a copy cannot see one
A field the record cannot re-pointa mutable type (Date, a builder)Instant, LocalDate, another recordshallow immutability only stops reassignment, not mutation
A collection-shaped fieldint[]/String[] returned directlycopy on the way out, or use a Listno array in Java has an unmodifiable form

when-not.md covers the one case this table leaves out: a collection large enough, or an accessor hot enough, that the copy itself becomes the cost worth naming.


When not to

Copying is not automatically right

Call an accessor once per frame in a render loop, and whatever allocation sits inside it runs once per frame too. List.copyOf on every accessor call is exactly that kind of allocation, plus an O(n) walk, every time, whether or not the caller ever reads the result. That is fine for a Booking's five seat ids. It is a real cost once the collection is large or the accessor is hot.

Where the cost actually lands

corpus/file-system's ls can return a directory holding thousands of entries, and it is called once per listing, not once per file inside it. Copying there is one allocation sized to the answer, which is cheap next to walking the tree to build that answer in the first place. The copy is not the expensive part.

Contrast a method called once per frame, or once per row in a UI table, that copies a few thousand elements it already owns and never lets anyone else touch. corpus/cost-explorer's AnnualProjection.months() copies twelve entries; nobody would notice that cost at any call rate. The same List.copyOf line, called from a render loop over a ten-thousand-row cart, shows up in a profiler. That is a guarantee the caller may not need, if nothing else in the program holds a reference to that list.

The actual threshold

Copy when more than one caller can plausibly hold the reference you hand out. Copy, too, when you cannot prove none of them will hold it past the moment your own state changes next.

Skip the copy, and say so in the javadoc, in two cases. First, the object is freshly built for this one call and nothing else has a handle on it. Second, the caller is trusted code in the same module the contract was written against.

The same threshold applies inside a class, not only at its public edge. A private helper passing a list to another private helper does not need List.copyOf at every hop. Only the boundary where an outside caller receives the reference does.

What this does not excuse

None of this licenses handing out your own live internal list "because copying would be slow." That is the leak this lesson exists to catch, not a performance exception to it. The cost argument only applies to a copy you would otherwise make correctly. Measure before you skip it, and default to copying until a real caller and a real call rate say otherwise.


Worked source

The 5 files of the worked design

Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.

worked/src/Appointment.java10 lines

import java.util.Date;

/**
 * A record is immutable exactly one level deep: {@code when} can never be reassigned, but the
 * {@link Date} it points to has its own mutators, and calling one of them after this record
 * exists still changes what {@link #when()} reports. Nothing here is a bug in the record; the
 * record is doing exactly what it promises. The trap is expecting a stronger promise than that.
 */
public record Appointment(String title, Date when) {
}

worked/src/LeakyLocker.java28 lines

import java.util.List;

/**
 * Every boundary mistake this lesson names, in one small class, so {@code Main} can print each
 * leak next to its fix. Nothing here is a subtle bug: every line below is a line a reviewer would
 * flag on sight, once shown what to look for.
 */
public final class LeakyLocker {

    private final List<String> itemIds;
    private final int[] weights;

    /** Stores exactly the references it was handed. Neither argument is copied. */
    public LeakyLocker(List<String> itemIds, int[] weights) {
        this.itemIds = itemIds;
        this.weights = weights;
    }

    /** Hands back the live internal list. A caller holding this can add or remove through it. */
    public List<String> itemIds() {
        return itemIds;
    }

    /** Hands back the live internal array. There is no unmodifiable wrapper for an array to hide behind. */
    public int[] weights() {
        return weights;
    }
}

worked/src/Locker.java30 lines

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

/**
 * {@link LeakyLocker} with both leaks closed. Modelled on {@code corpus/parking-lot}'s
 * {@code Stay}, which copies the list it is given "so the caller cannot keep a handle on where
 * this vehicle is" — the identical move, one domain over.
 *
 * <p>Copy on the way in, so a list the caller keeps mutating after construction cannot change
 * this locker. Copy on the way out, so a list handed to a caller cannot be used to change this
 * locker either. Two different copies, for two different callers, at two different moments.
 */
public final class Locker {

    private final List<String> itemIds;

    public Locker(List<String> itemIds) {
        this.itemIds = new ArrayList<>(itemIds);
    }

    /** A snapshot. Mutating it, or trying to, cannot reach this locker's own contents. */
    public List<String> itemIds() {
        return List.copyOf(itemIds);
    }

    /** The one sanctioned way to change what this locker holds. */
    public void add(String itemId) {
        itemIds.add(itemId);
    }
}

worked/src/SafeAppointment.java9 lines

import java.time.Instant;

/**
 * {@link Appointment} with the one change that closes the trap: {@link Instant} instead of
 * {@link java.util.Date}. There is no method on {@link Instant} that mutates the instant it
 * represents, so once this record exists, nothing reachable from it can move.
 */
public record SafeAppointment(String title, Instant when) {
}

worked/src/Main.java95 lines

import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;

/**
 * Three leaks, each run against its fix, with the real printed evidence that
 * worked/NOTES.md quotes.
 */
public final class Main {

    public static void main(String[] args) {
        copyInAndOut();
        viewVersusCopy();
        shallowRecord();
    }

    /** Block 1. Defensive copies in and out, for a {@code List} and for an array. */
    private static void copyInAndOut() {
        System.out.println("--- 1. Defensive copies in and out ---");

        List<String> source = new ArrayList<>(List.of("keys", "wallet"));
        int[] sourceWeights = {100, 250};
        LeakyLocker leaky = new LeakyLocker(source, sourceWeights);

        source.add("phone");
        sourceWeights[0] = 999;
        System.out.println("leaky, after mutating the caller's own source: " + leaky.itemIds()
                + " weights " + java.util.Arrays.toString(leaky.weights()));

        List<String> stolen = leaky.itemIds();
        stolen.add("burglar's crowbar");
        leaky.weights()[1] = -1;
        System.out.println("leaky, after mutating what itemIds()/weights() returned: "
                + leaky.itemIds() + " weights " + java.util.Arrays.toString(leaky.weights()));

        List<String> safeSource = new ArrayList<>(List.of("keys", "wallet"));
        Locker safe = new Locker(safeSource);
        safeSource.add("phone");
        System.out.println("safe,  after mutating the caller's own source:  " + safe.itemIds());

        List<String> snapshot = safe.itemIds();
        try {
            snapshot.add("burglar's crowbar");
        } catch (UnsupportedOperationException rejected) {
            System.out.println("safe,  itemIds().add(...) threw UnsupportedOperationException");
        }
        System.out.println("safe,  locker's own contents, unchanged:          " + safe.itemIds());
        System.out.println();
    }

    /** Block 2. A view over a mutable list is not the same guarantee as a snapshot. */
    private static void viewVersusCopy() {
        System.out.println("--- 2. List.copyOf vs Collections.unmodifiableList ---");

        List<String> backing = new ArrayList<>(List.of("A1", "A2"));
        List<String> view = Collections.unmodifiableList(backing);
        List<String> copy = List.copyOf(backing);

        backing.add("A3");
        System.out.println("backing mutated after both were taken: " + backing);
        System.out.println("unmodifiableList view sees the change: " + view);
        System.out.println("List.copyOf snapshot does not:         " + copy);

        try {
            view.add("A4");
        } catch (UnsupportedOperationException rejected) {
            System.out.println("view.add(...) still throws, but only blocks writes through itself");
        }
        System.out.println();
    }

    /** Block 3. A record's own fields cannot be reassigned; what they point to still can be. */
    private static void shallowRecord() {
        System.out.println("--- 3. Records give shallow immutability only ---");

        Date meetingTime = new Date(0L);
        Appointment appt = new Appointment("Design review", meetingTime);
        System.out.println("appointment built at epoch 0:      " + appt.when().getTime());

        meetingTime.setTime(999_999_999L);
        System.out.println("same record, after mutating Date:  " + appt.when().getTime()
                + "   (nothing reassigned appt.when, and it still moved)");

        Instant fixedTime = Instant.ofEpochMilli(0L);
        SafeAppointment safeAppt = new SafeAppointment("Design review", fixedTime);
        System.out.println("safe appointment built at epoch 0: " + safeAppt.when().toEpochMilli());
        System.out.println("Instant has no setter to call; " + fixedTime
                + " cannot move after construction");
    }

    private Main() {
    }
}

The faded stage is not here, on purpose

In the app, the third stage of a lesson hands you the worked design with a few lines replaced by // GAP: markers, then compiles your completion and runs a JUnit suite against it. That needs javac, and a static site has no compiler — so rather than show a control that cannot work, this page stops at the worked source.

Run the app for the drill: it is the download in the header, and it works offline once unpacked.

← D2 · Error signalling choice — exception vs result vs Optional D4 · Iteration, pagination and bulk-operation shape →

← all lessons