☕ Java

Immutable Programming

Immutable programming in Java refers to a design discipline — not a single language feature — of constructing objects whose observable state cannot change after construction, relying on a combination of language features accumulated across many releases (final fields, defensive copying, immutable collection factories from Java 9, and records from Java 16) to make immutability both easier to express correctly and harder to violate accidentally. Immutable objects eliminate an entire category of bugs related to shared mutable state — unexpected changes visible through aliased references, broken invariants from partial mutation, and the need for defensive synchronization when objects cross thread boundaries — at the cost of needing to create new instances rather than mutating existing ones for every state change. This entry covers exactly what guarantees true immutability requires beyond just final fields, the defensive-copying problem for mutable fields holding collections or arrays, how records and immutable collection factories make correct immutability the easy default rather than something requiring extensive boilerplate, and the practical performance/design tradeoffs (wither-style update methods, structural sharing) immutable design requires.

What True Immutability Requires Beyond final Fields

Declaring every field of a class final is necessary for immutability but is not by itself sufficient, a distinction that trips up a meaningful fraction of hand-written "immutable" classes. final only guarantees that a field's reference cannot be reassigned after construction — it says nothing about whether the object that reference points to can itself be mutated. A class with a single final field of type List<String> genuinely cannot reassign that field to point at a different list after construction, but if that final field holds a reference to an ordinary mutable ArrayList, and the constructor simply stores the reference it was given (or the accessor simply returns the stored reference directly), then any code holding a reference to the original list passed into the constructor — or any code that calls the accessor and keeps the returned reference — can mutate that same underlying list, and that mutation is then visible through the "immutable" object, despite its field never having been reassigned. The object's own field is still final and unchanged; the data it points to is not. Genuine immutability for a class holding any mutable type as a component (collections, arrays, dates in older non-immutable representations, or any other mutable reference type) requires defensive copying at both boundaries: the constructor must copy the incoming mutable data into a new, internally-owned structure rather than storing the caller's own reference directly, and any accessor that would otherwise return the internal mutable structure directly must instead return either a copy or, more efficiently, a genuinely immutable view or wrapper, so that the caller receiving that accessor's result cannot use it to mutate the object's internal state. Skipping either boundary's defensive copy reintroduces the exact same aliasing hazard immutability was meant to eliminate, just relocated to whichever boundary was left unprotected, and the bug is often subtle precisely because the class still looks immutable at a glance (all fields final, no setters) while still actually being mutable through an overlooked side door.
Java
// ── PITFALL: final field, but still mutable through an aliased reference
final class FakeImmutablePoint {
    private final List<Integer> coordinates;   // final FIELD, but the LIST it points to is mutable

    FakeImmutablePoint(List<Integer> coordinates) {
        this.coordinates = coordinates;   // stores the CALLER'S own reference directly — no copy!
    }

    List<Integer> getCoordinates() {
        return coordinates;   // returns the INTERNAL mutable list directly — no protection here either
    }
}

List<Integer> original = new ArrayList<>(List.of(1, 2));
FakeImmutablePoint p = new FakeImmutablePoint(original);
original.add(999);                     // mutating the ORIGINAL list the caller still holds...
System.out.println(p.getCoordinates());   // [1, 2, 999] — "immutable" object's state just changed!

p.getCoordinates().add(42);            // ALSO mutable via the accessor's returned reference
System.out.println(p.getCoordinates());   // [1, 2, 999, 42] — mutated again, through a different path

// ── CORRECT — defensive copy at BOTH the constructor and accessor boundary
final class TrueImmutablePoint {
    private final List<Integer> coordinates;

    TrueImmutablePoint(List<Integer> coordinates) {
        this.coordinates = List.copyOf(coordinates);   // genuinely immutable copy — no shared reference
    }

    List<Integer> getCoordinates() {
        return coordinates;   // safe to return DIRECTLY now — it's already a structurally
                                // immutable collection (List.copyOf), no further copy needed here
    }
}

List<Integer> original2 = new ArrayList<>(List.of(1, 2));
TrueImmutablePoint p2 = new TrueImmutablePoint(original2);
original2.add(999);                      // mutating the caller's own original list...
System.out.println(p2.getCoordinates());    // [1, 2] — UNAFFECTED, because the constructor copied it
// p2.getCoordinates().add(42);             // UnsupportedOperationException — accessor returns
                                              // a genuinely immutable collection, not the caller's own

Records and Collection Factories — Making Correct Immutability the Easy Default

