Files
amethyst/.claude/skills/kotlin-expert/references/immutability-patterns.md
T
2025-12-30 15:18:48 +02:00

14 KiB

Immutability Patterns

@Immutable annotation, data classes, and immutable collections for Compose performance.

Table of Contents


Why Immutability Matters

Compose Recomposition

Mental model: Compose tracks state changes by comparing references. If an @Immutable object reference doesn't change, Compose skips recomposition.

// Without @Immutable - Recomposes on every parent recomposition
data class User(val name: String, val age: Int)

@Composable
fun UserCard(user: User) {  // Recomposes unnecessarily
    Text(user.name)
}

// With @Immutable - Only recomposes when user reference changes
@Immutable
data class User(val name: String, val age: Int)

@Composable
fun UserCard(user: User) {  // Smart recomposition
    Text(user.name)
}

Performance difference:

  • Without @Immutable: 1000 UserCard recompositions per screen update
  • With @Immutable: 10 UserCard recompositions (only changed users)

Thread Safety

Immutable objects are inherently thread-safe:

@Immutable
data class Event(
    val id: String,
    val content: String,
    val createdAt: Long
)

// Safe to share across coroutines without synchronization
val sharedEvent: Event = fetchEvent()
launch { processEvent(sharedEvent) }  // Safe
launch { saveEvent(sharedEvent) }     // Safe

@Immutable Annotation

Basic Usage

Pattern from Amethyst:

// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
    id: HexKey,
    pubKey: HexKey,
    createdAt: Long,
    tags: Array<Array<String>>,
    content: String,
    sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
    // All properties are val (immutable)
    // No var properties
    // No mutable collections
}

Requirements for @Immutable:

  1. All properties must be val (no var)
  2. All property types must be immutable or primitives
  3. No mutable collections (MutableList, MutableMap)
  4. Arrays are allowed (treated as immutable by contract)
  5. No public mutable state

@Immutable vs @Stable

@Immutable: Value never changes after construction

@Immutable
data class User(val name: String, val age: Int)
// Once created, user.name and user.age never change

@Stable: Value can change, but changes are tracked

@Stable
class MutableCounter {
    var count by mutableStateOf(0)  // Changes tracked by Compose
}

Amethyst uses @Immutable extensively:

  • 173+ event classes annotated with @Immutable
  • All Nostr events immutable by design
  • Critical for feed performance (thousands of events)

Data Classes

Immutable Data Classes

Pattern:

@Immutable
data class RelayStatus(
    val url: NormalizedRelayUrl,
    val connected: Boolean,
    val error: String? = null,
    val messageCount: Int = 0
) {
    // Immutable properties only (val)
    // Default values allowed
}

Benefits:

  1. Structural equality: equals() compares values, not references
  2. copy(): Create modified copies without mutation
  3. toString(): Debugging-friendly output
  4. hashCode(): Consistent hashing for collections
  5. componentN(): Destructuring support

copy() for Updates

Mental model: Instead of mutating, create modified copies.

val status = RelayStatus(
    url = "wss://relay.damus.io",
    connected = false,
    error = null
)

// Immutable update
val updatedStatus = status.copy(connected = true)

// Original unchanged
assert(status.connected == false)
assert(updatedStatus.connected == true)

StateFlow pattern:

private val _relayStatuses = MutableStateFlow<Map<String, RelayStatus>>(emptyMap())

fun updateRelay(url: String, connected: Boolean) {
    _relayStatuses.value = _relayStatuses.value.mapValues { (key, status) ->
        if (key == url) {
            status.copy(connected = connected)  // Immutable update
        } else {
            status
        }
    }
}

All Properties in Constructor

Why important for data classes:

// BAD: Properties outside constructor not included in equals/hashCode
data class User(val name: String) {
    var age: Int = 0  // NOT in equals/hashCode/copy!
}

val user1 = User("Alice")
val user2 = User("Alice")
user1.age = 25
user2.age = 30

assert(user1 == user2)  // TRUE! age not compared
assert(user1.copy() == user1)  // TRUE! age not copied

// GOOD: All properties in constructor
@Immutable
data class User(
    val name: String,
    val age: Int  // Included in equals/hashCode/copy
)

