Kotlin StateMachine is a Kotlin/JVM 11 library for modeling features as
immutable Mealy state machines. A machine owns one serialized event mailbox;
collecting its Flow observes committed states and never executes transitions
or outputs.
The runtime takes the same functional approach as SwiftStateMachine, without the experimental Composite state-machine APIs.
statemachine: immutable DSL, hot runtime, output supervision, mediator, and structured logging.statemachine-compose: lifecycle-aware Compose controller and preview fake.statemachine-debug: deterministic JUnit helpers.statemachine-dump/statemachine-dump-no-op: debug state export and its release-build counterpart.consumer-smoke: compiled downstream-consumer fixture.benchmarks: serialized-mailbox throughput baseline.
sealed class FeatureState : State<Boolean>
data object Idle : FeatureState() {
override val superState = false
}
data class Loading(val id: Int) : FeatureState() {
override val superState = false
}
data class Loaded(val value: String) : FeatureState() {
override val superState = true
}
sealed interface FeatureEvent
data class Load(val id: Int) : FeatureEvent
data class LoadedValue(val value: String) : FeatureEvent
val machine = StateMachineFlow<FeatureState, FeatureEvent>(
initial = Idle,
scope = viewModelScope,
logger = StateMachineLogger { record -> analytics.record(record) },
) {
When(state = Idle::class) {
On(event = Load::class) { _, event ->
Transition(Loading(event.id))
Output(
context = Dispatchers.IO,
sideEffect = { LoadedValue(repository.load(event.id)) },
lifecycle = Cancel(onEvent = Load::class),
)
}
}
When(state = Loading::class) {
On(event = LoadedValue::class) { _, event ->
Transition(Loaded(event.value))
}
}
}Sending is independent of observation:
machine.trySend(Load(42)) // bounded, non-suspending
machine.sendSuspending(Load(42)) // waits for mailbox capacity
machine.sendAndWait(Load(42)) // deterministic processing barrier
machine.collect { state -> render(state.superState) }lastKnownState is always the latest committed state. Each observer has a
bounded state buffer; slow observers drop their oldest pending state without
slowing execution. runtimeMetrics exposes queue, observer, output, and drop
counts without retaining state or event payloads.
The supplied scope owns the machine. Cancelling that scope stops the machine. For explicit shutdown:
machine.finish() // stop accepting events and begin draining
machine.finishAndWait() // wait for commands, output cancellation, and callbacksOutput cancellation policies inspect every accepted event, including unmatched
events. An output remains supervised until its coroutine has actually exited.
Output contexts cannot replace the runtime-owned Job.
Add statemachine-compose, then let a factory create the machine in the
composition-owned scope:
val controller = rememberStateMachine(
ComposeStateMachineFactory { scope ->
StateMachineFlow<FeatureState, FeatureEvent>(
initial = Idle,
scope = scope,
) {
// routes
}
},
)
FeatureScreen(
state = controller.state.superState,
onLoad = { controller.send(Load(42)) },
)When a ViewModel owns the machine, use rememberStateMachine(machine); leaving
the composition only stops observation. PreviewStateMachine is a synchronous
fake for previews and presentation tests. The adapter uses the Compose runtime
provided by the host application and does not force a desktop or Android
Compose artifact transitively.
Logging is silent by default. Inject StateMachineLogger per machine. Standard
records contain the machine identifier and Kotlin types, not complete
state/event payloads. Runtime failures retain their Throwable.
assertions(scope = this, stateMachineFlow = machine) {
assert(Idle)
send(Load(42))
assert(Loading(42))
}
assertNoTransition(
stateMachineFlow = machine,
whenState = Idle,
onEvent = LoadedValue("unused"),
)The assertion helper always finishes the machine. Use sendAndWait and
finishAndWait directly for lower-level concurrency tests.
The build uses JDK 21 while producing JVM 11 bytecode:
./gradlew build
./gradlew checkKotlinAbi koverVerify detekt dokkaGenerate
./gradlew :benchmarks:benchmarkRuntime
./gradlew publishToMavenLocalDependency locks and checksum verification metadata are committed. Release
tags must use v<version> and match VERSION_NAME; CI actions are pinned to
immutable commit SHAs.
Published coordinates use group io.sideeffect.kotlinstatemachine and
artifacts statemachine, statemachine-compose, statemachine-debug,
statemachine-dump, and statemachine-dump-no-op.