Before records (covered separately), achieving genuine immutability for a simple data-carrying class required writing out the canonical constructor, the accessors, equals()/hashCode()/toString(), and — for any field of a mutable type — the defensive-copy logic at both the constructor and accessor boundaries described above, by hand, every single time, with the correctness of each individual class's immutability resting entirely on the author remembering to actually write all of that defensive-copying logic correctly. Records don't automatically defensively copy mutable component types on the developer's behalf (a record's canonical or compact constructor still needs to perform that copy explicitly, exactly as an ordinary hand-written immutable class would, if one of its components is a genuinely mutable type like an array or a plain ArrayList) — what records do provide is a vastly smaller, more visible surface for getting this right, since the canonical/compact constructor is the single, obvious place defensive-copying logic belongs, and there's no separate accessor to remember to protect since the compiler-generated accessor simply returns whatever the (correctly-copied) field already holds. Records pair especially naturally with the Java 9 immutable collection factories (List.of(...), Set.of(...), Map.of(...), covered separately) and List.copyOf(...)/Set.copyOf(...)/Map.copyOf(...) (which return the input unchanged if it's already one of these structurally immutable collections, or produce a genuine immutable copy otherwise) precisely because a record's compact constructor is the natural place to apply exactly this copying — List.copyOf(someList) inside a compact constructor both defensively copies the data and ensures what's stored is a collection that itself cannot be structurally mutated later, combining both the "don't alias the caller's mutable collection" defense and the "don't let anyone, including a caller who somehow gets a reference to the stored collection, mutate it after the fact" defense in a single short call. This combination — records for the boilerplate-free constructor/accessor/equals/hashCode/toString generation, plus the immutable collection factories for any collection-typed components — is what makes writing a genuinely, fully immutable data class in modern Java dramatically less effortful and less error-prone than it was for the first two decades of the language's existence, where every one of these protections had to be manually assembled and manually verified correct for every individual immutable class.
Java
// ── Record with a mutable-type component — STILL needs explicit defensive copying
record Team(String name, List<String> members) {
    Team {   // compact constructor — the natural, single place for this protection
        members = List.copyOf(members);   // defensively copies AND makes the result
        // structurally immutable — protects against BOTH the constructor-aliasing hazard
        // AND the accessor-returns-a-mutable-reference hazard, in one line
    }
}

List<String> mutableNames = new ArrayList<>(List.of("Alice", "Bob"));
Team team = new Team("Avengers", mutableNames);

mutableNames.add("Carol");                 // mutating the caller's original list...
System.out.println(team.members());         // [Alice, Bob] — UNAFFECTED, thanks to List.copyOf()

// team.members().add("Dave");              // UnsupportedOperationException — the stored
// collection is itself immutable, so the accessor's result can't be mutated either

// ── Without the compact constructor's defensive copy — the record is NOT actually immutable
record LeakyTeam(String name, List<String> members) {
    // No compact constructor at all — "members" stores whatever reference was passed in directly
}
List<String> mutableNames2 = new ArrayList<>(List.of("Alice", "Bob"));
LeakyTeam leaky = new LeakyTeam("X-Men", mutableNames2);
mutableNames2.add("Carol");
System.out.println(leaky.members());   // [Alice, Bob, Carol] — "immutable-looking" record,
// but actually still mutable through the aliased original list — records do NOT
// automatically protect against this; the defensive copy must still be written explicitly

// ── List.copyOf — no-op if the input is already structurally immutable ─
List<String> alreadyImmutable = List.of("a", "b");
List<String> copy = List.copyOf(alreadyImmutable);
System.out.println(copy == alreadyImmutable);   // true in practice — no redundant copy made
// when the source is already one of these known-immutable collection types

Wither Methods, Structural Sharing, and the Performance Tradeoff

Because an immutable object's state can never change after construction, any operation that conceptually "changes" one of its fields must instead produce an entirely new instance with that one field updated and every other field copied unchanged from the original — a pattern commonly implemented via wither methods (so named by convention as the immutable-update counterpart to a mutable setter, typically named withX(...) rather than setX(...)), each of which constructs and returns a new instance reflecting the requested change, leaving the original instance completely untouched and still usable. Records support this pattern naturally, though not automatically — a withX(...) method must still be written explicitly for each component an author wants to support "updating," typically implemented by calling the record's own canonical constructor with all the same component values except the one being changed. A frequent first concern about immutable design is that constructing an entirely new object for every conceptual update sounds wasteful compared to mutating one field of an existing object directly — and for object graphs with many components, naively copying every component on every update would indeed be wasteful. In practice, this concern is substantially mitigated by structural sharing: an immutable update typically only needs to construct new instances along the specific path from the root object down to the actual field being changed, while every other branch of the object graph that wasn't touched can be shared, by reference, completely unchanged between the old and new versions — entirely safe to share precisely because immutable objects can never be mutated out from underneath either version, a guarantee that would not hold if the shared substructure could later change through one version and unexpectedly affect the other. This is the same principle underlying how Java's own immutable collection factories and operations like List.copyOf() avoid recopying data that's already known to be safely immutable and shareable, as covered earlier, and it is the design idea that makes immutable data structures practical at scale rather than merely safe in principle but prohibitively wasteful.
Java
// ── Wither methods — explicit, immutable-update counterpart to a setter ─
record Address(String street, String city, String zip) {}
record Person(String name, int age, Address address) {
    // Each "wither" returns a NEW Person — original instance is never mutated:
    Person withAge(int newAge) {
        return new Person(name, newAge, address);   // every OTHER field copied unchanged
    }
    Person withAddress(Address newAddress) {
        return new Person(name, age, newAddress);
    }
}