Immutable Collections

kotlinx.collections.immutable

Installation:

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}

Why use:

  • Structural sharing (efficient copies)
  • Explicit immutability (compiler enforced)
  • Safe for Compose state

ImmutableList

import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList

// Create immutable list
val relays: ImmutableList<String> = persistentListOf(
    "wss://relay1.com",
    "wss://relay2.com"
)

// Add returns NEW list
val updated = relays.add("wss://relay3.com")
assert(relays.size == 2)      // Original unchanged
assert(updated.size == 3)     // New list has 3 items

// Convert from regular list
val mutableList = mutableListOf("a", "b", "c")
val immutable = mutableList.toImmutableList()

ImmutableMap

import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap

// Create immutable map
val relayStatuses: ImmutableMap<String, RelayStatus> = persistentMapOf(
    "wss://relay1.com" to RelayStatus(...),
    "wss://relay2.com" to RelayStatus(...)
)

// Put returns NEW map
val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...))

// Remove returns NEW map
val removed = relayStatuses.remove("wss://relay1.com")

ImmutableSet

import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf

val connectedRelays: ImmutableSet<String> = persistentSetOf(
    "wss://relay1.com",
    "wss://relay2.com"
)

val updated = connectedRelays.add("wss://relay3.com")

Structural Sharing

Mental model: Immutable collections reuse internal structure for efficiency.

val list1 = persistentListOf(1, 2, 3, 4, 5)  // 5 items
val list2 = list1.add(6)                     // Shares structure with list1

// Internally:
// list1 and list2 share nodes for items 1-5
// list2 has one additional node for item 6
// O(1) time, O(1) space for add operation

Common Patterns

Pattern: Immutable State Updates

@Immutable
data class FeedState(
    val events: ImmutableList<Event>,
    val loading: Boolean,
    val error: String?
)

class FeedViewModel {
    private val _state = MutableStateFlow(
        FeedState(
            events = persistentListOf(),
            loading = false,
            error = null
        )
    )
    val state: StateFlow<FeedState> = _state.asStateFlow()

    fun loadEvents() {
        _state.value = _state.value.copy(loading = true, error = null)

        viewModelScope.launch {
            try {
                val events = repository.getEvents()
                _state.value = _state.value.copy(
                    events = events.toImmutableList(),
                    loading = false
                )
            } catch (e: Exception) {
                _state.value = _state.value.copy(
                    loading = false,
                    error = e.message
                )
            }
        }
    }

    fun addEvent(event: Event) {
        _state.value = _state.value.copy(
            events = _state.value.events.add(event)  // Immutable add
        )
    }

    fun removeEvent(eventId: String) {
        _state.value = _state.value.copy(
            events = _state.value.events.filter { it.id != eventId }.toImmutableList()
        )
    }
}

Pattern: Deep Immutability

// Nested immutable structures
@Immutable
data class User(
    val name: String,
    val profile: Profile  // Also immutable
)

@Immutable
data class Profile(
    val bio: String,
    val avatar: String,
    val relays: ImmutableList<String>  // Immutable collection
)

// Safe deep copy
val user = User(
    name = "Alice",
    profile = Profile(
        bio = "Nostr enthusiast",
        avatar = "https://...",
        relays = persistentListOf("wss://relay1.com")
    )
)

val updatedUser = user.copy(
    profile = user.profile.copy(
        bio = "Bitcoin & Nostr enthusiast"  // Deep update
    )
)

Pattern: Collection Builder to Immutable

// Build mutable, convert to immutable
fun processEvents(input: List<Event>): ImmutableList<Event> {
    val processed = mutableListOf<Event>()

    for (event in input) {
        if (event.isValid()) {
            processed.add(event.normalize())
        }
    }

    return processed.toImmutableList()  // Convert once at end
}

Pattern: Immutable Map Updates

private val _relayStatuses = MutableStateFlow<ImmutableMap<String, RelayStatus>>(
    persistentMapOf()
)

fun updateRelay(url: String, connected: Boolean) {
    val currentStatuses = _relayStatuses.value
    val currentStatus = currentStatuses[url] ?: RelayStatus(url, false)

    _relayStatuses.value = currentStatuses.put(
        url,
        currentStatus.copy(connected = connected)
    )
}

