Skip to content

Commit

Permalink
Do not define actions to be executed inside the state machine (#6)
Browse files Browse the repository at this point in the history
Do not define actions to be executed inside the state machine, instead (optionally) set the action to be done on the call site.

This has several advantages:
- The API within the library becomes much smaller and there is less code to maintain
- The user of the library can decide if and how to execute certain action on state transitions, which also give a greater freedom on how process and map return types, or what the return types should be. This is stark contrast to the current API where all actions must always process the same type, and may never return a value.
- Supporting both suspending functions and non suspending functions is now trivial

Some addition changes that worth to mention:
- Interceptors defined within the state machine is now removed, since this can quite easily, and with more freedom, be built on top of the existing API offered by this library
- A method to get the accepted events for a given state is added to the StateMachine, this can be useful when you want to provide a user with what given actions are legal in the current state of an entity.

Here is an example of how the functionality _could_ be extended in a service where fsm is used:
```kotlin
// Since this method is inline, it doesn't matter if "action" is suspending or not
inline fun <S : Any, E : Any, T : Any> StateMachine<S, E>.onEvent(
    state: S,
    event: E,
    action: (state: S) -> T
): T {
    return when (val transition = onEvent(state, event)) {
        is Accepted -> {
            fsmLog.info { "Transitioned from state $state to ${transition.state} due to event $event" }
            action(transition.state)
        }
        Rejected -> throw InvalidStateException(state, event)
    }
}
```

Creating custom extension methods like this on top of the now simplified API provides much more powerful and flexible conditions for interacting with the state machine.
  • Loading branch information
mantono committed Oct 4, 2023
1 parent a00a219 commit 04a5214
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 269 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ implementation("io.nexure:fsm:2.0.0")

## Usage
Here is a fictional example of what a state machine could look like that models the process of a
payment. In summary it has
payment. In summary, it has
- _Initial_ state `CREATED`
- _Intermediary_ states `PENDING` and `AUTHORIZED`
- _Terminal_ states `SETTLED` and `REFUSED`
Expand All @@ -33,4 +33,4 @@ implementation("io.nexure:fsm:2.0.0")

See [ExampleStateMachineTest.kt](lib/src/test/kotlin/io/nexure/fsm/ExampleStateMachine.kt) for an
example of how a state machine with the above states and transitions is built, and how it can
be invoked to execute certain actions on a given state transition.
be invoked to execute certain actions on state transition.
1 change: 1 addition & 0 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies {

// Use the Kotlin test library.
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")

// Use the Kotlin JUnit integration.
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
Expand Down
5 changes: 0 additions & 5 deletions lib/src/main/kotlin/io/nexure/fsm/Action.kt

This file was deleted.

3 changes: 1 addition & 2 deletions lib/src/main/kotlin/io/nexure/fsm/Edge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ package io.nexure.fsm
/**
* A connection for a transition state from one state to another, with an event.
*/
internal class Edge<S : Any, E : Any, N : Any>(
internal class Edge<S : Any, E : Any>(
val source: S,
val target: S,
val event: E,
val action: (N) -> Unit
) {
operator fun component1(): S = source
operator fun component2(): S = target
Expand Down
18 changes: 11 additions & 7 deletions lib/src/main/kotlin/io/nexure/fsm/StateMachine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ package io.nexure.fsm
/**
* S = State
* E = Event triggering a transition between two states
* N = Signal, data associated with a state change
*/
interface StateMachine<S : Any, E : Any, N : Any> {
interface StateMachine<S : Any, E : Any> {
/**
* Return all the possible states of the state machine
*/
Expand All @@ -28,16 +27,21 @@ interface StateMachine<S : Any, E : Any, N : Any> {

/**
* Execute a transition from [state] to another state depending on [event].
* If an action is associated with the state transition, it will then be executed,
* with [signal] as input. Returns a [Transition] indicating if the transition was permitted and
* successful or not.
* Returns a [Transition] indicating if the transition was permitted and
* successful or not by the state machine.
*
* It is recommended that the return value is checked for the desired outcome, if it is critical
* that an event for example is accepted and not rejected.
*/
fun onEvent(state: S, event: E, signal: N): Transition<S>
fun onEvent(state: S, event: E): Transition<S>

/**
* Return a list of events that are accepted by the state machine in the given state. The returned list will be an
* empty list if the state is a terminal state.
*/
fun acceptedEvents(state: S): Set<E>

companion object {
fun <S : Any, E : Any, N : Any> builder(): StateMachineBuilder<S, E, N> = StateMachineBuilder()
fun <S : Any, E : Any> builder(): StateMachineBuilder.Uninitialized<S, E> = StateMachineBuilder()
}
}
146 changes: 52 additions & 94 deletions lib/src/main/kotlin/io/nexure/fsm/StateMachineBuilder.kt
Original file line number Diff line number Diff line change
@@ -1,108 +1,66 @@
package io.nexure.fsm

@Suppress("UNUSED_PARAMETER")
private fun <N : Any> noOp(signal: N) {}

/**
* - [S] - the type of state that the state machine handles
* - [E] - the type of events that the can trigger state changes
* - [N] - the type of the input used in actions which are executed on state transitions
*/
class StateMachineBuilder<S : Any, E : Any, N : Any> private constructor(
private var initialState: S? = null,
private val transitions: List<Edge<S, E, N>> = emptyList(),
private val interceptors: List<(S, S, E, N) -> (N)> = emptyList(),
private val postInterceptors: List<(S, S, E, N) -> Unit> = emptyList()
) {
constructor() : this(null, emptyList(), emptyList(), emptyList())

/**
* Set the initial state for this state machine. There must be exactly one initial state,
* no more or less. Failing to set an initial state for a state machine will cause an
* [InvalidStateMachineException] to be thrown when [build()] is invoked.
*
* Calling this method more than once, with a different initial state will also cause an
* [InvalidStateMachineException] to be thrown, but immediately upon the second call to this
* method rather when the state machine is built.
*/
@Throws(InvalidStateMachineException::class)
fun initial(state: S): StateMachineBuilder<S, E, N> {
return if (initialState == null) {
StateMachineBuilder(state, transitions, interceptors, postInterceptors)
} else if (state === initialState) {
StateMachineBuilder(initialState, transitions, interceptors, postInterceptors)
} else {
throw InvalidStateMachineException("There can only be one initial state")
}
sealed class StateMachineBuilder<S : Any, E : Any> {
class Uninitialized<S : Any, E : Any> internal constructor(
private val transitions: List<Edge<S, E>> = emptyList(),
) : StateMachineBuilder<S, E>() {
/**
* Set the initial state for this state machine. There can only one initial state,
* no more or less.
*/
fun initial(state: S): Initialized<S, E> = Initialized(state, transitions)
}

/**
* Create a state transition from [source] state to [target] state that will be triggered by
* [event], and execute an optional [action] when doing the state transition. There can be
* multiple events that connect [source] and [target], but there must never be any ambiguous
* transitions.
*
* For example, having both of the following transitions, would NOT be permitted
* - `(S1, E1) -> S2`
* - `(S1, E1) -> S3`
*
* since it would not be clear if the new state should be `S2` or `S3` when event `E1` is
* received.
*/
fun connect(
source: S,
target: S,
event: E,
action: (signal: N) -> Unit = ::noOp
): StateMachineBuilder<S, E, N> = connect(Edge(source, target, event, action))

fun connect(source: S, target: S, event: E, action: Action<N>): StateMachineBuilder<S, E, N> =
connect(source, target, event, action::action)
class Initialized<S : Any, E : Any> internal constructor(
private val initialState: S,
private val transitions: List<Edge<S, E>>,
) : StateMachineBuilder<S, E>() {
/**
* Create a state transition from [source] state to [target] state that will be triggered by
* [event]. There can be multiple events that connect [source] and [target],
* but there must never be any ambiguous transitions.
*
* For example, having both of the following transitions, would NOT be permitted
* - `(S1, E1) -> S2`
* - `(S1, E1) -> S3`
*
* since it would not be clear if the new state should be `S2` or `S3` when event `E1` is
* received.
*/
fun connect(
source: S,
target: S,
event: E,
): Initialized<S, E> = connect(Edge(source, target, event))

private fun connect(edge: Edge<S, E, N>): StateMachineBuilder<S, E, N> =
StateMachineBuilder(initialState, transitions.plus(edge), interceptors, postInterceptors)
private fun connect(edge: Edge<S, E>): Initialized<S, E> =
Initialized(initialState, transitions.plus(edge))

/**
* Add an interceptor that is run _before_ any state machine action or state transition is done.
* The interceptor will only be run if the current state permits the event in question. No
* execution of the interceptor will be done if the state is rejected.
*/
fun intercept(
interception: (source: S, target: S, event: E, signal: N) -> N
): StateMachineBuilder<S, E, N> =
StateMachineBuilder(initialState, transitions, interceptors.plus(interception), postInterceptors)
/**
* @throws InvalidStateMachineException if the configured state machine is not valid. The main
* reasons for a state machine not being valid are:
* - No initial state
* - More than one initial state
* - The state machine is not connected (some states are not possible to reach from the initial
* state)
* - The same source state and event is defined twice
*/
@Throws(InvalidStateMachineException::class)
fun build(): StateMachine<S, E> {
StateMachineValidator.validate(initialState, transitions)

/**
* Add an interceptor that is run _after_ a successful processing of an event by the state
* machine. This interceptor will not be run if the event was rejected by the state machine, or
* if there was an exception thrown while executing the state machine action (if any).
*/
fun postIntercept(
interception: (source: S, target: S, event: E, signal: N) -> Unit
): StateMachineBuilder<S, E, N> =
StateMachineBuilder(initialState, transitions, interceptors, postInterceptors.plus(interception))

/**
* @throws InvalidStateMachineException if the configured state machine is not valid. The main
* reasons for a state machine not being valid are:
* - No initial state
* - More than one initial state
* - The state machine is not connected (some states are not possible to reach from the initial
* state)
* - The same source state and event is defined twice
*/
@Throws(InvalidStateMachineException::class)
fun build(): StateMachine<S, E, N> {
val initState: S = initialState
?: throw InvalidStateMachineException("No initial state set for state machine")

StateMachineValidator.validate(initState, transitions)
return StateMachineImpl(
initialState,
transitions,
)
}
}

return StateMachineImpl(
initState,
transitions,
interceptors,
postInterceptors
)
companion object {
operator fun <S : Any, E : Any> invoke(): Uninitialized<S, E> = Uninitialized()
}
}
42 changes: 11 additions & 31 deletions lib/src/main/kotlin/io/nexure/fsm/StateMachineImpl.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
package io.nexure.fsm

internal class StateMachineImpl<S : Any, E : Any, N : Any>(
internal class StateMachineImpl<S : Any, E : Any>(
private val initialState: S,
private val transitions: List<Edge<S, E, N>>,
private val interceptors: List<(S, S, E, N) -> (N)>,
private val postInterceptors: List<(S, S, E, N) -> Unit>
) : StateMachine<S, E, N> {
private val allowedTransitions: Map<S?, Set<Pair<S, E?>>> = transitions
private val transitions: List<Edge<S, E>>,
) : StateMachine<S, E> {
private val allowedTransitions: Map<S, Set<Pair<S, E>>> = transitions
.groupBy { it.source }
.map { it.key to it.value.map { edge -> edge.target to edge.event }.toSet() }
.toMap()

private val nonTerminalStates: Set<S> = allowedTransitions.keys.filterNotNull().toSet()

private val transitionActions: Map<Triple<S, E, S>, (N) -> Unit> = transitions
.associate { Triple(it.source, it.event, it.target) to it.action }
private val nonTerminalStates: Set<S> = allowedTransitions.keys

override fun states(): Set<S> = transitions.asSequence()
.map { listOf(it.source, it.target) }
Expand All @@ -28,31 +23,16 @@ internal class StateMachineImpl<S : Any, E : Any, N : Any>(
override fun reduceState(events: List<E>): S =
events.fold(initialState) { state, event -> nextState(state, event) ?: state }

private fun executeTransition(source: S, target: S, event: E, signal: N) {
val action: (N) -> Unit = transitionActions[Triple(source, event, target)] ?: return
val interceptedSignal: N = runInterception(source, target, event, signal)
action.invoke(interceptedSignal)
postIntercept(source, target, event, interceptedSignal)
}

private fun runInterception(source: S, target: S, event: E, signal: N): N {
return interceptors.fold(signal) { acc, operation ->
operation(source, target, event, acc)
}
}

private fun postIntercept(source: S, target: S, event: E, signal: N) {
postInterceptors.forEach { intercept -> intercept(source, target, event, signal) }
}

override fun onEvent(state: S, event: E, signal: N): Transition<S> {
override fun onEvent(state: S, event: E): Transition<S> {
val next: S = nextState(state, event) ?: return Rejected
executeTransition(state, next, event, signal)
return Executed(next)
return Accepted(next)
}

override fun acceptedEvents(state: S): Set<E> =
allowedTransitions.getOrDefault(state, emptySet()).map { it.second }.toSet()

private fun nextState(source: S, event: E): S? {
val targets: Set<Pair<S, E?>> = allowedTransitions.getOrDefault(source, emptySet())
val targets: Set<Pair<S, E>> = allowedTransitions.getOrDefault(source, emptySet())
return targets.firstOrNull { it.second == event }?.first
}
}
14 changes: 7 additions & 7 deletions lib/src/main/kotlin/io/nexure/fsm/StateMachineValidator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import java.util.Deque
import java.util.LinkedList

internal object StateMachineValidator {
fun <S : Any, E : Any> validate(initialState: S, transitions: List<Edge<S, E, *>>) {
fun <S : Any, E : Any> validate(initialState: S, transitions: List<Edge<S, E>>) {
rejectDuplicates(transitions)
findIllegalCombinations(transitions)
isConnected(initialState, transitions)
Expand All @@ -13,8 +13,8 @@ internal object StateMachineValidator {
/**
* Check so a combination of source state, target state and event is not defined more than once
*/
private fun <S : Any, E : Any> rejectDuplicates(transitions: List<Edge<S, E, *>>) {
val duplicate: Edge<S, E, *>? = transitions
private fun <S : Any, E : Any> rejectDuplicates(transitions: List<Edge<S, E>>) {
val duplicate: Edge<S, E>? = transitions
.duplicatesBy { Triple(it.source, it.target, it.event) }
.firstOrNull()

Expand All @@ -35,8 +35,8 @@ internal object StateMachineValidator {
* These two transitions would not be allowed to exist in the same state machine at the same
* time.
*/
private fun <S : Any, E : Any> findIllegalCombinations(transitions: List<Edge<S, E, *>>) {
val illegal: Edge<S, E, *>? = transitions
private fun <S : Any, E : Any> findIllegalCombinations(transitions: List<Edge<S, E>>) {
val illegal: Edge<S, E>? = transitions
.groupBy { it.source }
.filter { it.value.size > 1 }
.map { x -> x.value.map { y -> x.value.map { it to y } } }
Expand All @@ -57,7 +57,7 @@ internal object StateMachineValidator {
* and event but different target, since a source state which is triggered
* by a specific event should always result in the same target state.
*/
private fun <S : Any, E : Any> illegalCombination(e0: Edge<S, E, *>, e1: Edge<S, E, *>): Boolean {
private fun <S : Any, E : Any> illegalCombination(e0: Edge<S, E>, e1: Edge<S, E>): Boolean {
if (e0 === e1) {
return false
}
Expand All @@ -70,7 +70,7 @@ internal object StateMachineValidator {
/**
* Validate the configuration of the state machine, making sure that state machine is connected
*/
private fun <S : Any, E : Any> isConnected(initialState: S, transitions: List<Edge<S, E, *>>) {
private fun <S : Any, E : Any> isConnected(initialState: S, transitions: List<Edge<S, E>>) {
val stateTransitions: Map<S?, Set<S>> = transitions
.groupBy { it.source }
.mapValues { it.value.map { value -> value.target }.toSet() }
Expand Down
Loading

0 comments on commit 04a5214

Please sign in to comment.