skip to content

What are the design limitations of relying on data class copy() for immutable updates at scale, and what alternatives or mitigations would you consider?

level: principalimportance: nice to knowfreq 35%

answer

  1. Deep nesting -> stacked copy() (verbose)
  2. Arrow Optics lenses/prisms for deep paths
  3. Normalize/flatten state (Map by id)
  4. Persistent collections for big collections
  5. init require() or private ctor for invariants

basics

~20 s

copy() is great for small, flat objects but gets painful for deep or large state: lots of nested copy() calls, only-public-constructor updates, shallow copying, and allocation cost. For big trees, use lens libraries, restructure state, or normalize it.

solid answer

~40 s

copy()'s limits at scale: (1) deep nesting forces stacked copy() calls per update, which is verbose and error-prone; (2) it is shallow, so nested mutable types must be kept immutable or aliasing bugs appear; (3) it operates over primary-constructor properties only, so encapsulated/validated invariants can be bypassed (any caller can copy() around them unless you restrict construction); (4) allocation per transition costs memory/GC, though structural sharing reuses unchanged branches. Mitigations: Arrow Optics lenses/prisms to update deep paths concisely; normalize/flatten state (store entities by id in maps) to reduce nesting; extract update helper functions; for validated types prefer a private constructor + factory and validate in the copy boundary, or avoid data class if invariants must hold; consider persistent collections (kotlinx.collections.immutable) for efficient large-collection updates. Choose per state shape, not dogmatically.

code

kotlin · 7 lines
kotlin
// init validation so even copy() must satisfy the invariant
data class DateRange(val start: Int, val end: Int) {
    init { require(end >= start) { "end must be >= start" } }
}

val r = DateRange(1, 5)
// r.copy(end = 0)  // throws IllegalArgumentException at construction

go deeper

for a junior

Knows copy() exists; not expected to discuss scaling limits.

for a middle

Recognizes deep-nesting verbosity and shallow-copy issues and can apply nested copy() correctly.

for a senior

Names mitigations (lenses, normalization, persistent collections) and the invariant-via-init pattern.

for a principal

Chooses state architecture per shape, weighs ergonomics vs allocation, and governs invariants and library choices across teams.

## Where copy() shines vs strains `copy()` is the right tool for **small, flat, immutable** value types. At scale several limitations surface. ### 1. Deep-update verbosity Updating a leaf deep in a tree means nesting `copy()` at every level: ```kotlin state.copy( user = state.user.copy( profile = state.user.profile.copy( address = state.user.profile.address.copy(city = "Paris") ) ) ) ``` This is noisy and easy to get wrong. **Mitigation:** **Arrow Optics** generates **lenses** (focus on a nested field) and **prisms** (focus into a sealed-type branch) so you write `User.profile.address.city.set(state, "Paris")`. Or **flatten/normalize** state — e.g., keep entities in a `Map<Id, Entity>` so updates are one level deep. ### 2. Shallow copy `copy()` copies references, not nested objects. Any **mutable** nested type (`MutableList`, `var` fields) becomes a shared-aliasing hazard. **Mitigation:** keep the entire reachable graph immutable; use read-only `List`/`Map` or **persistent collections** from `kotlinx.collections.immutable` (`PersistentList`), which give efficient `add`/`remove` returning new collections with structural sharing. ### 3. Invariant / encapsulation bypass `copy()` is public and reconstructs through the primary constructor's properties, but it **does not re-run constructor `init` validation guarantees you might expect** beyond constructing a valid instance — and more importantly, any caller can `copy()` an instance into a new combination of values, sidestepping intended construction paths. For types with **invariants** (e.g., "end >= start"), a `data class` exposes `copy()` that callers can use to build states your factory would have rejected. **Mitigation:** put validation in `init { require(...) }` so even copy() must pass it; or make the constructor `private` with a validating factory and **don't** rely on `data class` if you must forbid arbitrary copies (a regular class with controlled `with`-style methods). ### 4. Allocation / GC pressure Every transition allocates a new object (and new nested objects along the changed path). For high-frequency updates or very large states this adds GC pressure. **Mitigation:** structural sharing already reuses unchanged branches; avoid copying in hot loops; batch updates; use persistent collections for large collections. ## Choosing an approach - Small/flat value: plain `copy()`. - Deep tree, frequent nested updates: **Arrow Optics** lenses or **normalize** the state shape. - Large collections: **persistent collections** (`kotlinx.collections.immutable`). - Strict invariants: validate in `init`, or use a controlled non-data class. ## Key point copy() is a building block, not an architecture. At scale, the state's **shape** and the **update ergonomics** (lenses, normalization, persistent collections) matter more than copy() itself.

  • How do you keep a data class invariant safe given copy() is public?
    Put the check in init { require(...) }; copy() constructs through the primary constructor, so the invariant is re-validated on every copy.
  • What does normalizing state buy you over deeply nested data classes?
    Updates become one level deep (replace an entry in a Map by id), drastically reducing nested copy() calls and making sharing/diffing cheaper.
  • When would you NOT use a data class for immutable state?
    When you must forbid arbitrary value combinations that copy() would allow — use a controlled class with a private constructor/factory and explicit update methods.

copy() is a hand screwdriver: perfect for a few screws, but for assembling a whole building you bring power tools (lenses) and a better blueprint (normalized state).

saying these in an interview costs you the question

  • Treating copy() as a complete state-management solution at any scale
  • Ignoring that copy() lets callers bypass intended construction paths
  • Not knowing init require() runs on copy()
  • Unaware of Arrow Optics or persistent collections as mitigations
  • Claiming immutable updates are free of allocation cost

context