Overview

A declarative Kotlin DSL for building durable state machines on top of Skipper — states, events, transitions, timers, and a built-in admin UI.

Skipper State Machine is a companion library: a declarative Kotlin DSL for building durable state machines on top of Skipper. You declare states, events, and transitions; the framework handles deterministic event processing, compact persistence, checkpointed side effects, and an admin UI that visualizes every transition an instance ever made.

If your workflow is naturally described as “in state X, when event Y arrives, do Z and move to state W” — and you’d otherwise express that as a chain of waitUntil calls and @SignalMethods branching on @StateField flags — this library is for you.

Show me the code

A moderated support ticket: it opens in OPEN, waits for an agent, and reaches one of three terminal states. A reminder fires after 24 hours; the whole machine times out after 5 days.

after 24h → reminder AgentAssigned timeout 5d Resolution Escalate or timeout 2d OPEN AWAITING_AGENT RESOLVED ESCALATED EXPIRED

Dark arrows are events (external signals); orange arrows are time-driven (timeout / after). RESOLVED, ESCALATED, and EXPIRED are terminal (double border).

// 1. States — a plain enum
enum class TicketState { OPEN, AWAITING_AGENT, RESOLVED, ESCALATED, EXPIRED }

// 2. Events — a sealed hierarchy; `object` for no payload, `data class` for payload
sealed class TicketEvent : StateMachineEvent() {
    data class AgentAssigned(val agentId: Long) : TicketEvent()
    data class Resolution(val notes: String) : TicketEvent()
    object Escalate : TicketEvent()
}

// 3. Input — passed to every handler and hook
data class TicketInput(val ticketId: Long, val reporterId: Long)

// 4. State machine
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)
        }
    }
}

That’s the whole machine — no waitUntil, no manual state-field branching, no hand-rolled history. Skipper persists the event log and replays it deterministically on every restart, and the built-in Admin UI shows each instance’s full state and event history, every transition, pending timer deadlines, an auto-generated Mermaid sequence diagram, and a form to send events manually.

Why use this instead of a raw workflow?

A raw Skipper workflow describes behavior imperatively with waitUntil and @SignalMethod. That’s perfect for linear flows, but gets noisy when the workflow branches on the order of incoming signals, when many states share timeout/reminder semantics, or when operators need to see “what state is this in right now” without reading code.

ConcernRaw workflowState Machine
Express “in state X, on event Y, do Z”if (state == X) { waitUntil(...); ... } chainsstate(X) { on<Y> { ... transitionTo(Z) } }
State timeout / remindersManual waitUntil(condition, timeout) plumbingtimeout(d) { ... } / after(d) { ... }
History (states, events, transitions)DIY via @StateField listsBuilt-in, durable, admin-viewable
Operator UIWorkflow action history onlyDedicated state view + Mermaid diagram
Cross-cutting metrics / auditHand-rolled in handler bodiesMiddleware hooks

Key concepts

  • States — a finite set of situations, modeled as a Kotlin enum.
  • Events — the inputs that drive the machine, modeled as a sealed-class hierarchy (object for no payload, data class for payload). All extend StateMachineEvent.
  • Transitions — the rules: “in state X, on event Y, do Z and move to W.”
  • HooksonEntry / onExit callbacks that fire when entering or leaving a state.
  • Timerstimeout(d) drives a transition if no event arrives; after(d) runs a side effect at a point inside a state without changing state.
  • Middleware — cross-cutting lifecycle hooks for metrics, audit, and alerting.
  • Terminal states — entering a state marked terminal() completes the underlying workflow.

How it relates to Skipper

A state machine is a thin layer over a single Skipper workflow. The framework compiles your DSL into one workflow:

  • One @WorkflowMethodexecute(input) runs the event loop until a terminal state.
  • One @SignalMethodsendEvent(event) appends to the durable event log and wakes the workflow.
  • A few @QueryMethods — getState(), isTerminal(), getAdminSnapshot().
  • A single @StateField holding a compact, compressed blob of the event log and metadata.

Because everything funnels through Skipper, all the standard guarantees apply: durable execution, action checkpointing, retries, compensation. A SkipperStateMachine subclass is a Workflow, so you can still add your own @StateFields, @QueryMethods, and Actions when the DSL alone isn’t enough.

TransitionResult: transitionTo vs stay vs ignore

Every event handler returns a TransitionResult. The difference matters:

ResultHooks (onExit/onEntry)MiddlewareTimers resetUse when
transitionTo(S)yesyesyesMoving to a different state
stay()yes (re-enter same state)yesyesActivity happened, same state — e.g. reset an inactivity timer
ignore()nononoThe event is irrelevant here — a silent no-op

stay() is a full transition back to self; ignore() is a silent no-op. Confusing the two is a common subtle bug.

Replay safety: @StateField vs local var

The single most important rule:

Do not use @StateField for variables that drive transition decisions (counters, accumulators, flags). Use plain local var fields.

Skipper re-executes the workflow on every restart and event arrival; the event loop re-processes the entire persisted event log from index 0. Local var fields rebuild deterministically from the event sequence each time. @StateField values are restored from persisted state and can diverge. Use @StateField only for data you expose via @QueryMethod or persist alongside Actions. See Persistence & Replay.

Installation

State Machine ships as a separate module on top of Skipper. Add the dependency (coordinates are illustrative until the first public release), and the admin module if you want the UI:

// build.gradle.kts
dependencies {
  implementation("dev.skipper:skipper-state-machine:0.1.0")
  implementation("dev.skipper:skipper-state-machine-admin:0.1.0") // optional: admin UI
}

It uses the same Kotlin AllOpen plugin Skipper requires, so your workflow and action classes are already open. To enable the admin UI, register StateMachineAdminResource (a JAX-RS resource) with your server alongside Skipper’s own AdminResource — see Admin UI.

Next steps