skip to content

You're designing a public Kotlin API. How do you use the read-only/mutable collection model to express intent and protect invariants, given that read-only types aren't truly immutable?

level: seniorimportance: should knowfreq 50%

answer

  1. Default signatures to read-only List/Set/Map
  2. Read-only is a view: copy to guarantee
  3. toList() on entry and exit for snapshots
  4. kotlinx persistent collections for cheap shared immutability
  5. buildList builds mutably, returns read-only

basics

~10 s

Accept and return read-only types (List, Set, Map) so callers can't change your data, and copy incoming or outgoing collections when you need a real guarantee. Use mutable types only inside your implementation.

solid answer

~40 s

Default every parameter and return type to the **read-only** interface (`List`, `Set`, `Map`) to communicate 'I won't mutate this' / 'don't mutate this'. But remember read-only is a *view*: a returned `List` may be the same object you mutate internally, and an accepted `List` may be a `MutableList` the caller keeps mutating. So when invariants matter, take **defensive copies**: `param.toList()` on entry, return `internal.toList()` (or a precomputed snapshot). For shared mutable state, prefer `kotlinx.collections.immutable` (`PersistentList`, `toPersistentList()`) which is genuinely immutable and structurally shared, avoiding full copies. Keep `Mutable*` types and `buildList { }` strictly inside implementation bodies; never leak them across module boundaries. This pairs with Spring Modulith / API-surface discipline: expose read-only contracts, hide mutability.

code

kotlin · 10 lines
kotlin
import kotlinx.collections.immutable.*

class Registry {
    private var entries: PersistentList<String> = persistentListOf()
    // genuinely immutable snapshot, O(1) to hand out, no copy
    fun snapshot(): List<String> = entries
    fun register(name: String) {
        entries = entries.add(name)   // returns a new shared structure
    }
}

go deeper

for a junior

Knows to prefer List over MutableList in public signatures.

for a middle

Understands defensive copies and that read-only doesn't stop the backing object from changing.

for a senior

Designs boundaries with snapshots, knows persistent collections and their structural-sharing benefits, handles Java interop.

for a principal

Establishes org-wide conventions: read-only at boundaries, immutability strategy (copy vs persistent), state-holder patterns, and enforcement via architecture tests.

## Read-only types as intent Kotlin lets your signatures *document* mutability: - `fun process(items: List<Item>)` says the function won't add/remove (it can't, through that type). - `fun result(): List<Item>` says callers get something they shouldn't mutate. This is stronger than Java, where everything is `java.util.List` and mutability is ambiguous. ## The leak: read-only is a view, not a copy Two failure modes: 1. **Returning your backing list.** If you `return internalMutableList` upcast to `List`, the caller holds a window onto live state; later internal mutations change what they see. 2. **Storing a caller's list.** If you `this.items = incoming` where `incoming` is a `MutableList`, the caller can mutate your invariant out from under you. ```kotlin class Cart { private val _items = mutableListOf<Item>() // GOOD: hand out a snapshot, not the live list val items: List<Item> get() = _items.toList() fun add(i: Item) { _items += i } } ``` ## Defensive copying - On entry: `val safe = incoming.toList()` (independent snapshot). - On exit: `return _items.toList()` or expose a precomputed unmodifiable snapshot. - Cost: O(n) copy each time. For hot paths or large data this is wasteful. ## Persistent collections for shared state `org.jetbrains.kotlinx:kotlinx-collections-immutable` provides `PersistentList`, `PersistentMap`, `ImmutableList`. They are truly immutable; 'mutations' (`add`, `put`) return a new instance that **structurally shares** most of the old one, so updates are cheap and snapshots are free. Ideal for state holders (e.g. exposed via `StateFlow<PersistentList<T>>`). ```kotlin import kotlinx.collections.immutable.* var state: PersistentList<Item> = persistentListOf() state = state.add(item) // new instance, old one untouched ``` ## buildList / build* for assembly Use `buildList { add(...); addAll(...) }` to construct a result with a mutable receiver but return a read-only `List` — keeps mutation local and the public type clean. ## Boundary discipline - Public API: read-only types only. - Implementation internals: `Mutable*`, builders, persistent collections. - Never expose `MutableList`/`MutableMap` across a module or library boundary; it invites accidental mutation and breaks encapsulation (and ArchUnit/Modulith-style API checks). ## Java interop A Java method's `List` is a Kotlin platform type and may be mutable; copy at the boundary if you depend on stability. `listOf()` results passed to Java may throw `UnsupportedOperationException` if mutated.

  • When is toList() insufficient and you need persistent collections?
    When you hand out snapshots frequently or hold large shared state: toList() copies O(n) each time. Persistent collections give free immutable snapshots and cheap structural-sharing updates.
  • Why avoid returning a MutableList even if it's convenient?
    It leaks internal mutable state across the boundary; callers can corrupt your invariants and you can't change the representation later. It also defeats read-only intent and module API checks.

A read-only type is a 'do not touch' sign; a defensive copy or persistent collection is the locked display case that actually enforces it.

saying these in an interview costs you the question

  • Returning the internal MutableList directly upcast to List
  • Assuming a returned List is safe from internal mutation
  • Storing a caller-supplied MutableList without copying
  • Copying with toList() in hot paths over huge data without considering cost
  • Exposing MutableMap/MutableList across a public/module boundary

context