Your First State Machine

Build, invoke, signal, and test a complete Skipper state machine from scratch.

This builds the moderated-support-ticket machine from the Overview, broken into the steps you’d follow in your own service.

1. Declare the state enum

States are plain Kotlin enums — there’s no special base type.

enum class TicketState { OPEN, AWAITING_AGENT, RESOLVED, ESCALATED, EXPIRED }

2. Declare the event hierarchy

Events extend StateMachineEvent. Use a sealed root, object for events without payload, and data class for events that carry data.

sealed class TicketEvent : StateMachineEvent() {
    data class AgentAssigned(val agentId: Long) : TicketEvent()
    data class Resolution(val notes: String) : TicketEvent()
    object Escalate : TicketEvent()
}

StateMachineEvent provides class-based equals/hashCode so Skipper’s serialization round-trip passes for object subtypes. data class subtypes override with property-based equality automatically.

3. Declare the input type

The input is the data your workflow needs at every step; it’s passed to every handler and hook. It must be a serializable POJO/data class — the same rules as any Skipper @WorkflowMethod argument (no top-level generics; define equals/hashCode).

data class TicketInput(val ticketId: Long, val reporterId: Long)

4. Define the actions

All side effects — DB writes, RPCs, notifications — go through Skipper Actions. Actions are checkpointed, so they don’t re-execute on replay.

class TicketActions : Actions() {
    @Inject private lateinit var dao: TicketDao
    @Inject private lateinit var notifier: TicketNotifier

    @Execute suspend fun markAwaitingAgent(id: Long) { dao.updateStatus(id, "AWAITING_AGENT") }
    @Execute suspend fun markResolved(id: Long, notes: String) { dao.resolve(id, notes) }
    @Execute suspend fun markEscalated(id: Long) { dao.escalate(id) }
    @Execute suspend fun markExpired(id: Long) { dao.expire(id) }
    @Execute suspend fun sendReminder(reporterId: Long) { notifier.reminder(reporterId) }
}

5. Implement the state machine

Subclass SkipperStateMachine<StateT, EventT, InputT> with your three type parameters and the initial state, and override define(). Keep define() a flat list of state(S) { handleS() } calls so it reads like a state table, and put each state’s behavior in a private extension function on StateBuilder.

class TicketStateMachine :
    SkipperStateMachine<TicketState, TicketEvent, TicketInput>(TicketState.OPEN) {

    private val actions = actions<TicketActions>()

    override fun StateMachineBuilder<TicketState, TicketEvent, TicketInput>.define() {
        state(TicketState.OPEN) { handleOpen() }
        state(TicketState.AWAITING_AGENT) { handleAwaitingAgent() }
        state(TicketState.RESOLVED) { terminal() }
        state(TicketState.ESCALATED) { terminal() }
        state(TicketState.EXPIRED) { terminal() }
    }

    private fun StateBuilder<TicketState, TicketEvent, TicketInput>.handleOpen() {
        on<TicketEvent.AgentAssigned> { _, input ->
            actions.markAwaitingAgent(input.ticketId)
            transitionTo(TicketState.AWAITING_AGENT)
        }
        after(Duration.ofHours(24)) { input -> actions.sendReminder(input.reporterId) }
        timeout(Duration.ofDays(5)) { input ->
            actions.markExpired(input.ticketId)
            transitionTo(TicketState.EXPIRED)
        }
    }

    private fun StateBuilder<TicketState, TicketEvent, TicketInput>.handleAwaitingAgent() {
        on<TicketEvent.Resolution> { event, input ->
            actions.markResolved(input.ticketId, event.notes)
            transitionTo(TicketState.RESOLVED)
        }
        on<TicketEvent.Escalate> { _, input ->
            actions.markEscalated(input.ticketId)
            transitionTo(TicketState.ESCALATED)
        }
        timeout(Duration.ofDays(2)) { input ->
            actions.markEscalated(input.ticketId)
            transitionTo(TicketState.ESCALATED)
        }
    }
}

6. Invoke the state machine

A state machine is a Skipper workflow, so you create and signal it through the standard IWorkflowFactory (see Invoking Workflows for how to obtain one). Use the workflowId<SM, IdT>() helper to derive a stable workflow id from a domain identifier, so every caller — creator, signaller, querier — produces the same id.

class TicketController(private val factory: IWorkflowFactory) {

    suspend fun openTicket(ticketId: Long, reporterId: Long) {
        val id = SkipperStateMachine.workflowId<TicketStateMachine, Long>(ticketId)
        val sm = factory.builder<TicketStateMachine>(id).build()
        sm.execute(TicketInput(ticketId, reporterId))
    }

    fun assignAgent(ticketId: Long, agentId: Long) {
        val id = SkipperStateMachine.workflowId<TicketStateMachine, Long>(ticketId)
        factory<TicketStateMachine>(id).sendEvent(TicketEvent.AgentAssigned(agentId))
    }

    fun currentState(ticketId: Long): TicketState {
        val id = SkipperStateMachine.workflowId<TicketStateMachine, Long>(ticketId)
        return factory<TicketStateMachine>(id).getState()
    }
}

.build() vs .runAsync().build(): the default .build() runs via Skipper’s in-memory queue (with a DB backup) and is picked up in milliseconds — use it when the caller will immediately poll the machine for a state change. .runAsync().build() enqueues into the scheduler only (~1s pickup) — use it for fire-and-forget.

7. Test the state machine

State machines are tested with Skipper’s SkipperTest helpers — drive the machine through events and assert on the resulting state. (See Testing for the harness basics.)

class TicketStateMachineTest : SkipperTest() {

    @Bind private val dao: TicketDao = mock()
    @Bind private val notifier: TicketNotifier = mock()

    @Test
    fun `agent assignment then resolution transitions to RESOLVED`() = runBlocking {
        val ticketId = 42L
        val id = SkipperStateMachine.workflowId<TicketStateMachine, Long>(ticketId)
        val sm = workflowBuilder(TicketStateMachine::class.java, id).build()

        sm.execute(TicketInput(ticketId, reporterId = 7))
        sm.sendEvent(TicketEvent.AgentAssigned(agentId = 99))
        sm.sendEvent(TicketEvent.Resolution(notes = "fixed"))

        helper.waitForWorkflowToComplete()
        assertThat(sm.getState()).isEqualTo(TicketState.RESOLVED)
    }
}

For fast tests of transition logic alone — handler dispatch, guards, transition results — you can build a StateMachineBuilder directly and walk events through its handler chain, without spinning up Skipper’s execution engine. See Persistence & Replay for why that works.

Next steps

  • DSL Reference — every method on the builders.
  • Middleware — cross-cutting hooks for metrics, audit, alerting.
  • Admin UI — inspect any instance in the browser.