Simple state machine modelling. Define the state-machine in terms of states, events and commands
In Gradle, install the ForkHandles BOM and then this module in the dependency block:
implementation(platform("dev.forkhandles:forkhandles-bom:X.Y.Z"))
implementation("dev.forkhandles:state4k")
State4k introduces a mechanic for moving a state machine from state to state using events in a strict fashion. To define a state machine, you need:
- An
Entity
which is the object being modelled - A set of
States
, which the entity can be in - A set of
Events
to transition between those states. Each transition may result in an optionalCommand
being generated as a reaction to the transition.
The model is built by creating the machine with a set of transitions which tie a starting State
, an Event
, a modification process to the Entity
when that event is received, and an optional Command
to generate and send upon the transition.
Transitions occur in one of 2 ways:
- Receive a known out-of-band
Event
. This modifies the state of the entity in a known way, and may generate aCommand
as per the defined transition table. - Receive and process a
Command
, which will result in one of a discreet set ofEvents
to be applied (see #1)
We have a state machine for making a cup of tea. The state machine looks like this and when the kettle is boiled, we issue a command to check if the person would like milk:
The entities look like:
// this is our entity - it tracks the state
data class CupOfTea(val state: TeaState, val lastAction: String)
// the various states the entity can be in
enum class TeaState {
GetCup, BoilingWater, SteepingTea, CheckForMilk, WhiteTea, BlackTea
}
// commands define actions which can result in dynamically generated events
enum class TeaCommands {
DoYouHaveMilk
}
// events transition the machine from one state to another
sealed interface TeaEvent {
data object TurnOnKettle : TeaEvent
data object PourWater : TeaEvent
data object MilkPlease : TeaEvent
data object NoMilkPlease : TeaEvent
data object MilkIsFull : TeaEvent
data object MilkIsEmpty : TeaEvent
}
We can define the state machine in code as:
// the lens gets and sets the state on the Entity
val lens = StateIdLens(CupOfTea::state) { entity, state -> entity.copy(state = state) }
// commands is responsible for issuing new orders which will generate new events
val commands = { entity: CupOfTea, command: TeaCommands ->
println("Issuing command $command for $entity")
Success(Unit)
}
// define the machine
val teaStateMachine = StateMachine<TeaState, CupOfTea, TeaEvent, TeaCommands, String>(
commands,
lens,
// the state transitions for GetCup - we don't need to update the entity
StateBuilder<TeaState, CupOfTea, TeaCommands>(GetCup)
.transition<TurnOnKettle>(BoilingWater),
// the state transitions for BoilingWater - we can update the entity
StateBuilder<TeaState, CupOfTea, TeaCommands>(BoilingWater)
.transition<PourWater>(SteepingTea) { event: PourWater, entity: CupOfTea ->
entity.copy(lastAction = "Waiting...")
},
// when we enter SteepingTea, we ask if they have milk (a command). The result of that
// command will be a MilkPlease or NoMilkPlease event
StateBuilder<TeaState, CupOfTea, TeaCommands>(SteepingTea)
.onEnter(DoYouHaveMilk)
.transition<MilkPlease>(CheckForMilk)
.transition<NoMilkPlease>(BlackTea),
StateBuilder<TeaState, CupOfTea, TeaCommands>(CheckForMilk)
.transition<MilkIsFull>(WhiteTea)
.transition<MilkIsEmpty>(BlackTea),
StateBuilder(BlackTea)
)
To manipulate the machine, we can call one of 2 methods - one for async events and one for command processing (which will result in a discreet event being generated). Each transition results in a Result4k
result determining if the transition was successful
// this is the type of the result of a transition
typealias TeaResult = Result<StateTransitionResult<TeaState, CupOfTea, TeaCommands>, String>
// returns OK with the updated entity - state only,
val boilingKettle: TeaResult = teaStateMachine.transition(
CupOfTea(GetCup, "-"),
TurnOnKettle
)
val updatedCupOfTea = boilingKettle.valueOrNull()!!.entity
println(updatedCupOfTea)
// returns OK with the updated entity - the lastAction is updated
val steepingTea: TeaResult = teaStateMachine.transition(
updatedCupOfTea,
PourWater
)
val updatedCupOfTea2 = steepingTea.valueOrNull()!!.entity
println(updatedCupOfTea2)
// returns OK with the updated entity in state three or four
val blackOrCheckingForMilk: TeaResult = teaStateMachine.transition(updatedCupOfTea2, DoYouHaveMilk) {
// imagine a remote operation here which could go one of 2 ways (or fail!)
when (Random.nextBoolean()) {
true -> Success(NoMilkPlease)
false -> Success(MilkPlease)
}
}
println(blackOrCheckingForMilk)
// we can display the state machine as a PlantUML diagram
println(teaStateMachine.renderUsing(Puml("simple")))
Note that the storage of the controlled entity is done entirely outside of State4k. The typical model is for commands to be issued to a queue and the reprocessed back into the machine. In the case of a database, you will want to process each command or async event in an "select for update"-type block to ensure that only a single operation is processed at once.