Workflows, Now in Axoniq Framework

A Workflow is a long-running business process expressed as ordinary, imperative code. You write a method. Inside it, you call services, wait for external events, branch on results, retry, sleep, fan out in parallel, using normal Java or Kotlin control flow. The Workflow engine turns every one of those steps into durable events in your event store.

The code looks like this:

@Workflow(startOnEvent = "OrderPlaced", idProperty = "orderId")
public void execute(SimpleWorkflowContext ctx) {

    ctx.awaitExecute("reserveStock",    inventory::reserve);
    ctx.awaitExecute("initiatePayment", payment::initiate,
                     RetryPolicy.maxRetries(3));

    ctx.awaitEvent("awaitConfirmation",
                   PaymentConfirmed.class,
                   Duration.ofMinutes(15));

    ctx.awaitExecute("shipOrder", shipping::ship);
}
@Workflow(startOnEvent = "OrderPlaced", idProperty = "orderId")
public void execute(SimpleWorkflowContext ctx) {

    ctx.awaitExecute("reserveStock",    inventory::reserve);
    ctx.awaitExecute("initiatePayment", payment::initiate,
                     RetryPolicy.maxRetries(3));

    ctx.awaitEvent("awaitConfirmation",
                   PaymentConfirmed.class,
                   Duration.ofMinutes(15));

    ctx.awaitExecute("shipOrder", shipping::ship);
}
@Workflow(startOnEvent = "OrderPlaced", idProperty = "orderId")
public void execute(SimpleWorkflowContext ctx) {

    ctx.awaitExecute("reserveStock",    inventory::reserve);
    ctx.awaitExecute("initiatePayment", payment::initiate,
                     RetryPolicy.maxRetries(3));

    ctx.awaitEvent("awaitConfirmation",
                   PaymentConfirmed.class,
                   Duration.ofMinutes(15));

    ctx.awaitExecute("shipOrder", shipping::ship);
}

One method. No state fields. No scheduler wiring. No manual retry implementation. The engine handles persistence, crash recovery, retries, timeouts and replay. Every step you take leaves a permanent, queryable trail of events in the event store.

That last part is the punchline: a Workflow is not just durable execution. It is a business ledger written into the system of record, forever.

Why: The State Before

Axoniq has always given teams an unmatched system of record: an event-sourced ledger of every business decision the system has ever made. That record is what powers audits, replays, projections, and increasingly the kind of analytics and AI workloads that need to look back over months or years of history.

But the on-ramp was steep.

  • Event sourcing works, but the ramp is steep. You need senior architects, deep domain modeling, and a willingness to think in events from day one.

  • Developers want simple, imperative code. Most engineers reach for if, for, try/catch, and a linear flow. Sagas spread that flow across a dozen @SagaEventHandler methods, manual scheduler tokens, and idempotency flags.

  • We still want the full event-sourced record. Whatever we do to simplify the developer experience, we don't want to lose the ledger.

Here is the same order-fulfillment process as an Axon 4 Saga, the before, next to a Workflow:

With Axon 4 Saga (excerpt 11 handlers, 9 state fields, manual scheduler):

@Saga
public class OrderSaga {

    @Autowired transient CommandGateway commands;
    @Autowired transient EventScheduler scheduler;

    private String orderId;
    private String paymentId;
    private String shipmentId;
    private ScheduleToken paymentTimeout;
    private ScheduleToken shipmentTimeout;
    private boolean stockReserved = false;
    private boolean paymentConfirmed = false;
    private boolean shipmentDispatched = false;
    private int paymentRetries = 0;

    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void on(OrderPlaced evt) {
        this.orderId = evt.orderId();
        this.paymentId = UUID.randomUUID().toString();
        associateWith("paymentId", paymentId);
        commands.send(new ReserveStockCommand(orderId, evt.items()));
        paymentTimeout = scheduler.schedule(Duration.ofMinutes(15),
                                            new PaymentTimedOut(orderId, paymentId));
    }

    @SagaEventHandler(associationProperty = "orderId")
    public void on(StockReserved evt) {
        if (stockReserved) return; // idempotency guard
        this.stockReserved = true;
        commands.send(new InitiatePaymentCommand(orderId, paymentId, evt.amount()));
    }

