swift-state-machine-patterns
Swift State Machine Patterns
When to use
- Reentrancy-sensitive flows
- Protocol or lifecycle logic with strict transition rules
- Async streams or continuations that need cancellation safety
- Boundaries where invalid state must be unrepresentable
Core rules
- Model state as an enum with associated values. Each case carries only data valid in that state.
- Keep the state machine as a value type (
struct) with a privatestate. - Transition methods are pure: mutate state and return
Actionvalues. - Execute side effects outside the transition (and outside any lock).
- Use strong types for events (enum or method parameters), actions, and identifiers. Avoid strings or boolean flags for state.
- Prefer per-state structs for larger associated data to prevent accidental access to irrelevant fields.
- Make invalid states unrepresentable by construction, not by runtime checks.
Sub-state pattern
Model each state as a dedicated struct and store it as an associated value in the enum. Capabilities are expressed as protocols and only adopted by states that support them.
- Use one struct per state (
IdleState,ActiveState,QuiescingState). - Store only the fields valid in that state; move shared data into protocols.
- Use
init(from:)to build the next state from the prior one and copy only relevant fields. - Use capability protocols (e.g.,
MaySendFrames,HasLocalSettings) to keep helpers constrained to valid states. - Optional: a
modifyingsentinel state can avoid CoW during transitions.
private struct IdleState: ConnectionStateWithRole, ConnectionStateWithConfiguration { /* ... */ }
private struct ActiveState: ConnectionStateWithRole, HasLocalSettings, HasRemoteSettings { /* ... */ }
private enum State {
case idle(IdleState)
case active(ActiveState)
}
Usage pattern: driver + state machine
Embed the state machine inside a handler/coordinator that owns side effects. The driver calls transition methods, applies effects, and decides whether to forward/emit/drop inputs.
final class ProtocolHandler {
private var stateMachine = ConnectionStateMachine()
private var pendingEvents: [UserEvent] = []
func receive(_ message: Message) {
let result = stateMachine.receive(message)
apply(effect: result.effect)
switch result.result {
case .succeed:
forward(message)
case .ignore:
break
case .connectionError(let error):
handleConnectionError(error)
case .streamError(let id, let error):
handleStreamError(id, error)
}
flushPendingEvents()
}
func send(_ message: Message) {
let result = stateMachine.send(message)
apply(effect: result.effect)
if case .succeed = result.result {
write(message)
} else {
handleSendError(result.result)
}
flushPendingEvents()
}
}
Pattern: Method-per-event (SwiftNIO style)
Model events as dedicated mutating methods (send/receive). Each method switches
on state and returns a typed result or actions. This mirrors
HTTP2ConnectionStateMachine in SwiftNIO HTTP/2.
struct ConnectionStateMachine: Sendable {
private enum State: Sendable {
case idle(IdleState)
case active(ActiveState)
case quiescing(QuiescingState)
}
private struct IdleState: Sendable { }
private struct ActiveState: Sendable { var streamID: Int }
private struct QuiescingState: Sendable { var lastStreamID: Int }
enum Action: Sendable {
case sendPreface
case closeStream(id: Int)
case ignore
}
private var state: State = .idle(IdleState())
mutating func receiveGoaway(lastStreamID: Int) -> [Action] {
switch state {
case .active(let data):
state = .quiescing(QuiescingState(lastStreamID: lastStreamID))
return lastStreamID < data.streamID ? [.closeStream(id: data.streamID)] : []
default:
return [.ignore]
}
}
mutating func sendPreface() -> [Action] {
switch state {
case .idle:
state = .active(ActiveState(streamID: 0))
return [.sendPreface]
default:
return [.ignore]
}
}
}
Notes:
- For complex inputs, use a nested
Requeststruct to keep method signatures small. - Share repeated transitions in private helpers that mutate state and return actions.
- Expose read-only query helpers (e.g.,
isConnected) as computed properties. - When each event returns a different shape, use per-event action enums
(
NextAction,FinishAction) instead of a single sharedAction. - Use a
.modifyingsentinel state to avoid CoW when updating associated data. - Trap impossible transitions with
preconditionFailureto keep invariants strict. - Track suspended and cancelled sets with placeholder IDs for out-of-order cancel.
- Model terminal outcomes with a
Terminationenum (e.g., finished vs failed). - Return optional actions when an event produces no side effects.
SwiftNIO-derived refinements
From NIOTypedHTTPClientUpgraderStateMachine and NIOTypedHTTPClientUpgradeHandler:
-
Keep the handler/coordinator and state machine strictly separated.
- State machine: pure transitions + typed actions only.
- Handler/coordinator: executes effects (pipeline mutation, async callbacks/futures, promise completion).
-
Use phase-specific states for transient workflow steps.
- Example shape:
.awaitingResponseHead -> .awaitingResponseEnd -> .upgrading -> .unbuffering -> .finished. - Encode buffered payload directly in state-associated data instead of side channels.
- Example shape:
-
Prefer small action enums per event over one giant action enum.
- Example:
ChannelActiveAction,WriteAction,ChannelReadAction. - This narrows allowed effects and makes illegal combinations unrepresentable.
- Example:
-
Use explicit “internal inconsistency” traps for impossible paths.
- Invariant violations should fail fast (
preconditionFailure/fatalError) in debug paths. - Recoverable protocol/runtime errors should be returned as typed actions/errors, not traps.
- Invariant violations should fail fast (
-
Model pipeline/IO backpressure states explicitly.
- When async upgrade/mutation is in-flight, transition to a buffering state and append reads.
- After completion, transition to unbuffering and drain deterministically.
-
Represent teardown/removal as a first-class event.
- Add a dedicated
handlerRemoved/terminatedevent with explicit action to fail pending promises/tasks. - This prevents dangling continuations when lifecycle ends unexpectedly.
- Add a dedicated
Examples
Channel-style
struct ChannelStateMachine<Element: Sendable> {
private struct SuspendedProducer: Hashable {
let id: UInt64
let continuation: UnsafeContinuation<Void, Never>?
let element: Element?
static func placeHolder(id: UInt64) -> SuspendedProducer {
SuspendedProducer(id: id, continuation: nil, element: nil)
}
}
private struct SuspendedConsumer: Hashable {
let id: UInt64
let continuation: UnsafeContinuation<Element?, any Error>?
static func placeHolder(id: UInt64) -> SuspendedConsumer {
SuspendedConsumer(id: id, continuation: nil)
}
}
private enum State {
case channeling(
suspendedProducers: OrderedSet<SuspendedProducer>,
cancelledProducers: Set<SuspendedProducer>,
suspendedConsumers: OrderedSet<SuspendedConsumer>,
cancelledConsumers: Set<SuspendedConsumer>
)
case terminated(Termination)
}
enum SendAction { case suspend; case resumeConsumer(UnsafeContinuation<Element?, any Error>?) }
enum NextAction { case suspend; case resumeProducer(UnsafeContinuation<Void, Never>?, Result<Element?, Error>) }
enum SendCancelledAction { case none; case resumeProducer(UnsafeContinuation<Void, Never>?) }
mutating func send() -> SendAction { /* switch state */ }
mutating func next() -> NextAction { /* switch state */ }
mutating func sendCancelled(producerID: UInt64) -> SendCancelledAction { /* placeholders */ }
}
CombineLatest upstream coordination
struct CombineLatestStateMachine<A: AsyncSequence, B: AsyncSequence, C: AsyncSequence?> {
typealias DownstreamContinuation = UnsafeContinuation<Result<(A.Element, B.Element, C.Element?)?, Error>, Never>
private enum State {
struct Upstream<Element> {
var continuation: UnsafeContinuation<Void, Error>?
var element: Element?
var isFinished: Bool
}
case initial(base1: A, base2: B, base3: C)
case waitingForDemand(task: Task<Void, Never>, upstreams: (Upstream<A.Element>, Upstream<B.Element>, Upstream<C.Element>), buffer: Deque<(A.Element, B.Element, C.Element?)>)
case combining(task: Task<Void, Never>, upstreams: (Upstream<A.Element>, Upstream<B.Element>, Upstream<C.Element>), downstreamContinuation: DownstreamContinuation, buffer: Deque<(A.Element, B.Element, C.Element?)>)
case upstreamsFinished(buffer: Deque<(A.Element, B.Element, C.Element?)>)
case upstreamThrew(error: Error)
case finished
case modifying
}
enum NextAction {
case startTask(A, B, C)
case resumeUpstreamContinuations([UnsafeContinuation<Void, Error>])
case resumeContinuation(DownstreamContinuation, Result<(A.Element, B.Element, C.Element?)?, Error>)
case resumeDownstreamContinuationWithNil(DownstreamContinuation)
}
mutating func next(for continuation: DownstreamContinuation) -> NextAction { /* switch state */ }
mutating func upstreamFinished(baseIndex: Int) -> UpstreamFinishedAction? { /* switch state */ }
}
Action-first transitions
- Determine actions inside the transition method.
- Perform side effects after the transition, outside locks or actor boundaries.
- This keeps the state machine deterministic and easy to test.
Concurrency integration
- Protect the state with a
Mutex(Synchronization) on iOS 18+ orNSRecursiveLockiOS 16+ or an actor. - Extract any continuation or callback while holding the lock.
- Resume continuations outside the lock to avoid deadlocks.
- Use
withTaskCancellationHandlerwhen storing continuations so cancellation can drive a state transition.
Testing guidance
- Test transitions directly by calling transition methods (e.g.,
sendPreface,receiveGoaway). - Assert the resulting
Actionarray and the new state. - Avoid time-based tests; make them deterministic.
Checklist
- State is an enum with associated values
- Each case only holds valid data for that state
- Per-state structs + capability protocols model valid operations
- Transition methods return actions, no side effects inside
- Strong types for event inputs and actions, no strings or bools
- Concurrency-safe: no user code or continuation resumes inside locks
- Driver applies effects, then forwards/emits inputs
- Tests cover each transition and invalid-transition behavior
Anti-patterns
- Separate booleans for state (
isActive,isFinished,hasStarted) - Shared optional fields that are only valid in some phases
- Side effects performed inside transition switches
- Resuming continuations while holding a lock
- "State" represented as
Stringor loosely-typed integers