Skip to content

sideeffect-io/KotlinStateMachine

Repository files navigation

Kotlin StateMachine

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.

Modules

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

Build

The project requires JDK 21. Gradle itself does not need to be installed because the wrapper is committed.

./gradlew build

To verify the artifacts locally:

./gradlew publishToMavenLocal

This publishes version 0.1.0-SNAPSHOT under the io.sideeffect.kotlinstatemachine group.

Use from an Android app

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_TOKEN
dependencyResolutionManagement {
    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.

Key points:

  • 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 Job that benefits from cooperative cancellation

State Machines

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:

state machine

How does it read?

  • INITIALLY, the data is idle, the feature does nothing.
  • WHEN the data is idle, ON the event did request loading, the data is loading and the load() output is executed.
  • WHEN the data is loading, ON the event did succeed to load, the data is loaded.
  • WHEN the data is loading, ON the event did fail to load, the data is in failure.
  • WHEN the data is in failure, ON the event did request reloading, the data is loading and the load() output is executed.

What defines this state machine?

  • The feature can be in 4 exclusive states: DataIsIdle, DataIsLoading, DataIsLoaded and DataIsInFailure. This is the finite set of possible states. The initial state of the feature is DataIsIdle.
  • The feature can receive 4 events: DidRequestLoading, DidSucceedToLoad, DidFailToLoad and DidRequestReloading. 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.

DSL

How to model the states

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.

How to model the events

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()

How to model the state machine

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

How to run a state machine

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.

How to send events to the state machine

stateMachineFlow.send(event = DidRequestLoading(id = 1701))

How to connect state machines together

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.

How to dump the state machine's 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 machine

Use statemachine-dump in debug builds and statemachine-dump-no-op in release builds, as shown in the dependency setup above.

How to customize the logging

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()

How to test

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()
}

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages