Skip to content

sideeffect-io/KotlinStateMachine

Repository files navigation

Kotlin StateMachine

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.

Modules

  • 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.

Define and run a machine

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.

Lifecycle and cancellation

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 callbacks

Output 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.

Compose

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 and privacy

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.

Test helpers

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.

Build and release checks

The build uses JDK 21 while producing JVM 11 bytecode:

./gradlew build
./gradlew checkKotlinAbi koverVerify detekt dokkaGenerate
./gradlew :benchmarks:benchmarkRuntime
./gradlew publishToMavenLocal

Dependency 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages