Immutability — Senior Level¶
Focus: "How do we architect for immutability across teams and services?" — immutable domain models, event sourcing / CQRS read models, immutable DTOs at service boundaries, persistent-collection libraries at scale, enforcement in review and tooling, and the migration from a mutable model to an immutable one without a big-bang rewrite.
Table of Contents¶
- Immutability as an architectural choice
- Immutable domain models, event sourcing, and CQRS read models
- Immutable DTOs across service boundaries
- Persistent-collection libraries at scale
- Enforcing immutability in review and with tooling
- Immutability for concurrency: lock-free sharing
- Copy-on-write at I/O and cache boundaries
- Migrating from a mutable to an immutable model
- Common Mistakes
- Test Yourself
- Cheat Sheet
- Summary
- Further Reading
- Related Topics
Immutability as an architectural choice¶
At junior scale, immutability is a per-object decision: make Money a value object, return a copy instead of the internal list. At team and system scale it becomes an architectural constraint that shapes how state flows through the whole system. The payoff is no longer "fewer bugs in this class" — it is reasoning at the boundary:
- A value you receive cannot change underneath you. No defensive copy on entry, no "did the caller keep a reference?" audit.
- Identity becomes structural. Two immutable values are interchangeable if equal; you can cache them, dedupe them, and use them as map keys freely.
- Concurrency stops being a per-field problem. An immutable object is thread-safe by construction — no locks, no
volatile, no happens-before reasoning per access. - State transitions become explicit and auditable. Change is a new value, so "what changed and when" is a first-class fact you can log, replay, and diff.
The cost is allocation pressure and a discipline tax: every mutation site becomes a "produce a new value" site, and your collections need structural sharing or you pay O(n) per update. The senior job is to decide where the immutable boundary sits: pure-immutable domain core, mutable shell at the I/O edge (parsing, batching, hot loops), with copy-on-write conversions at the seam. This is the "functional core, imperative shell" arrangement.
The boundary is the design. Inside the core, mutation is forbidden; outside, it is allowed but contained; at the seam, you convert deliberately and exactly once.
Immutable domain models, event sourcing, and CQRS read models¶
An immutable domain model and event sourcing reinforce each other. If state changes are produced by applying immutable events to immutable state, then the entire history is a fold:
apply(state, event) returns a new state. Nothing is mutated, so the same event stream always produces the same state — replay is deterministic, and you can rebuild any read model at any version.
Java — immutable aggregate with records and a pure apply:
public sealed interface AccountEvent
permits Opened, Deposited, Withdrawn {}
public record Opened(AccountId id, Instant at) implements AccountEvent {}
public record Deposited(Money amount, Instant at) implements AccountEvent {}
public record Withdrawn(Money amount, Instant at) implements AccountEvent {}
public record Account(AccountId id, Money balance, boolean open) {
public static Account replay(List<AccountEvent> events) {
Account acc = null;
for (AccountEvent e : events) acc = apply(acc, e);
return acc;
}
static Account apply(Account s, AccountEvent e) {
return switch (e) {
case Opened o -> new Account(o.id(), Money.ZERO, true);
case Deposited d -> s.withBalance(s.balance.plus(d.amount()));
case Withdrawn w -> s.withBalance(s.balance.minus(w.amount()));
};
}
// records have no `with`; generate one explicitly or use a builder
Account withBalance(Money b) { return new Account(id, b, open); }
}
Records give you equals/hashCode/toString and final fields for free. Because apply never mutates, the read model is just another fold over the same events — that is the whole of CQRS read-model construction.
Python — frozen dataclass aggregate, replace for transitions:
from dataclasses import dataclass, replace
from decimal import Decimal
@dataclass(frozen=True, slots=True)
class Account:
id: str
balance: Decimal
open: bool
def apply(state: Account | None, event: dict) -> Account:
match event["type"]:
case "Opened":
return Account(event["id"], Decimal(0), True)
case "Deposited":
return replace(state, balance=state.balance + event["amount"])
case "Withdrawn":
return replace(state, balance=state.balance - event["amount"])
raise ValueError(event["type"])
frozen=True blocks attribute assignment (raises FrozenInstanceError); slots=True removes __dict__ and shrinks the per-instance footprint, which matters when you hold millions of read-model rows in memory. replace() is the idiomatic "produce a new value with one field changed."
Go — value semantics + functional options for transitions:
type Account struct {
ID string
Balance int64 // minor units; never a float for money
Open bool
}
// apply returns a copy; Account is passed and returned by value.
func apply(s Account, e Event) Account {
switch e := e.(type) {
case Opened:
return Account{ID: e.ID, Open: true}
case Deposited:
s.Balance += e.Amount // mutates the *local copy*, not the caller's
return s
case Withdrawn:
s.Balance -= e.Amount
return s
}
return s
}
Go has no final and no frozen structs. The discipline is value semantics: pass and return Account by value, keep fields exported only if the package is trusted, and never hand out a pointer to internal state. The s.Balance += e.Amount line mutates a copy local to apply — the caller's value is untouched. This only holds while the struct contains no reference types (slices, maps, pointers); the moment it does, you need an explicit deep copy (see copy-on-write below).
CQRS read models are projections: separate, denormalized, read-optimized immutable structures rebuilt from events. Because they are derived, they are disposable — drop and rebuild on schema change. Snapshots (a materialized state_n plus the events after it) bound replay cost; the snapshot is itself an immutable value.
Immutable DTOs across service boundaries¶
A DTO that crosses a process boundary should be immutable for the same reason a value crossing a function boundary should be: the receiver must not be able to mutate it, and the sender must not see mutations the receiver makes. Across the network this is partly enforced by serialization (you get a fresh object on deserialize), but within a service the DTO is shared by reference, and a mutable DTO is a latent aliasing bug.
- Java: prefer
recordfor DTOs; use Lombok@Valueif you cannot use records (it makes the classfinal, fieldsprivate final, generatesequals/hashCode/toStringand getters with no setters). For wire types, generate from schema — Protobuf generated classes are immutable withBuilders; AvroSpecificRecordand Immutables (@Value.Immutable) give the same shape. Schema-first means the immutable DTO is the contract. - Python:
@dataclass(frozen=True)for internal DTOs; Pydantic v2 withmodel_config = ConfigDict(frozen=True)when you need validation + immutability at the boundary. For generated wire types usebetterproto(dataclass-based, frozen-friendly) or the standard protobuf runtime. - Go: unexported fields + a constructor + value receivers; expose data through getters or keep the struct in a package whose only writers are the constructors. Generated protobuf structs in Go are technically mutable — treat them as read-only after construction and never share a
*pb.Messageyou intend to keep.
// Java DTO with Immutables — immutable, builder, no setters
@Value.Immutable
public interface CreateOrderRequest {
String customerId();
List<LineItem> items(); // defensively copied into an ImmutableList
Optional<String> couponCode();
}
// Generated: ImmutableCreateOrderRequest.builder().customerId("c1")...build();
# Python boundary DTO with Pydantic v2 — validated AND frozen
from pydantic import BaseModel, ConfigDict
class CreateOrderRequest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
customer_id: str
items: tuple[LineItem, ...] # tuple, not list — immutable container
coupon_code: str | None = None
extra="forbid" is the boundary analog of mass-assignment protection: an attacker cannot inject extra fields. Note tuple[...] over list[...] — Pydantic frozen models still hand back the same mutable list object otherwise.
Schema evolution is where immutable DTOs earn their keep at scale: because the DTO is generated from a versioned .proto/.avsc/openapi.yaml, "add a field" is a compatible change, "change a type" is a breaking one, and the build catches consumers that depend on the old shape. The immutable contract is the API version.
Persistent-collection libraries at scale¶
Naive immutability copies the whole collection on every change: O(n) per update, which dies under load. Persistent (a.k.a. immutable, functional) collections use structural sharing — a new version shares unchanged subtrees with the old one, giving O(log n) updates and O(1)-ish reads while every version stays valid forever.
| Language | Library | Key types | Update cost | Notes |
|---|---|---|---|---|
| Java | Vavr | io.vavr.collection.List/Map/Set | O(log n) maps, O(1) prepend | Functional API (map, fold); also Tuple, Option, Either |
| Java | Guava | ImmutableList/Map/Set | copy-on-build (no shared updates) | Defensive immutability — cheap reads, but with-style update copies all |
| Java | Eclipse Collections | ImmutableList, primitive collections | O(n) update; huge memory wins on primitives | IntList avoids boxing — millions of ints without Integer overhead |
| Python | pyrsistent | PVector, PMap, PSet, PRecord | O(log n) | True structural sharing; pmap().set(k, v) returns a new map |
| Python | immutables | immutables.Map (HAMT) | O(log n) | The implementation behind contextvars; very fast .set() |
| JS/TS | Immer | plain objects via produce() | structural sharing of unchanged branches | Write "mutating" code in a draft; get a frozen result. Lowest adoption cost |
| JS/TS | Immutable.js | Map, List, Record | O(log n) | Heavier API; largely superseded by Immer for app code |
Choose by access pattern, not dogma:
- Read-mostly, built once (config, a snapshot, a response body): Guava
ImmutableList/ a frozen plain object is simplest and fastest to read. Structural sharing buys nothing if you never produce variants. - Update-heavy with many live versions (undo stacks, event-sourced state, time-travel): persistent collections (Vavr, pyrsistent, Immutable.js) — structural sharing is the whole point.
- Hot numeric paths: Eclipse Collections primitive collections to kill boxing, or stay mutable inside the imperative shell.
# pyrsistent — structural sharing; v1 is untouched by the update
from pyrsistent import pmap
v1 = pmap({"a": 1, "b": 2})
v2 = v1.set("b", 99) # O(log n), shares the "a" subtree
assert v1["b"] == 2 and v2["b"] == 99 # both versions valid forever
// Immer — "mutating" syntax, immutable result, structural sharing
import { produce } from "immer";
const next = produce(state, (draft) => {
draft.orders[id].status = "shipped"; // looks mutable; isn't
});
// `state` is unchanged; `next` shares every untouched branch with `state`.
Scale warning: persistent collections cost more per element (tree nodes, pointers) than arrays. For a 10-element list rebuilt rarely,
ImmutableList.copyOfbeats a HAMT. Profile before reaching for the fancy structure — the win is in update churn, not in immutability itself.
Enforcing immutability in review and with tooling¶
Immutability that is merely intended decays. At team scale you enforce it mechanically so a well-meaning teammate cannot add a setter in a hurry.
Java
- Records (Java 16+) for new value types — final by language rule.
- Lombok
@Valuefor pre-records code: final class, final fields, no setters. @Immutable+ Error Prone: Google's Error Prone ships an@Immutablechecker (fromcom.google.errorprone.annotations). Annotate a class@Immutableand the compiler fails the build if it has non-final fields or holds a mutable type without proof of immutability.- NullAway / Error Prone in the build catches the mutable-collection-field smell early.
@Immutable // Error Prone enforces this at compile time
public final class Money {
private final long minorUnits;
private final Currency currency;
Money(long m, Currency c) { this.minorUnits = m; this.currency = c; }
}
<!-- Maven: Error Prone with the Immutable checker enabled -->
<compilerArgs>
<arg>-XepOpt:Immutable:Disable=false</arg>
<arg>-Xplugin:ErrorProne</arg>
</compilerArgs>
Python
@dataclass(frozen=True)for runtime enforcement; mypy for static enforcement. Declare fields with immutable types (tuple,frozenset,Mapping) and mypy flags reassignment. Mark whole classes/fieldsFinal(from typing import Final).- Pydantic
frozen=Trueat boundaries. - A small custom AST / flake8 plugin can flag
def set_*methods on classes annotated as value objects, or list-typed fields on frozen dataclasses.
Go
- No language-level immutability. Enforce via unexported fields + constructor-only mutation, value receivers, and returning copies.
- Linters:
copylocks(vet) catches accidental copies of types with locks;exportloopvar/reviveand customgo/analysispasses can flag methods that mutate the receiver on a type meant to be a value. The honest answer for Go is convention + code review + a project-specific analyzer, not a built-in keyword.
Review heuristics (all languages):
- A new setter on a type that was a value object → reject; offer a
with*/replace. - A getter returning the internal slice/list/map directly → reject; return a copy or an immutable view.
- A constructor storing a passed-in mutable collection without copying → reject; defensive copy or accept an immutable type.
- A field typed
List/[]T/dicton a class advertised as immutable → requireImmutableList/tuple/Mapping. - "We'll make it immutable later" → it never happens; immutability is cheapest at creation.
Immutability for concurrency: lock-free sharing¶
The single biggest architectural reason to choose immutability at scale is safe sharing across threads with no synchronization. An immutable object has no writes after publication, so there is no data race to protect against — every reader sees a fully constructed, never-changing value.
- Java: an object whose fields are all
finaland which does not leakthisduring construction is safely published and thread-safe per the JMM (Java Memory Model) — nosynchronized, novolatileneeded. Share a singleImmutableMapconfig across all request threads; swap the reference atomically with anAtomicReference<ImmutableMap<...>>when config reloads. Readers never lock; the reference flip is the only synchronization point. - Go: an immutable value can be passed across goroutines and channels freely. The race detector (
go test -race) will catch any accidental shared mutation, which is exactly why a value-semantics discipline pays off — the moment someone shares amapfield,-racelights up. - Python: the GIL hides many races but does not make compound operations atomic; an immutable value sidesteps the question entirely, and
immutables.Map(HAMT) is the structure behindcontextvars, which is the canonical "per-task immutable snapshot" mechanism.
// Lock-free config: readers never block; updates swap the whole immutable map.
private final AtomicReference<ImmutableMap<String, String>> config =
new AtomicReference<>(ImmutableMap.of());
String get(String key) { return config.get().get(key); } // no lock
void reload(Map<String, String> fresh) { // no lock for readers
config.set(ImmutableMap.copyOf(fresh)); // atomic reference swap
}
This is copy-on-write at the reference level: writers build a new immutable snapshot and publish it atomically; readers always observe a consistent version. It scales because the read path has zero contention.
Copy-on-write at I/O and cache boundaries¶
The functional core wants immutability; I/O and caches want to avoid allocation. Copy-on-write (COW) is the seam discipline that reconciles them: share a read-only structure cheaply, and only allocate a copy when someone actually mutates.
- At the cache boundary: cache the immutable snapshot, not a mutable structure. A cached
ImmutableList/frozen object can be handed to every reader without defensive copying, because no reader can corrupt it. If a caller needs to modify, it copies-on-write into its own version, leaving the cached one intact. This is why immutable values make ideal cache entries — the cache never needs to clone onget. - At the I/O boundary: parse into a mutable buffer in the imperative shell (fast, allocation-light), then freeze once into an immutable value as it crosses into the core. On the way out, thaw the immutable value into a mutable buffer for serialization. Convert exactly once per crossing — not per field access.
// COW at a cache boundary: hand out the shared snapshot for reads;
// callers that need to mutate clone first.
type Snapshot struct{ items []Item } // treated as immutable after Build
func (s *Snapshot) Items() []Item { return s.items } // read-only contract
// Caller wanting to modify: copy-on-write into a private slice.
func withExtra(s *Snapshot, extra Item) *Snapshot {
next := make([]Item, len(s.items), len(s.items)+1)
copy(next, s.items) // the copy only happens on write
return &Snapshot{items: append(next, extra)}
}
The cost model: COW makes reads free and writes pay. It is the right default when reads vastly outnumber writes (config, reference data, query results). When writes dominate, COW's per-write copy is the bottleneck and a persistent collection's O(log n) structural sharing wins.
Migrating from a mutable to an immutable model¶
You rarely get to start immutable. The migration from a mutable god-model to immutable values is a multi-step, test-protected refactor — never a big-bang flip.
- Pin behavior first. Add characterization tests around the mutable model so you can detect any behavior change. Property-based tests are ideal here — assert round-trip and invariants that must survive the migration.
- Stop the bleeding. Make new fields final/frozen and forbid new setters in review. The model stops getting more mutable while you work.
- Make mutation explicit. Replace in-place setters with
with*/replacemethods that return a new instance, initially still backed by the mutable type. Callers migrate to the new style without the internals changing yet. (This is branch by abstraction: the immutable-style API is the abstraction; the mutable internals are the old implementation behind it.) - Freeze the leaves. Convert leaf value objects (
Money,Address, IDs) to immutable first — they have the fewest dependents and the clearest semantics. - Work inward. Convert aggregates that hold those leaves. Replace mutable collection fields with
ImmutableList/tuple/persistent collections. Add defensive copies at constructors as a bridge, then remove them once all callers pass immutable types. - Move the boundary. Push the freeze/thaw seam outward until the entire domain core is immutable and only the I/O shell mutates.
- Delete the bridges. Remove defensive copies and compatibility setters once nothing depends on them. Turn on
@Immutable/mypyFinal/the custom linter to lock the gains in.
Strangler-fig variant for services: when the mutable model spans services, route new write paths through an event-sourced (hence immutable) implementation while old paths keep mutating the legacy store. Project both into the read model. Retire the legacy write path once traffic has moved.
Common Mistakes¶
- Shallow freeze over a deep mutable graph. A frozen dataclass holding a plain
list, arecordholding ajava.util.ArrayList, or a Go struct holding amap— the wrapper is immutable but the contents are not.Object.freezein JS is one level deep too. Freeze transitively or use immutable containers all the way down. - Trusting
frozen=True/ records to deep-copy. They do not. A frozen dataclass stores the same list object you passed; mutate it elsewhere and the "immutable" value changes. Usetuple/frozenset/Mapping, or copy in__post_init__viaobject.__setattr__. - Returning the internal collection from a getter. The classic escape hatch — the object is immutable but its getter leaks a live reference to internal state. Return a copy or an immutable view.
- Reaching for persistent collections everywhere. Structural sharing has per-node overhead; for small, build-once collections it is slower and heavier than a plain array or
ImmutableList.copyOf. Match the structure to the update churn. - Treating generated protobuf/Avro structs in Go as immutable. They are mutable pointers. Sharing a
*pb.Messageyou intend to keep is an aliasing bug the type system will not catch. AtomicReferenceto a mutable object. The atomic swap is pointless if the pointed-to object is then mutated by readers. The value behind the reference must itself be immutable.- Immutability as religion in hot loops. Allocating a new value per iteration in a tight numeric loop can dominate runtime and thrash the GC. The imperative shell exists precisely so the hot path can mutate locally; keep the immutable boundary outside it.
- Migrating big-bang. Flipping a large mutable model to immutable in one PR breaks callers en masse and has no rollback. Branch by abstraction and convert leaf-first.
Test Yourself¶
- Why does an all-
final-fields Java object not needsynchronizedto be shared across threads?
Answer
The Java Memory Model guarantees that `final` fields are *safely published*: once the constructor completes without leaking `this`, every thread that observes a reference to the object sees the fully initialized final fields, with correct happens-before ordering, even without synchronization. Because the object never changes after construction, there are no writes to race with reads — so no lock, `volatile`, or atomic is needed on the read path. (This breaks if the constructor leaks `this` before completing, or if a field is a mutable object that is later mutated.)- When is
Guava ImmutableListthe wrong choice and a persistent collection the right one?
Answer
`ImmutableList` is *defensively* immutable: cheap to read, but producing a variant copies the whole list (O(n)). It shines for read-mostly, built-once data. When you maintain many live versions with frequent updates — undo stacks, event-sourced state, time-travel debugging — Guava's copy-per-update is O(n) churn. A persistent collection (Vavr, pyrsistent, Immutable.js) uses structural sharing for O(log n) updates while keeping every prior version valid, which is exactly that workload.- A teammate makes a Python DTO a
frozen=Truedataclass but a field islist[Item]. What is still broken, and how do you fix it?
Answer
`frozen=True` only blocks *reassigning the attribute* (`dto.items = [...]` raises). It does not stop `dto.items.append(x)` — the field holds a live, mutable `list`, and the dataclass stored the very object the caller passed in (no copy). The value can change underneath everyone. Fix: type the field as `tuple[Item, ...]` (immutable container), or copy into a tuple in `__post_init__` using `object.__setattr__` to bypass the frozen guard during construction.- How does event sourcing make immutability "free," and what is the cost you pay instead?
Answer
State is a fold over immutable events: `state_n = events.fold(initial, apply)`, where `apply` returns a new state. Nothing is mutated, replay is deterministic, and read models are just additional folds over the same events — so immutability falls out of the architecture rather than being bolted on. The cost: storage grows with history, replay cost grows without snapshots, you need an upcasting/versioning strategy for old event shapes, and reads require projecting events into read models rather than querying current state directly.- Why is
AtomicReference<ImmutableMap<K,V>>a good lock-free config pattern, and where could it still go wrong?
Answer
Readers call `config.get().get(key)` with no lock — the reference points to an immutable snapshot that cannot change, so reads never contend. A reload builds a *new* `ImmutableMap` and publishes it with a single atomic `set`, so readers always observe one consistent version (the old or the new, never a torn mix). It goes wrong if (a) the map is not actually immutable — readers mutate it and corrupt the shared snapshot; (b) callers do read-modify-write across two `get`s expecting atomicity (use `updateAndGet`/`compareAndSet` instead); or (c) values inside the map are themselves mutable objects that get mutated after publication.- You must migrate a 4,000-line mutable
Orderaggregate to immutable. Outline the first three steps.
Answer
(1) Add characterization and property-based tests around the current behavior so any regression is caught — round-trip, invariants, and representative scenarios. (2) Freeze the bleeding: forbid new setters/new mutable fields in review so the model stops getting worse while you work. (3) Branch by abstraction — replace in-place setters with `with*`/`replace` methods that return a new instance, initially still backed by the mutable internals, and migrate callers to the new API. Then convert leaf value objects first and work inward. Never big-bang.Cheat Sheet¶
| Concern | Java | Python | Go |
|---|---|---|---|
| Value type | record, Lombok @Value, Immutables | @dataclass(frozen=True, slots=True) | struct + unexported fields + constructor |
| Boundary DTO | record / generated protobuf builders | Pydantic ConfigDict(frozen=True, extra="forbid") | unexported fields; treat pb structs read-only |
| Immutable list | Guava ImmutableList, Vavr List | tuple, pyrsistent PVector | return copies; never leak internal slice |
| Immutable map (shared updates) | Vavr Map (HAMT) | pyrsistent PMap, immutables.Map | copy-on-write map; no built-in |
| Transition (one field) | builder / explicit with* | dataclasses.replace() | return modified copy (value receiver) |
| Compile/static enforcement | Error Prone @Immutable, final | mypy Final, frozen dataclass | go vet copylocks, custom go/analysis |
| Lock-free sharing | AtomicReference<Immutable…> | immutables.Map / contextvars | value across channels; -race to verify |
| JS/TS | — | — | Immer produce() (lowest-cost adoption) |
Boundary rule of thumb: immutable domain core, mutable imperative shell, copy-on-write at the seam, freeze/thaw exactly once per I/O crossing.
Library choice: read-mostly built-once → defensive immutable (Guava / frozen object). Update-heavy with many versions → persistent collection (structural sharing). Hot numeric → primitive collections or stay mutable inside the shell.
Summary¶
At senior scale, immutability is an architectural boundary, not a per-object tactic. You decide where the immutable core ends and the mutable I/O shell begins, then enforce that line with tooling — Error Prone @Immutable, mypy Final, frozen dataclasses, custom analyzers — and in code review, because intent-only immutability decays. Immutable domain models compose naturally with event sourcing and CQRS: state is a deterministic fold over immutable events, and read models are disposable projections. Across service boundaries, immutable DTOs generated from versioned schemas make the contract and the immutability the same artifact. Persistent-collection libraries (Vavr, pyrsistent, immutables, Immer) buy O(log n) updates with structural sharing when update churn is high; defensive immutables (Guava, frozen objects) win when data is read-mostly. The architectural payoff is lock-free sharing — an immutable value is thread-safe by construction, so config and snapshots can be shared via an atomic reference swap with zero reader contention. Copy-on-write reconciles the immutable core with allocation-sensitive caches and I/O. And the path from a mutable model is always incremental: pin behavior with tests, branch by abstraction, convert leaf-first, push the seam outward, then lock the gains with the linter.
Further Reading¶
- Eric Evans — Domain-Driven Design (value objects, aggregates as the unit of consistency).
- Vaughn Vernon — Implementing Domain-Driven Design (event sourcing, CQRS, aggregate design at scale).
- Brian Goetz et al. — Java Concurrency in Practice, ch. 3 (safe publication, immutability and the Java Memory Model).
- Martin Fowler — "Event Sourcing", "CQRS", and "Branch By Abstraction" (martinfowler.com).
- Greg Young — talks and notes on CQRS / Event Sourcing.
- Vavr, Guava, Eclipse Collections, pyrsistent,
immutables, and Immer official documentation (API and performance characteristics). - Google Error Prone — the
@Immutablechecker documentation. - Rich Hickey — "The Value of Values" and "Are We There Yet?" (persistent data structures and structural sharing).
Related Topics¶
- junior.md — what immutability is and the per-object basics.
- middle.md — defensive copies, value objects, immutable collections in one codebase.
- professional.md — allocation cost, GC behavior, and the measured price of immutability.
- Chapter README — the positive rules of this chapter.
- Concurrency — why immutable values are the simplest path to thread safety.
- Objects and Data Structures — encapsulation, getters, and not leaking internal state.
- Functional Programming — pure functions and persistent data structures.
- Anti-Patterns — the aliasing and shared-mutable-state smells immutability removes.
In this topic
- junior
- middle
- senior
- professional