fun removeRelay(url: String) {
    _relayStatuses.value = _relayStatuses.value.remove(url)
}

Performance Impact

Benchmarks (Approximate)

Recomposition cost:

// 1000 items in LazyColumn
// Without @Immutable: ~100ms per frame (skipped frames)
// With @Immutable: ~16ms per frame (smooth 60fps)

@Immutable
data class Item(val id: String, val name: String)

@Composable
fun ItemList(items: ImmutableList<Item>) {
    LazyColumn {
        items(items, key = { it.id }) { item ->
            ItemRow(item)  // Only recomposes when item changes
        }
    }
}

Structural sharing efficiency:

val list1 = persistentListOf(1..10000)
val list2 = list1.add(10001)  // O(log n) time, shares structure

// Regular list (copy on modification):
val mutableList = (1..10000).toMutableList()
val copy = mutableList.toList() + 10001  // O(n) time, full copy

When to Use Immutable Collections

Use ImmutableList/Map/Set when:

  • Storing in Compose state (@Immutable class)
  • Sharing across coroutines
  • Frequent modifications (structural sharing efficient)
  • Need compile-time immutability guarantee

Use Array when:

  • Fixed size, no modifications
  • Nostr protocol (tags are Array<Array<String>>)
  • Performance-critical (array access is fastest)

Use regular List/Map/Set when:

  • Local scope only
  • Build once, read many times
  • Converting to immutable at boundary

Anti-Patterns

Mutable Properties in @Immutable Class

@Immutable
data class BadEvent(
    val id: String,
    var content: String  // BAD: var breaks immutability
)

All val Properties

@Immutable
data class GoodEvent(
    val id: String,
    val content: String
)

Mutable Collections in @Immutable Class

@Immutable
data class BadState(
    val items: MutableList<Item>  // BAD: Can mutate items
)

// Caller can mutate:
val state = BadState(mutableListOf())
state.items.add(newItem)  // Breaks immutability!

Immutable Collections

@Immutable
data class GoodState(
    val items: ImmutableList<Item>
)

// Caller must create new state:
val updated = state.copy(items = state.items.add(newItem))

Direct Mutation

val status = RelayStatus(url, connected = false)
status.connected = true  // Compile error (val)

// But could happen with mutable nested objects:
@Immutable
data class Config(
    val settings: Settings  // If Settings is mutable...
)

class Settings {
    var theme: String = "dark"  // BAD
}

val config = Config(Settings())
config.settings.theme = "light"  // Mutates "immutable" config!

Deep Immutability

@Immutable
data class Config(
    val settings: Settings
)

@Immutable
data class Settings(
    val theme: String  // val only
)

val config = Config(Settings("dark"))
val updated = config.copy(
    settings = config.settings.copy(theme = "light")
)

Exposing Mutable Internal State

@Immutable
class BadViewModel {
    private val _items = mutableListOf<Item>()
    val items: List<Item> = _items  // BAD: Exposes mutable list

    fun addItem(item: Item) {
        _items.add(item)
    }
}

// Caller can cast and mutate:
val vm = BadViewModel()
(vm.items as MutableList).clear()  // Breaks encapsulation!

Convert to Immutable at Boundary

@Immutable
class GoodViewModel {
    private val _items = mutableListOf<Item>()
    val items: ImmutableList<Item>
        get() = _items.toImmutableList()  // GOOD: Copy to immutable

    fun addItem(item: Item) {
        _items.add(item)
    }
}

Checklist for Immutability

For @Immutable classes:

  • All properties are val, never var
  • No mutable collections (MutableList, MutableMap, MutableSet)
  • Nested objects are also @Immutable or primitives
  • No public mutable state
  • Use copy() for updates, never mutation
  • Arrays used only when truly immutable by contract

For StateFlow state:

  • State class is @Immutable
  • Use immutable collections (ImmutableList, ImmutableMap)
  • Create new instances for updates (copy(), .add(), .put())
  • Never mutate state in-place

For Compose performance:

  • All @Composable parameters are @Immutable or @Stable
  • Lists use ImmutableList and key parameter in items()
  • Heavy objects (events, profiles) cached and reused

References