kfsm
kfsm copied to clipboard
Finite state machinery in Kotlin
kFSM is Finite State Machinery for Kotlin.
How to use
There are four key components to building your state machine.
- The nodes representing different states in the machine -
State
- The type to be transitioned through the machine -
Value
- The effects that are defined by transitioning from one state to the next -
Transition
- A transitioner, which can be customised when you need to define pre and post transition hooks -
Transitioner
Let's build a state machine for a traffic light.
stateDiagram-v2
[*] --> Green
Amber --> Red
Green --> Amber
Red --> Green
State
The states are a collection of related classes that define a distinct state that the value can be in. They also define which states are valid next states.
sealed class Color(to: () -> Set<Color>) : app.cash.kfsm.State<Color>(to)
data object Green : Color({ setOf(Amber) })
data object Amber : Color({ setOf(Red) })
data object Red : Color({ setOf(Green) })
[!IMPORTANT] Be sure to define your state constructor with functions rather than literal values if you require cycles in your state machine. Otherwise, you are likely to encounter null pointer exceptions from the Kotlin runtime's inability to define the types.
Value
The value is responsible for knowing and updating its current state.
data class Light(override val state: Color) : Value<Light, Color> {
override fun update(newState: Color): Light = this.copy(state = newState)
}
Transition
Types that provide the required side-effects that define a transition in the machine.
abstract class ColorChange(
from: States<Color>,
to: Color
) : Transition<Light, Color>(from, to) {
// Convenience constructor for when the from set has only one value
constructor(from: Color, to: Color) : this(States(from), to)
}
class Go(private val camera: Camera) : ColorChange(from = Red, to = Green) {
override suspend fun effect(value: Light) = camera.disable()
}
object Slow : ColorChange(from = Green, to = Amber)
class Stop(private val camera: Camera) : ColorChange(from = Amber, to = Red) {
override suspend fun effect(value: Light) = camera.enable()
}
Transitioner
Moving a value from one state to another is done by the transitioner. We provide it with a function that declares how to persist values.
class LightTransitioner(
private val database: Database
) : Transitioner<ColorChange, Light, Color>() {
override suspend fun persist(value: Light, change: ColorChange): Result<Light> = database.update(value)
}
Each time a transition is successful, the persist function will be called.
Pre and Post Transition Hooks
It is sometimes necessary to execute effects before and after a transition. These can be defined on the transitioner.
class LightTransitioner ... {
// ...
override suspend fun preHook(value: V, via: T): Result<Unit> = runCatching {
globalLock.lock(value)
}
override suspend fun postHook(from: S, value: V, via: T): Result<Unit> = runCatching {
globalLock.unlock(value)
notificationService.send(via.successNotifications())
}
}
Transitioning
With the state machine and transitioner defined, we can progress any value through the machine by using the transitioner.
val transitioner = LightTransitioner(database)
val greenLight: Result<Light> = transitioner.transition(redLight, Go)
Putting it all together
// The state
sealed class Color(to: () -> Set<Color>) : app.cash.kfsm.State<Color>(to)
data object Green : Color({ setOf(Amber) })
data object Amber : Color({ setOf(Red) })
data object Red : Color({ setOf(Green) })
// The value
data class Light(override val state: Color) : Value<Light, Color> {
override fun update(newState: Color): Light = this.copy(state = newState)
}
// The transitions
abstract class ColorChange(
from: States<Color>,
to: Color
) : Transition<Light, Color>(from, to) {
// Convenience constructor for when the from set has only one value
constructor(from: Color, to: Color) : this(States(from), to)
}
class Go(private val camera: Camera) : ColorChange(from = Red, to = Green) {
override suspend fun effect(value: Light) = camera.disable()
}
object Slow : ColorChange(from = Green, to = Amber)
class Stop(private val camera: Camera) : ColorChange(from = Amber, to = Red) {
override suspend fun effect(value: Light) = camera.enable()
}
// The transitioner
class LightTransitioner(
private val database: Database
) : Transitioner<ColorChange, Light, Color>() {
override suspend fun persist(value: Light, change: ColorChange): Result<Light> = database.update(value)
}
// main ...
val transitioner = LightTransitioner(database)
val greenLight: Result<Light> = transitioner.transition(redLight, Go)
More examples
See lib/src/test/kotlin/app/cash/kfsm/exemplar for a different example of how to use this library.
Coroutine Support
If you are using coroutines and need suspending function support, you can extend TransitionerAsync
instead of
Transitioner
and implement any suspending transition effects via the Transition.effectAsync
method.
Safety
How does kFSM help validate the correctness of your state machine and your values?
- It is impossible to define a Transition that does not comply with the transitions defined in the States. For example,
a transition that attempts to define an arrow between
Red
andAmber
will fail at construction. - If a value has already transitioned to the target state, then a subsequent request will not execute the transition a
second time. The result will be success. I.e. it is a no-op.
- (unless you have defined a circular/self-transition, in which case it will)
- If a value is in a state unrelated to the executed transition, then the result will be an error and no effect will be executed.
Testing your state machine
The utility StateMachine.verify
will assert that a defined state machine is valid - i.e. that all states are visited
from a given starting state.
StateMachine.verify(Green) shouldBeRight true
Document your state machine
The utility StateMachine.mermaid
will generate a mermaid diagram of your state machine. This can be rendered in markdown.
The diagram of Color
above was created using this utility.
StateMachine.mermaid(Green) shouldBeRight """stateDiagram-v2
[*] --> Green
Amber --> Red
Green --> Amber
Red --> Green
""".trimMargin()
Documentation
The API documentation is published with each release at https://cashapp.github.io/kfsm
See a list of changes in each release in the CHANGELOG.
See lib/src/test/kotlin/app/cash/kfsm/exemplar for a different example of how to use this library.
For details on contributing, see the CONTRIBUTING guide.
Building
[!NOTE] kFSM uses Hermit.
Hermit ensures that your team, your contributors, and your CI have the same consistent tooling. Here are the installation instructions.
Activate Hermit either by enabling the shell hooks (one-time only, recommended) or manually sourcing the env with
. ./bin/activate-hermit
.
Use gradle to run all tests
gradle build