    @SagaEventHandler(associationProperty = "paymentId")
    public void on(PaymentConfirmed evt) {
        if (paymentConfirmed) return;
        this.paymentConfirmed = true;
        if (paymentTimeout != null) scheduler.cancelSchedule(paymentTimeout);
        checkReadyToShip();
    }

    @SagaEventHandler(associationProperty = "paymentId")
    public void on(PaymentRejected evt) {
        if (paymentRetries++ < 3) {
            commands.send(new InitiatePaymentCommand(orderId, paymentId, evt.amount()));
            return;
        }
        commands.send(new ReleaseStockCommand(orderId));
        commands.send(new CancelOrderCommand(orderId, "payment_rejected"));
        SagaLifecycle.end();
    }

    // … plus PaymentTimedOut, ShipmentDispatched, ShipmentFailed,
    //     ShipmentTimedOut, OrderCanceledByCustomer, and a checkReadyToShip()
    //     helper that has to keep all those flags in sync.
}
@Saga
public class OrderSaga {

    @Autowired transient CommandGateway commands;
    @Autowired transient EventScheduler scheduler;

    private String orderId;
    private String paymentId;
    private String shipmentId;
    private ScheduleToken paymentTimeout;
    private ScheduleToken shipmentTimeout;
    private boolean stockReserved = false;
    private boolean paymentConfirmed = false;
    private boolean shipmentDispatched = false;
    private int paymentRetries = 0;

    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void on(OrderPlaced evt) {
        this.orderId = evt.orderId();
        this.paymentId = UUID.randomUUID().toString();
        associateWith("paymentId", paymentId);
        commands.send(new ReserveStockCommand(orderId, evt.items()));
        paymentTimeout = scheduler.schedule(Duration.ofMinutes(15),
                                            new PaymentTimedOut(orderId, paymentId));
    }

    @SagaEventHandler(associationProperty = "orderId")
    public void on(StockReserved evt) {
        if (stockReserved) return; // idempotency guard
        this.stockReserved = true;
        commands.send(new InitiatePaymentCommand(orderId, paymentId, evt.amount()));
    }

    @SagaEventHandler(associationProperty = "paymentId")
    public void on(PaymentConfirmed evt) {
        if (paymentConfirmed) return;
        this.paymentConfirmed = true;
        if (paymentTimeout != null) scheduler.cancelSchedule(paymentTimeout);
        checkReadyToShip();
    }

    @SagaEventHandler(associationProperty = "paymentId")
    public void on(PaymentRejected evt) {
        if (paymentRetries++ < 3) {
            commands.send(new InitiatePaymentCommand(orderId, paymentId, evt.amount()));
            return;
        }
        commands.send(new ReleaseStockCommand(orderId));
        commands.send(new CancelOrderCommand(orderId, "payment_rejected"));
        SagaLifecycle.end();
    }

    // … plus PaymentTimedOut, ShipmentDispatched, ShipmentFailed,
    //     ShipmentTimedOut, OrderCanceledByCustomer, and a checkReadyToShip()
    //     helper that has to keep all those flags in sync.
}
@Saga
public class OrderSaga {

    @Autowired transient CommandGateway commands;
    @Autowired transient EventScheduler scheduler;

    private String orderId;
    private String paymentId;
    private String shipmentId;
    private ScheduleToken paymentTimeout;
    private ScheduleToken shipmentTimeout;
    private boolean stockReserved = false;
    private boolean paymentConfirmed = false;
    private boolean shipmentDispatched = false;
    private int paymentRetries = 0;

    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void on(OrderPlaced evt) {
        this.orderId = evt.orderId();
        this.paymentId = UUID.randomUUID().toString();
        associateWith("paymentId", paymentId);
        commands.send(new ReserveStockCommand(orderId, evt.items()));
        paymentTimeout = scheduler.schedule(Duration.ofMinutes(15),
                                            new PaymentTimedOut(orderId, paymentId));
    }

    @SagaEventHandler(associationProperty = "orderId")
    public void on(StockReserved evt) {
        if (stockReserved) return; // idempotency guard
        this.stockReserved = true;
        commands.send(new InitiatePaymentCommand(orderId, paymentId, evt.amount()));
    }

