A value that cannot be changed cannot be changed behind your back either: a whole class of “who else holds this reference” questions disappears, and sharing across threads no longer requires locks. The price is copying on change, and it is paid at the boundary: a collection a constructor accepts gets copied, otherwise the caller keeps a lever on your state.
The idea is old in functional programming and was brought to mainstream object-oriented code by Joshua Bloch’s Effective Java (“Minimize mutability”: a class should be immutable unless there is a very good reason to make it mutable). The data-structure side — sharing structure between versions instead of copying everything — goes back to Driscoll, Sarnak, Sleator and Tarjan’s “Making Data Structures Persistent” and to Okasaki’s Purely Functional Data Structures; it is why Clojure or Scala can “copy” a million-element map by allocating a few nodes. Pat Helland’s “Immutability Changes Everything” (CIDR 2015) shows the same idea at the scale of storage systems.
// record + List.copyOf; collections via immutable factories
public record Portfolio(String owner, List<Position> positions) {
public Portfolio { positions = List.copyOf(positions); } // defensive copy
public Portfolio withPosition(Position p) {
var next = new ArrayList<>(positions); next.add(p);
return new Portfolio(owner, next);
}
}// immutability is the language default; mut is the explicit exception
let portfolio = Portfolio::new(owner, positions); // can't be changed without mut
// shared state: Arc<Portfolio> is cheap and safe precisely because
// its contents are immutable// the language has no immutability; by convention small value types are copied,
// and slices are copied explicitly in constructors
func NewPortfolio(owner string, ps []Position) Portfolio {
return Portfolio{owner: owner, positions: slices.Clone(ps)}
}# frozen dataclass + tuple instead of list
@dataclass(frozen=True, slots=True)
class Portfolio:
owner: str
positions: tuple[Position, ...]
def with_position(self, p: Position) -> "Portfolio":
return replace(self, positions=(*self.positions, p))In data infrastructure it is the default already. A Kafka log is append-only; a Parquet file in an Iceberg or Delta table is never rewritten in place — a commit writes new files and atomically swaps a pointer to a new snapshot. That is what makes time travel, concurrent readers without locks and a cheap rollback possible: the old snapshot simply still exists. Mutation moves to one place (the commit of the pointer) instead of being spread over every file.
Where it stops paying. Shallow immutability is a common false sense of safety: List.copyOf copies the list, not the Position objects in it, and a frozen dataclass holding a list is still mutable through that list. In hot loops over large arrays, copy-on-change costs allocation and GC pressure that data-oriented layouts (10) avoid by mutating in place. And Rust shows that the real hazard is shared mutation, not mutation as such: an exclusive &mut is safe because nobody else can see the change. The rule is “immutable when shared”, and the default leans that way because sharing is hard to see.