statemachine aims to provide a way to structure an application thanks to state machines. The goal is to identify the states and the side effects involved in each feature and to model them in a consistent and scalable way thanks to a DSL.
This repository is a standalone Kotlin/JVM library. It has no Android framework dependency, but its JVM 11 artifacts can be consumed by Android applications.
statemachine: the Flow runtime, state machine model, side-effect runtime, mediator, and DSL.statemachine-debug: JUnit helpers for state-machine tests.statemachine-dump: active state collection and export, intended for Android debug builds.statemachine-dump-no-op: API-compatible no-op implementation, intended for Android release builds.
The project requires JDK 21. Gradle itself does not need to be installed because the wrapper is committed.
./gradlew buildTo verify the artifacts locally:
./gradlew publishToMavenLocalThis publishes version 0.1.0-SNAPSHOT under the io.sideeffect.kotlinstatemachine group.
After publishing a release to GitHub Packages, add the package repository to the Android application's settings.gradle.kts. GitHub Packages requires authentication for package downloads, including public packages, so keep a classic personal access token with read:packages in the user's ~/.gradle/gradle.properties, never in the repository:
github.actor=YOUR_GITHUB_USERNAME
github.token=YOUR_CLASSIC_PERSONAL_ACCESS_TOKENdependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/OWNER/REPOSITORY")
credentials {
username = providers.gradleProperty("github.actor").orNull
password = providers.gradleProperty("github.token").orNull
}
}
}
}Then add the artifacts to the Android module:
dependencies {
implementation("io.sideeffect.kotlinstatemachine:statemachine:VERSION")
testImplementation("io.sideeffect.kotlinstatemachine:statemachine-debug:VERSION")
debugImplementation("io.sideeffect.kotlinstatemachine:statemachine-dump:VERSION")
releaseImplementation("io.sideeffect.kotlinstatemachine:statemachine-dump-no-op:VERSION")
}Create a GitHub release to run the included publish workflow. A tag such as v1.0.0 is published as Maven version 1.0.0.
- Each feature is a Mealy state machine
- State machines are declarative: a DSL offers a natural and concise syntax
- Coroutines is at the core:
- A state machine is a
Flow - Each side effect runs as a
Jobthat benefits from cooperative cancellation
- A state machine is a
A state machine is defined by:
- a set of possible states
- a set of possible events
- an initial state
- some transitions driving the passage from on state the another given an event
- some outputs that are executed depending on the current state and an event
Here is a diagram describing a simple state machine:
- INITIALLY, the
data is idle, the feature does nothing. - WHEN the
data is idle, ON the eventdid request loading, thedata is loadingand theload()output is executed. - WHEN the
data is loading, ON the eventdid succeed to load, thedata is loaded. - WHEN the
data is loading, ON the eventdid fail to load, thedata is in failure. - WHEN the
data is in failure, ON the eventdid request reloading, thedata is loadingand theload()output is executed.
- The feature can be in 4 exclusive states:
DataIsIdle,DataIsLoading,DataIsLoadedandDataIsInFailure. This is the finite set of possible states. The initial state of the feature isDataIsIdle. - The feature can receive 4 events:
DidRequestLoading,DidSucceedToLoad,DidFailToLoadandDidRequestReloading. This is the finite set of possible events. - The feature can perform 1 action:
load(). This is the finite set of possible outputs. - The elevator can go from one state to another when events are received. This is the finite set of possible transitions.
The assumption we make is that almost any feature can be described in terms of state machines. And to make it as simple as possible, we use a Domain Specific Language.
States is a finite set of possible values. Each state implements to the State interface which only requirement is to provide a superState property.
The superState property represents the aggregate of values that the user of the state machine cares about (it allows to derive those values directly from each individual state, there is no need for a
when statement - which could go against the O/C principle).
We use a sealed class to enforce the boundaries of the set of States.
data class FeatureSuperState(val isLoaded: Boolean, val isFailed: Boolean)
sealed class FeatureState: State<FeatureSuperState>
data class DataIsIdle(val seed: Int = Random.nextInt(0, 100)): FeatureState() {
override val superState: FeatureSuperState
get() = FeatureSuperState(isLoaded = false, isFailed = false)
}
data class DataIsLoading(val seed: Int = Random.nextInt(0, 100)): FeatureState() {
override val superState: FeatureSuperState
get() = FeatureSuperState(isLoaded = false, isFailed = false)
}
data class DataIsLoaded(val value: Int): FeatureState() {
override val superState: FeatureSuperState
get() = FeatureSuperState(isLoaded = true, isFailed = false)
}
data class DataIsInFailure(val error: Error): FeatureState() {
override val superState: FeatureSuperState
get() = FeatureSuperState(isLoaded = false, isFailed = true)
}By using a sealed class approach, we can still use when statements where it seems appropriate (like in predicates where we receive states and events) and we still benefit from the compiler checking the completeness of the when.
Events is a finite set of possible values. We use a sealed class to model them:
sealed class FeatureEvent
data class DidRequestLoading(val id: Int): FeatureEvent()
data class DidRequestReloading(val id: Int): FeatureEvent()
data class DidSucceedToLoad(val value: Int): FeatureEvent()
data class DidFailToLoad(val value: Int): FeatureEvent()val stateMachine = StateMachine<FeatureState, FeatureEvent>(initial = Idle()) {
When(state = DataIsIdle::class) {
On(event = DidRequestLoading::class) { state, event ->
Transition(state = DataIsLoading(id = event.id))
Output(context = Dispatchers.IO, sideEffect = load(event.id), lifecycle = Cancel(onEvent = DidRequestLoading::class))
// we can declare a CoroutineContext for each output
// we can apply a cancellation policy to outputs
}
}
When(state = DataIsLoading::class) {
On(event = DidSucceedToLoad::class) { state, event ->
Transition(state = DataIsLoaded(vale: event.value))
}
On(event: DidFailToLoad::class) { state, event ->
Transition(state = DataIsInFailure(error = event.error))
}
}
When(state = DataIsInFailure::class) {
On(event = DidRequestReloading::class) { state, event ->
Transition(state = DataIsLoading(id = event.id))
Output(sideEffect = load(event.id))
}
}
}It is also possible to declare transitions for several states or several events in one block.
When(states = {
+DataIsIdle::class
+DataIsLoading::class
}) {
On(events = {
+DidRequestLoading::class
+DidRequestReloading::class
}) { state, event ->
Transition(state = DataIsLoading(id = event.id))
Output(sideEffect = load(event.id))
}
}In this case, the type of the state and event parameters is the type of the sealed class. It is up to the developer to ensure it allows access to the needed information (the id in this case).
As mentioned, a state machine is a Flow of states.
val stateMachineFlow = StateMachineFlow<FeatureState, FeatureEvent>(initial = DataIsIdle()) {
When(state = DataIsLoading::class) {
On(event = DidSucceedToLoad::class) { state, event ->
...
}
On(event: DidFailToLoad::class) { state, event ->
...
}
}
}By default, a StateMachineFlow has its own coroutine scope, that is used to execute the output in coroutines.
It is possible to provide another scope in the constructor (for instance the viewModel scope).
We can register functions that will be called for:
- the initial state
- the subsequent transitions
- the end of life
stateMachineFlow
.onInitialState(context = Dispatchers.IO) { id, initialState ->
// id is the unique identifier of the state machine
// do stuff such as analytics
}
.onTransition(context = Dispatchers.IO) { id, currentState, event, newState ->
// id is the unique identifier of the state machine
// do stuff such as analytics
}
.onEndOfLife { id ->
// id is the unique identifier of the state machine
// do stuff such as analytics
}
}As we cannot automatically track the release of an object in memory, the registered onEndOfLife functions are
executed when the endOfLife() function is called.
stateMachineFlow.send(event = DidRequestLoading(id = 1701))val stateMachineFlow1 = StateMachineFlow(initial = DataIsIdle1()) {
...
}
val stateMachineFlow2 = StateMachineFlow(initial = DataIsIdle2()) {
...
}
val mediator = Mediator<FeatureEvent1>()
stateMachineFlow1.connectAsReceiver(mediator: = mediator)
stateMachineFlow2.connectAsSender(mediator = mediator, whenCurrentState = DataIsIdle2::class) { currentState, event, newState ->
DidRequestLoading()
}In this case, when the second state machine will operate any transition where the current state is Idle2, an event DidRequestLoading
will be sent to the first state machine.
There are several versions of the connectAsSender function if we need to constrain either the current state, the event or the new state.
It is possible to track the lifecycle of several state machines and dump their current states under any format you'd like:
io.sideeffect.kotlinstatemachine.dump.startCollecting(scope = scope) // enables the dump for every tracked state machine
myStateMachineFlow.activateDump() // will track initial state, subsequent transitions, end of life
// end of life can be omitted by passing false to the `activateDump` function.
io.sideeffect.kotlinstatemachine.dump.dump(context = Dispatchers.IO) { stateContexts ->
// here we can access all the states that are currently "alive"
// we can export the data into a JSON file and store it for instance
}
io.sideeffect.kotlinstatemachine.dump.stopCollecting() // disable the collecting for every tracked state machineUse statemachine-dump in debug builds and statemachine-dump-no-op in release builds, as shown in the dependency setup above.
By default a StateMachineFlow will log every transition (current state + event => new state) using a println function.
You can change that by setting your own logging function:
logger = { message ->
Timber.i(message)
}It is also possible to disable the logs for a particular state machine:
stateMachineFlow.disableLog()To assert that the state machine will emit the expected states given input events:
val expectedStateIdle = DataIsIdle()
val expectedStateLoading = DataIsLoading()
val expectedStateLoaded = DataIsLoaded(value = Random.nextInt())
val stateMachineFlow = StateMachineFlow<FeatureState, FeatureEvent>(initial = expectedStateIdle, scope = this) {
When(state = DataIsIdle::class) {
On(event = DidRequestLoading::class) { _, _ ->
Transition(state = expectedStateLoading)
Output(context = coroutineContext, sideEffect = { DidSucceedToLoad(value = 1701) })
}
}
When(state = DataIsLoading::class) {
On(event = DidSucceedToLoad::class) { _, _ ->
Transition(state = expectedStateLoaded)
}
}
}
assertions(scope = this, stateMachineFlow = stateMachineFlow) {
assert(state = expectedStateIdle)
send(event = DidRequestLoading(id = 1701))
assert(state = expectedStateLoading)
assert(state = expectedStateLoaded)
}It allows to ensure that the state machine behaves as expected from a complete user flow perspective. It is a holistic way of testing the state machine, also involving the outputs.
To assert that there is no transition for the state and the event:
val stateMachineFlow = StateMachineFlow<FeatureState, FeatureEvent>(initial = DataIsIdle(), scope = this) {
When(state = DataIsIdle::class) {
On(event = DidRequestLoading::class) { _, _ ->
Transition(state = DataIsLoading())
Output(context = coroutineContext, sideEffect = { DidSucceedToLoad(value = 1701) })
}
}
When(state = DataIsLoading::class) {
On(event = DidSucceedToLoad::class) { _, _ ->
Transition(state = DataIsLoaded(value = 1701))
}
}
}
assertNoTransition(stateMachineFlow = stateMachineFlow, whenState = DataIsIdle(), onEvent = DidSucceedToLoad(value = 1701))We can also assert that no transition is happening directly while testing the flow of the state machine.
val expectedStateIdle = DataIsIdle()
val stateMachineFlow = StateMachineFlow<FeatureState, FeatureEvent>(initial = expectedStateIdle, scope = this) {
When(state = DataIsIdle::class) {
On(event = DidRequestLoading::class, guard = { _, event -> event.id != 1701 } ) { _, _ ->
Transition(state = expectedStateLoading)
}
}
}
assertions(scope = this, stateMachineFlow = stateMachineFlow) {
assert(state = expectedStateIdle)
send(event = DidRequestLoading(id = 1701))
assertNoTransition()
}