Core Concepts

The handful of ideas that make up Skipper's programming model — workflows, actions, determinism, and durable state.

Skipper’s model is small. Once these few concepts click, the rest of the API is just detail.

Workflows

A workflow is the composition of a series of sequential or parallel steps that make up a business process. In code it is a class that extends Workflow and exposes at least one method annotated with @WorkflowMethod.

class TransferWorkflow : Workflow() {
  @WorkflowMethod
  suspend fun transfer(request: TransferRequest): Boolean { /* ... */ }
}
public class TransferWorkflow extends Workflow {
  @WorkflowMethod(returnType = Boolean.class)
  public CompletableFuture<Boolean> transfer(TransferRequest request) { /* ... */ }
}

The workflow method holds your business logic — the decisions, branches, and waits. It should read like a description of the process, not like infrastructure code.

Actions

An action is a function that does one unit of real work: sending an email, writing to a database, calling another service — anything with I/O or side effects. Actions live on classes that extend Actions, with methods annotated with @Execute.

class LedgerActions : Actions() {
  @Execute
  suspend fun debit(account: String) { /* ... */ }
}
public class LedgerActions extends Actions {
  @Execute
  public void debit(String account) { /* ... */ }
}

Actions are decoupled from workflow logic and are therefore reusable. A workflow calls actions through a typed handle:

private val ledger = actions<LedgerActions>()
private final LedgerActions ledger = actions(LedgerActions.class);

Action inputs and return types must be serializable so Skipper can persist them — a primitive, or a POJO/data class that Jackson can serialize (no top-level generics, so wrap a List/Map in a top-level class).

Determinism

Skipper provides durability by reconstructing a workflow’s progress when it resumes — for example after a wait, a retry, or a process restart. To do this reliably, the workflow method must be deterministic: given the same inputs, the code outside of action calls must always take the same path.

In practice this means a few operations must not happen directly inside workflow code:

  • Reading the current time (Instant.now() and similar).
  • Generating random values or UUIDs.
  • Any other I/O or non-deterministic call.

Anything non-deterministic belongs in an action (or a checkpoint, below). This is the single most important rule when writing workflows.

Don’t wrap action calls in a catch for Throwable/Error — Skipper uses control-flow signals internally, and swallowing them leads to undefined behavior. Catch the specific exception types you expect instead.

Durable state & checkpoints

Fields you want to persist across the life of a workflow are marked with @StateField. Skipper saves and restores them so the workflow can pause and resume without losing data.

@StateField var isApproved: Boolean? = null
@StateField Boolean isApproved;

When a workflow resumes, completed actions are not run again — their results were persisted and are returned from the saved record. For non-deterministic work that isn’t a natural action, wrap it in a checkpoint, which runs once and persists its effect:

checkpoint { total = computeExpensiveTotal() }
checkpoint(() -> { total = computeExpensiveTotal(); });

This is also how you safely mutate a @StateField from workflow code when a signal can mutate the same field — see Workflow API.

Putting it together

A workflow coordinates actions, persists state, and can wait for the outside world — and Skipper makes the whole thing durable. The next page builds a complete example end to end.