Async State Machine 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.
let stateMachine = StateMachine(initial: .state1) {
When(state: .state1) {
Execute(output: .output1)
} transitions: {
On(event: .event1) { Transition(to: .state2) }
On(event: .event2) { Transition(to: .state3) }
On(event: .event3(value:)) { value in Transition(to: .state4(value)) }
}
When(state: .state2(value:)) { value in
Execute.noOutput
} transitions: { value in
…
}
}
State machines are declarative: a DSL offers a natural and concise syntax
Swift concurrency is at the core:
A state machine is an AsyncSequence
Each side effect runs inside a Task that benefits from cooperative cancellation
Concurrent transitions can suspend
State machines are built in complete isolation: tests dont require mocks
Dependencies are injected per side effect: no global bag of dependencies
State machines are not UI related: it works with UIKit or SwiftUI
A Simple Example
As a picture is worth a thousand words, here’s an example of a state machine that drives the opening of an elevator‘s door:
How does it read?
INITIALLY, the elevator is open with 0 person inside
WHEN the state is open, ON the event personsHaveEntered, the new state is open with n + x persons.
WHEN the state is open, ON the event closeButtonWasPressed, the new state is closing if there is less than 10 persons (elevator’s capacity is limited).
WHEN the state is closing, the close action is executed (the door can close at different speeds).
WHEN the state is closing, ON the event doorHasLocked, the new state is closed.
What defines this state machine?
The elevator can be in 3 exclusive states: open, closing and closed. This is the finite set of possible states. The initial state of the elevator is open with 0 person inside.
The elevator can receive 3 events: personsHaveEntered, closeButtonWasPressed and doorHasLocked. This is the finite set of possible events.
The elevator can perform 1 action: close the door when the state is closing and the number of persons is less than 10. The speed of the doors is determined by the number of persons inside. 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.
The state machine DSL
Here’s the translation of the aforementioned state machine using enums and the Async State Machine DSL:
enum State: DSLCompatible {
case open(persons: Int)
case closing(persons: Int)
case closed
}
enum Event: DSLCompatible {
case personsHaveEntered(persons: Int)
case closeButtonWasPressed
case doorHasLocked
}
enum Output: DSLCompatible {
case close(speed: Int)
}
let stateMachine = StateMachine(initial: State.open(persons: 0)) {
When(state: State.open(persons:)) { _ in
Execute.noOutput
} transitions: { persons in
On(event: Event.personsHaveEntered(persons:)) { newPersons in
Transition(to: State.open(persons: persons + newPersons))
}
On(event: Event.closeButtonWasPressed) {
Guard(predicate: persons < 10)
} transition: {
Transition(to: State.closing(persons: persons))
}
}
When(state: State.closing(persons:)) { persons in
Execute(output: Output.close(speed: persons > 5 ? 1 : 2))
} transitions: { _ in
On(event: Event.doorHasLocked) {
Transition(to: State.closed)
}
}
}
The only requirement to be able to use enums with the DSL is to have them conform to DSLCompatible (which allows to use enums in a declarative manner, without the need for pattern matching).
The Runtime
The DSL aims to describe a formal state machine: no side effects, only pure functions!
The StateMachine declares outputvalues to describe the intent of side effects to be performed, but the implementation of those side effects are declared in the Runtime where one can map outputs to side effect functions.
(Amongst other benefits, this decoupling allows for easier testing of your State Machine without depending on the implementation of the side effects.)
Side effects are async functions that return either a single Event, or an AsyncSequence<Event>. Every time the state machine produces the expected output, the corresponding side effect will be executed.
In addition, the Runtime can register middleware functions executed on any state or event:
let runtime = Runtime<State, Event, Output>()
.map(output: Output.close(speed:), to: close(speed:))
.register(middleware: { (state: State) in print("State: \(state)") })
.register(middleware: { (event: Event) in print("Event: \(event)") })
The AsyncStateMachine can then be instantiated:
let sequence = AsyncStateMachine(
stateMachine: stateMachine,
runtime: runtime
)
for await state in sequence { … }
await sequence.send(Event.personsHaveEntered(persons: 3))
Swift concurrency at the core
Async State Machine is 100% built with the Swift 5.5 concurrency model in mind.
Transitions
Transitions defined in the DSL are async functions; they will be executed in a non blocking way.
Transitions cannot If an event previously sent is being processed by a transition, the next call to send(_:) will await. This prevents concurrent transitions to happen simultaneously (which could otherwise lead to inconsistent states).
Side effects
Side effects are async functions executed in the context of Tasks.
Task priority can be set in the Runtime: .map(output: Output.close(speed:), to: close(speed:), priority: .high).
Collaborative task cancellation applies: when an AsyncStateMachine is deinit, all the pending side effect tasks will be marked as cancelled.
Async sequence
AsyncStateMachine benefits from all the operators associated to AsyncSequence (map, filter, …). (See also swift async algorithms)
AsyncStateMachine is compliant with a multiple producer / multiple consumer mode in a concurrent mode. Although to output is not shared (meaning each consumer will receive the successive versions of the state), the transitions are guaranteed concurrent-safe.
How to inject dependencies?
Most of the time, side effects will require dependencies to perform their duty. However, Async State Machine expects a side effect to be a function that eventually takes a parameter (from the Output) and returns an Event or an AsyncSequence<Event>. There is no place for dependencies in their signatures.
There are several ways to overcome this:
Make a business object that captures the dependencies and declares a function that matches the side effect’s signature:
class ElevatorUseCase {
let engine: Engine
init(engine: Engine) { self.engine = engine }
func close(speed: Int) async -> Event {
try? await Task.sleep(nanoseconds: UInt64(self.engine.delay / speed))
return .doorHasLocked
}
}
let useCase = ElevatorUseCase(engine: FastEngine())
let runtime = Runtime<State, Event, Output>()
.map(output: Output.close(speed:), to: useCase.close(speed:))
Make a factory function that provides a side effect, capturing its dependencies:
func makeClose(engine: Engine) -> (Int) async -> Event {
return { (speed: Int) in
try? await Task.sleep(nanoseconds: UInt64(engine.delay / speed))
return .doorHasLocked
}
}
let close = makeClose(engine: FastEngine())
let runtime = Runtime<State, Event, Output>()
.map(output: Output.close(speed:), to: close)
Use the provided inject function (preferred way verbosity wise):
State machine definitions do not depend on any dependencies, thus they can be tested without using mocks. Async State Machine provides a unit test helper making it even easier:
No matter the UI framework you use, rendering a user interface is about interpreting a state. You can use an AsyncStateMachine as a reliable state factory. ViewStateMachine is a handy wrapper around AsyncStateMachine that eases the consumption of the state from a UI perspective.
Side effect cancellation
Make `close(speed:)` side effect execution be cancelled when the state machine produces any new states. It is also possible to cancel on a specific state.
Runtime.map(
output: Output.close(speed:),
to: close(speed:),
strategy: .cancelWhenAnyState
)
States set
Allows to factorize the same transition for a set of states.
Allows to create a SwiftUI binding on a property of the current state, sending an Event when the binding changes, debounced with the specified dueTime.
Connecting two state machines
This will send the event `OtherEvent.refresh` in the other state machine when the first state machine's state is `State.closed`.
let channel = Channel<OtherEvent>()
let runtime = Runtime<State, Event, Output>()
...
.connectAsSender(to: channel, when: State.closed, send: OtherEvent.refresh)
let otherRuntime = Runtime<OtherState, OtherEvent, OtherOutput>()
...
.connectAsReceiver(to: channel)
Async State Machine
Async State Machine 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.
Installation
Key points:
AsyncSequence
Task
that benefits from cooperative cancellationA Simple Example
As a picture is worth a thousand words, here’s an example of a state machine that drives the opening of an elevator‘s door:
How does it read?
open
with 0 person insideopen
, ON the eventpersonsHaveEntered
, the new state isopen
withn + x
persons.open
, ON the eventcloseButtonWasPressed
, the new state isclosing
if there is less than 10 persons (elevator’s capacity is limited).closing
, theclose
action is executed (the door can close at different speeds).closing
, ON the eventdoorHasLocked
, the new state is closed.What defines this state machine?
open
,closing
andclosed
. This is the finite set of possible states. The initial state of the elevator isopen
with 0 person inside.personsHaveEntered
,closeButtonWasPressed
anddoorHasLocked
. This is the finite set of possible events.close
the door when the state isclosing
and the number of persons is less than 10. The speed of the doors is determined by the number of persons inside. This is the finite set of possible outputs.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.
The state machine DSL
Here’s the translation of the aforementioned state machine using enums and the Async State Machine DSL:
The only requirement to be able to use enums with the DSL is to have them conform to DSLCompatible (which allows to use enums in a declarative manner, without the need for pattern matching).
The Runtime
The DSL aims to describe a formal state machine: no side effects, only pure functions!
The
StateMachine
declares output values to describe the intent of side effects to be performed, but the implementation of those side effects are declared in theRuntime
where one can map outputs to side effect functions.(Amongst other benefits, this decoupling allows for easier testing of your State Machine without depending on the implementation of the side effects.)
Side effects are
async
functions that return either a singleEvent
, or anAsyncSequence<Event>
. Every time the state machine produces the expectedoutput
, the corresponding side effect will be executed.In addition, the Runtime can register middleware functions executed on any
state
orevent
:The
AsyncStateMachine
can then be instantiated:Swift concurrency at the core
Async State Machine is 100% built with the Swift 5.5 concurrency model in mind.
Transitions
async
functions; they will be executed in a non blocking way.send(_:)
willawait
. This prevents concurrent transitions to happen simultaneously (which could otherwise lead to inconsistent states).Side effects
async
functions executed in the context ofTasks
..map(output: Output.close(speed:), to: close(speed:), priority: .high)
.AsyncStateMachine
is deinit, all the pending side effect tasks will be marked as cancelled.Async sequence
AsyncStateMachine
benefits from all the operators associated toAsyncSequence
(map
,filter
, …). (See also swift async algorithms)AsyncStateMachine
is compliant with a multiple producer / multiple consumer mode in a concurrent mode. Although to output is not shared (meaning each consumer will receive the successive versions of the state), the transitions are guaranteed concurrent-safe.How to inject dependencies?
Most of the time, side effects will require dependencies to perform their duty. However, Async State Machine expects a side effect to be a function that eventually takes a parameter (from the
Output
) and returns anEvent
or anAsyncSequence<Event>
. There is no place for dependencies in their signatures.There are several ways to overcome this:
inject
function (preferred way verbosity wise):Testable in complete isolation
State machine definitions do not depend on any dependencies, thus they can be tested without using mocks. Async State Machine provides a unit test helper making it even easier:
Using Async State Machine with SwiftUI and UIKit
No matter the UI framework you use, rendering a user interface is about interpreting a state. You can use an
AsyncStateMachine
as a reliable state factory.ViewStateMachine
is a handy wrapper aroundAsyncStateMachine
that eases the consumption of the state from a UI perspective.A simple and naive SwiftUI usage could be:
With UIKit, a simple and naive approach would be:
Extras
Conditionally resumable `send()` function
Allows to send an event while awaiting for a specific state or set of states to resume.Side effect cancellation
Make `close(speed:)` side effect execution be cancelled when the state machine produces any new states. It is also possible to cancel on a specific state.States set
Allows to factorize the same transition for a set of states.SwiftUI bindings
Allows to create a SwiftUI binding on the current state, sending an Event when the binding changes.Allows to create a SwiftUI binding on a property of the current state, sending an Event when the binding changes.
Allows to create a SwiftUI binding on a property of the current state, sending an Event when the binding changes, debounced with the specified dueTime.
Connecting two state machines
This will send the event `OtherEvent.refresh` in the other state machine when the first state machine's state is `State.closed`.