    @SagaEventHandler(associationProperty = "paymentId")
    public void on(PaymentConfirmed evt) {
        if (paymentConfirmed) return;
        this.paymentConfirmed = true;
        if (paymentTimeout != null) scheduler.cancelSchedule(paymentTimeout);
        checkReadyToShip();
    }

    @SagaEventHandler(associationProperty = "paymentId")
    public void on(PaymentRejected evt) {
        if (paymentRetries++ < 3) {
            commands.send(new InitiatePaymentCommand(orderId, paymentId, evt.amount()));
            return;
        }
        commands.send(new ReleaseStockCommand(orderId));
        commands.send(new CancelOrderCommand(orderId, "payment_rejected"));
        SagaLifecycle.end();
    }

    // … plus PaymentTimedOut, ShipmentDispatched, ShipmentFailed,
    //     ShipmentTimedOut, OrderCanceledByCustomer, and a checkReadyToShip()
    //     helper that has to keep all those flags in sync.
}

With a Workflow API (1 method, 0 state fields, durable by default):

@Workflow(startOnEvent = "OrderPlaced", idProperty = "orderId")
public void execute(SimpleWorkflowContext ctx) {

    ctx.awaitExecute("reserveStock",    inventory::reserve);
    ctx.awaitExecute("initiatePayment", payment::initiate,
                     RetryPolicy.maxRetries(3));

    ctx.awaitEvent("awaitConfirmation",
                   PaymentConfirmed.class,
                   Duration.ofMinutes(15));

    ctx.awaitExecute("shipOrder", shipping::ship);
}
@Workflow(startOnEvent = "OrderPlaced", idProperty = "orderId")
public void execute(SimpleWorkflowContext ctx) {

    ctx.awaitExecute("reserveStock",    inventory::reserve);
    ctx.awaitExecute("initiatePayment", payment::initiate,
                     RetryPolicy.maxRetries(3));

    ctx.awaitEvent("awaitConfirmation",
                   PaymentConfirmed.class,
                   Duration.ofMinutes(15));

    ctx.awaitExecute("shipOrder", shipping::ship);
}
@Workflow(startOnEvent = "OrderPlaced", idProperty = "orderId")
public void execute(SimpleWorkflowContext ctx) {

    ctx.awaitExecute("reserveStock",    inventory::reserve);
    ctx.awaitExecute("initiatePayment", payment::initiate,
                     RetryPolicy.maxRetries(3));

    ctx.awaitEvent("awaitConfirmation",
                   PaymentConfirmed.class,
                   Duration.ofMinutes(15));

    ctx.awaitExecute("shipOrder", shipping::ship);
}

The Saga's state fields, manual idempotency guards, and explicit scheduler tokens are gone. They are not missing; they are the engine's job now, and they are recorded as events instead of stored as transient flags.

How it Works: It's 'Just' Events

The core insight: your workflow emits events, and the engine rebuilds the workflow's state entirely from those events.

When the engine runs ctx.awaitExecute("reserveStock", ...), it writes a ReserveStockStarted event, runs the action, then writes a ReserveStockCompleted event with the result. When it hits ctx.awaitEvent(...), it writes an AwaitPaymentStarted event and suspends (nothing is running on the JVM) until a matching event arrives, at which point it writes AwaitPaymentCompleted and resumes.

A single workflow run produces a stream that looks like this:




These are not your typical, technical workflow events, rather they all carry domain-specific meaning that directly map to the business process.

