Some pointers to the layout of this project -
- Code lives in
commonMain
as the state machine itself is a multiplatform library. - Tests live in
commonTest
and should always pass for ALL platforms. - Examples use the library published to your
mavenLocal
repo to best simulate end-user usage scenarios. Run thepublishToMavenLocal
gradle task before running the examples and all should be fine.
-
- Overhaul of the documentation to reflect current state
- Compose integrations
-
- Full DSL / API review to ensure that it makes sense
- Work through the callback and coroutines API to make sure that it also makes sense
-
- Move the tests into
commonTest
so they can be run across all platforms - Fix reported bugs (thanks Jigar for reporting the issue, and steps to reproduce it)
- Move the tests into
-
- Execute state transitions as coroutines
-
- Embraced Kotlin multiplatform
- Use the Gradle version catalog to simplify the build
- NOTE: this library is built against the NEW native memory module introduced in Kotlin 1.6.10
-
- introduced the state machine and its declarative DSL for defining states and transitions
- built as a traditional JVM library.
(later) Publish the library to Maven Central
Somewhere along the way, there need to be additional examples written, for native, Android and iOS.
Our state machine has three states:
sealed class MatterState : State {
object Solid : MatterState()
object Liquid : MatterState()
object Gas : MatterState()
}
Allowing us to define a simple state machine for matter:
val stateMachine = graph {
initialState(Solid)
state(Solid) {
allows(Liquid)
}
state(Liquid) {
allows(Solid, Gas)
}
state(Gas) {
allows(Liquid)
}
}
Which can then be driven by calling transitionTo()
:
stateMachine.start()
// sublimation not allowed - stays in Solid
stateMachine.transitionTo(Gas)
// melt the solid
stateMachine.transitionTo(Liquid)
// vaporize the liquid
stateMachine.transitionTo(Gas)
Note: if you prefer, you can have multiple allows()
definitions rather than the comma-separated list
state(Liquid) {
allows(Solid)
allows(Gas)
}
Transitions between states can be triggered by events:
sealed class MatterEvent : Event {
object OnMelted : MatterEvent()
object OnFrozen : MatterEvent()
object OnVaporized : MatterEvent()
object OnCondensed : MatterEvent()
}
Allowing us to define an event-driven state machine that focuses more on the edges between the nodes (the red arrows in the state diagram):
val stateMachine = graph {
initialState(Solid)
state(Solid) {
on(OnMelted) {
transitionTo(Liquid)
}
}
state(Liquid) {
on(OnFrozen) {
transitionTo(Solid)
}
on(OnVaporized) {
transitionTo(Gas)
}
}
state(Gas) {
on(OnCondensed) {
transitionTo(Liquid)
}
}
}
Note: defining the state transition using transitionTo()
implicitly sets up the list of allowed transitions for a
given state; there is no need to use allows()
when using transitionTo()
.
This event-driven state machine can then be driven by calling consume()
or, transitionTo()
stateMachine.start()
// sublimation not allowed - stays in Solid
stateMachine.consume(OnVaporized)
// melt the solid
stateMachine.consume(OnMelted)
// vaporize the liquid
stateMachine.transitionTo(Gas)
The simplest execution triggers are entry and exit of our states:
state(Solid) {
onEnter {
// code executed each time we enter the Solid state
}
onExit {
// code executed each time we leave the Solid state
}
on(OnMelted) { transitionTo(Liquid) }
}
However, the state machine also has the concept of edges between the nodes of the graph. It's possible to execute code as we enter and exit the transition (that is, at the start and the end of the red lines in the state diagram):
// Event driven style
state(Solid) {
onEnter { }
onExit { }
on(OnMelted) {
onEnter {
// code executed each time we enter the
// transition state from Solid --> Liquid
}
onExit {
// code executed each time we exit the
// transition state from Solid --> Liquid
}
transitionTo(Liquid)
}
}
// Non-event driven
state(Liquid) {
onEnter { }
onExit { }
onTransitionTo(Gas) {
onEnter { }
onExit { }
}
}
In this scenario, consuming the OnMelted
event will trigger a transition which will execute the following steps:
Node
Solid OnExitEdge
Solid --> Liquid OnEnterEdge
Solid --> Liquid OnExitNode
Liquid OnEnter
If you include a decision
in a state definition, it will be executed in preference to the normal onEnter
. The return
value from the decision lambda will be processed as if consume()
had been called, with all the normal event handling /
transition rules. A return value null
or other unhandled event wont cause a transition.
graph {
initialState(StateA)
state(StateA) { allows(StateB) }
state(StateB) {
decision { /* returns an event, or null */ }
on(TestEvent) { transitionTo(StateA) }
on(OtherTestEvent) { transitionTo(StateC) }
}
state(StateC)
}
Suppose you have a basic state machine
val stateMachine = graph {
initialState(Solid)
state(Solid) { ... }
state(Liquid) { ... }
state(Gas) { ... }
}
The graph you build is observable - you can observe either the states themselves as things change over time, or the lower-level state transitions (that includes edge traversal)
stateMachine.observeState().collect { state ->
// called with each state that we land in eg, Solid or Gas
}
stateMachine.observeStateChanges().collect { machineState ->
// called when dwelling on a particular node,
// eg, MachineState.Dwelling( Gas )
//
// or when traversing an edge of the graph,
// eg, MachineState.Traversing( Liquid to Gas )
}
State machines are defined to be in an inactive state when they are first defined (the large black dot on the state
diagram). A call to start()
is required to make the initial transition into the defined initialState. Optionally a machine state
can be passed into the start()
method to start the state machine at an arbitrary node in the graph. The state machine
allows either Inactive
or Dwelling
machine states to start and will throw an exception if you try to start
with Traversing
.
@Test
fun `freezing should move us from liquid to solid`() {
// Given
stateMachine.start(Dwelling(Liquid))
// When
stateMachine.consume(OnFrozen)
// Then
assertEquals(Solid, stateMachine.currentState.id)
}
The start()
method can be called at any time (and even multiple times) to reset the state machine to a given node in
the graph.
NOTE This section of the library is in-flux and subject to deep changes
By default, state transitions are instantaneous and never fail. You can supply a block of code that will override that behavior, allowing for long-running operations that have the option to succeed or fail. The assumption is that the action succeeds, so you only need to notify the state machine if there is a failure:
state(Solid) {
on(OnMelted) {
onEnter { }
onExit { }
transitionTo(Liquid)
execute { result ->
/* Do something that might take a while */
if ( /* something went wrong */ ) {
failure()
}
}
}
}
and for a non-event driven state machines :
state(Solid) {
onTransitionTo(Liquid) {
onEnter { }
onExit { }
execute { result ->
/* Do something that might take a while */
if ( /* some condition */ ) {
result.success()
} else {
result.failure()
}
}
}
}
By default, when a transition fails, the exit block of the edge will not be called and the state machine will re-enter the "from" state of the transition.
Node
Solid OnExitEdge
Solid --> Liquid OnEnterNode
Solid OnEnter
An application might be tempted to show and hide a progress indicator (onEnter
/ onExit
) while making a REST service
call (using execute
)
but the lack of a call to the onExit
when a transition fails would leave the progress indicator visible. In that case
the call to failure()
can be replaced with failAndExit()
to ensure that the onEnter
/ onExit
are still executed as a pair.
The execution block can be combined with the call to transitionTo()
for a more concise syntax
state(Solid) {
on(OnMelted) {
transitionTo(Liquid) { result ->
/* Do something that might take a while */
if ( /* some condition */ ) {
result.success()
} else {
result.failure()
}
}
}
}
Note: Be aware that the state machine will be left in limbo (the transition will never complete) if none of the success or failure methods are called.