Person alice = new Person("Alice", 30, new Address("1 Main St", "Springfield", "00001"));
Person olderAlice = alice.withAge(31);

System.out.println(alice.age());        // 30 — ORIGINAL instance completely untouched
System.out.println(olderAlice.age());    // 31 — a DIFFERENT, new instance
System.out.println(alice.address() == olderAlice.address());   // true — STRUCTURAL SHARING:
// the unchanged "address" component is the SAME shared object in both versions,
// safe to share precisely because neither version could ever mutate it later

// ── Updating a deeply nested component — only the touched PATH is rebuilt
record AddressUpdate() {
    static Person withCity(Person p, String newCity) {
        Address oldAddr = p.address();
        Address newAddr = new Address(oldAddr.street(), newCity, oldAddr.zip());   // new Address
        return new Person(p.name(), p.age(), newAddr);                               // new Person
        // Only Person and Address along this specific path were rebuilt;
        // if Person had OTHER unrelated nested components, those would be shared, untouched
    }
}

Person relocatedAlice = AddressUpdate.withCity(alice, "Shelbyville");
System.out.println(alice.address().city());          // "Springfield" — original fully intact
System.out.println(relocatedAlice.address().city());  // "Shelbyville" — only the touched
                                                          // path was actually reconstructed

Related Topics in Functional Programming

Functional Programming Basics
Functional programming is a programming paradigm that treats computation as the evaluation and composition of functions, favors expressions that produce values over statements that mutate state, and prefers passing behavior around as data rather than encoding it only inside class hierarchies. Java was designed from the outset as an object-oriented, imperative language: methods could be invoked but never treated as values — they could not be stored in a variable, passed as an argument, or returned from another method, except through the verbose workaround of wrapping a single method inside an anonymous class that implemented a one-method interface. Java 8 closed this gap by introducing lambda expressions, method references, and a standard library of single-method functional interfaces in java.util.function, together with the Stream API built on top of them, giving Java a genuinely functional layer on top of its existing object-oriented core without abandoning that core. This entry covers what distinguishes functional style from imperative and object-oriented style, how Java represents a function as an instance of a functional-interface type rather than as a true first-class value, the standard functional interfaces the JDK provides for common shapes of behavior, and how adopting functional style changes the shape of everyday Java code.
Pure Functions
A pure function is a function whose return value depends solely on the arguments passed to it, with no dependence on any hidden or external state, and which produces no observable effect on the world beyond computing and returning that value — no mutation of arguments, no writes to shared state, no I/O, and no dependence on anything that can change between calls such as the system clock or a random number generator. Calling a pure function with the same arguments always produces the same result, a property known as referential transparency, which means a call to a pure function can be replaced anywhere in a program by its result without changing what the program does. Java does not enforce purity at the language level — any method, lambda, or method reference may freely read and write shared mutable state, perform I/O, or behave non-deterministically — so purity in Java is a discipline the developer chooses to follow rather than a guarantee the compiler provides. This entry covers the precise definition of purity and referential transparency, the common ways Java code becomes impure even unintentionally, and the practical payoffs of writing pure functions: simpler testing, safe caching and memoization, and safe parallel execution.
Higher Order Functions
A higher-order function is a function that takes one or more other functions as parameters, returns a function as its result, or both — as opposed to a first-order function, which only takes and returns plain data values like numbers, strings, or objects. Java has supported a narrow form of this since its very first versions, since passing a Runnable to a Thread or a Comparator to Collections.sort is technically passing a function (wrapped in a single-method interface) into another method, but writing an anonymous class at every call site made the pattern feel like a special case reserved for a handful of built-in APIs rather than a general technique available for everyday code. Lambda expressions and method references, introduced in Java 8, made supplying a function as an argument concise enough that higher-order functions became a natural, everyday tool — most visibly through the Stream API's map, filter, and reduce, but equally usable in ordinary application code through java.util.function. This entry covers the definition of a higher-order function, how Java represents functions as arguments and return values without true first-class functions, the most common shapes of functions that take functions as parameters, and the complementary shape of functions that build and return other functions, including function composition.
Closures
A closure is a function bundled together with the variables from its enclosing lexical scope that it refers to, captured at the time the function is created, so that the function can keep using those variables even after the scope that originally held them has finished executing. In Java, both lambda expressions and anonymous inner classes form closures: they can refer to local variables and parameters of the method that creates them, and to the fields of the enclosing object, even after that method has returned and its stack frame is gone. Java places one significant restriction on this that languages like JavaScript do not: any local variable or parameter captured by a lambda or inner class must be effectively final — assigned exactly once and never reassigned — which rules out the kind of closure-over-a-mutable-counter pattern that is idiomatic in other languages, and requires a small workaround whenever genuinely mutable shared state is needed. This entry covers what a closure captures and how, why Java enforces the effectively-final rule, the standard workarounds for needing mutable captured state, and the practical patterns and memory implications of using closures in real code.