Three things follow from this design:

  1. Crash recovery is free. If the JVM dies between events 5 and 6, the engine restarts and replays the stream. It sees InitiatePaymentStarted without a matching Completed, and resumes from there. No special checkpointing API.

  2. Workflows are not silos. Because every step is a plain Framework event, anyone can subscribe with a classic @EventHandler and build a live view: a shipping dashboard, a BPMN tracker, an SLA monitor. No special integration:

    @Component
    public class ShippingDashboard {
    
        @EventHandler
        public void on(PackingCompleted evt)    { board.markNode(evt.orderId(), "packing", DONE); }
    
        @EventHandler
        public void on(LabelPrintedCompleted evt) { board.markNode(evt.orderId(), "label", DONE); }
    
        @EventHandler
        public void on(ShippedCompleted evt)    { board.markNode(evt.orderId(), "shipped", DONE); }
    }
    @Component
    public class ShippingDashboard {
    
        @EventHandler
        public void on(PackingCompleted evt)    { board.markNode(evt.orderId(), "packing", DONE); }
    
        @EventHandler
        public void on(LabelPrintedCompleted evt) { board.markNode(evt.orderId(), "label", DONE); }
    
        @EventHandler
        public void on(ShippedCompleted evt)    { board.markNode(evt.orderId(), "shipped", DONE); }
    }
    @Component
    public class ShippingDashboard {
    
        @EventHandler
        public void on(PackingCompleted evt)    { board.markNode(evt.orderId(), "packing", DONE); }
    
        @EventHandler
        public void on(LabelPrintedCompleted evt) { board.markNode(evt.orderId(), "label", DONE); }
    
        @EventHandler
        public void on(ShippedCompleted evt)    { board.markNode(evt.orderId(), "shipped", DONE); }
    }
  3. The audit trail is the source of truth. When someone asks "what happened on order #12847 last March?", the answer is in the event store, not in a log file that has long since rotated.

API: The Full Vocabulary in Five Primitives

The whole DSL is a small set of named primitives. They compose with normal Java/Kotlin control flow.

Primitive

Kind

Variants

What it does

execute

execute

awaitExecute (sync, blocking) · execute (async, non-blocking)

Run an action and durably record its input and output. The blocking variant returns the typed result; the async variant returns a WorkflowStepResult you can pass to combinators.

awaitEvent

wait

awaitEvent (sync, on event) · waitFor (async, on event) · sleep (on duration)

Suspend the workflow until something happens: an external event arrives, or a duration elapses. All three are durable: the workflow holds nothing on the JVM while it waits, and resumes cleanly after a crash.

match

combinator

anyMatch (race) · allMatch (fan-in) · noneMatch (guard)

Orchestrate multiple steps running in parallel: race to the first that matches, fan-in until all match, or guard that none of them match. The building block for parallel branches, timeouts-vs-events, and compensating gates.

setPayload

state

Merge values into the workflow payload, persisted across steps.

terminate

lifecycle

fail (error) · cancel (business reason)

End the workflow explicitly: fail for errors, cancel for an intentional business outcome.

A realistic Kotlin example, the whole idea in five lines:

loanWorkflow = workflow {
    creditScore = execute("check-credit") { creditBureau.getScore(customerId) }

    if (creditScore < 650) return failed("Credit too low")

    documents = waitForEvent("DocumentsUploaded", timeout = 7.days)
    approval  = execute("underwriter-review") { underwriter.review(creditScore, documents) }

    return success(approval.contractId

loanWorkflow = workflow {
    creditScore = execute("check-credit") { creditBureau.getScore(customerId) }

    if (creditScore < 650) return failed("Credit too low")

    documents = waitForEvent("DocumentsUploaded", timeout = 7.days)
    approval  = execute("underwriter-review") { underwriter.review(creditScore, documents) }

    return success(approval.contractId

loanWorkflow = workflow {
    creditScore = execute("check-credit") { creditBureau.getScore(customerId) }

    if (creditScore < 650) return failed("Credit too low")

    documents = waitForEvent("DocumentsUploaded", timeout = 7.days)
    approval  = execute("underwriter-review") { underwriter.review(creditScore, documents) }

    return success(approval.contractId

Note what is not there:

  • No graph DSL, no BPMN editor, no JSON state machine.

  • No checkpoint API, no manual saveState() calls.

  • No retry annotation soup. Retries and timeouts are arguments to the primitives.

The Kotlin DSL is first-class, not a Java afterthought. 30.seconds, named parameters, extension functions on the workflow Kontext, co-designed with Kotlin idioms.

Build Your Own DSL on Top

The five primitives are the foundation, not the ceiling. You can extend SimpleWorkflowContext and expose business verbs, and the workflow then reads like plain English:

@Workflow(startOnEvent = "PurchaseRequestSubmitted", idProperty = "requestId")
public void execute(ApprovalWorkflowContext ctx) {

    if (!ctx.requestApproval("team-lead", Duration.ofDays(3))) {
        ctx.escalate("department-head");
    }

    if (ctx.amount() > 10_000) {
        ctx.requireSecondApproval("cfo", Duration.ofDays(2));
    }

    ctx.notifyOutcome(ctx.requester(), "approved");
}
@Workflow(startOnEvent = "PurchaseRequestSubmitted", idProperty = "requestId")
public void execute(ApprovalWorkflowContext ctx) {

    if (!ctx.requestApproval("team-lead", Duration.ofDays(3))) {
        ctx.escalate("department-head");
    }

    if (ctx.amount() > 10_000) {
        ctx.requireSecondApproval("cfo", Duration.ofDays(2));
    }

    ctx.notifyOutcome(ctx.requester(), "approved");
}
@Workflow(startOnEvent = "PurchaseRequestSubmitted", idProperty = "requestId")
public void execute(ApprovalWorkflowContext ctx) {

    if (!ctx.requestApproval("team-lead", Duration.ofDays(3))) {
        ctx.escalate("department-head");
    }

    if (ctx.amount() > 10_000) {
        ctx.requireSecondApproval("cfo", Duration.ofDays(2));
    }

    ctx.notifyOutcome(ctx.requester(), "approved");
}

No engine primitives in sight, just the business story. One engine, many languages.

Workflows vs. Other Workflow Engines

There are several mature workflow engines in the market today. They all give you durable execution: the ability to run a long-lived process and recover it after a crash. That part is table stakes; it is not what differentiates Workflows. The difference is what survives after the workflow ends.


Other workflow engines

Workflows · Axon

Durable execution

Yes

Yes

Event-sourced business history

No. The execution log is for replay, not for business auditing; retention-bound

First-class. Published events are your business timeline, forever

Queryable after retention

Limited. History is purged or trimmed after the retention window (days to weeks)

Forever. Events live in the event store

Programming model

SDK-style workflows or handler/actor abstractions, often with determinism constraints

Imperative Java/Kotlin. Plain if, for, try/catch

Custom DSL for workflows

Generally no. A generic SDK API, no domain primitives beyond it

First-class. Named primitives, build your own DSL

Backbone for agentic AI memory

Unclear. No first-class event trail to power agent reasoning

Ready. Events are the agent's memory of every decision

The shortest version: most workflow engines are black boxes. They execute the workflow and discard the trail. Workflows on Axon is a glass box: same workflow, every step becomes a permanent, queryable event.

After the retention window:

  • Black box (other workflow engines): workflow ran, end state preserved, intermediate trail gone.

  • Glass box (Workflows · Axon): every started/completed pair is still in the event store. Audit it. Replay it. Build a new projection on it three years later. Feed it into an LLM as memory.

For systems where the history is the asset (financial services, healthcare, supply chain, anything compliance-adjacent, anything that will eventually have an AI agent reasoning over it), that difference compounds over time.

Closing Thoughts: Simple Code, Durable Memory, Everything Recorded

Workflows is what happens when you stop forcing developers to choose between easy and event-sourced. Imperative code on the surface, an unbroken event ledger underneath. The workflow you write today becomes the audit trail, the projection source, and the agent memory you will need in three years, without you having to do anything extra.

Workflows are the tip of the iceberg. Axoniq Framework is the depth beneath: the power to build robust enterprise systems that go beyond what any SDK can deliver.

Where it stands today:

  • Q1 · Preview Release (now). Core primitives, crash recovery, Spring Boot integration, Kotlin DSL, all shipping. First-look release for early adopters to build against and give feedback.

  • Q2 · Hardening. Hibernating workflows, exactly-once QoS, test fixtures further improve the API.

  • Q3 · Production Ready. General availability. Battle-tested, documented, supported. Safe for business-critical processes.

Simple code. Durable memory. Everything recorded.

If you have a process you have been putting off because the saga felt too heavy, or because you wished you had event sourcing but didn't want the on-ramp, this is the entry point we have been waiting to give you. Try the preview, write a workflow, and look at what ends up in your event store.

👉 Get started: docs.axoniq.io/axon-framework-reference/development/workflows

🤖 Playable Workflow simulator**:** https://www.axoniq.io/static/playable-workflow-simulator--the-agent-axon

Join the Thousands of Developers

Already Building with Axon in Open Source

Join the Thousands of Developers

Already Building with Axon in Open Source

Join the Thousands of Developers

Already Building with Axon in Open Source