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.
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):
@SagapublicclassOrderSaga{
@Autowiredtransient CommandGateway commands;
@Autowiredtransient 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")publicvoidon(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")publicvoidon(StockReserved evt){if(stockReserved)return;// idempotency guardthis.stockReserved = true;commands.send(new InitiatePaymentCommand(orderId,paymentId,evt.amount()));}
@SagaEventHandler(associationProperty = "paymentId")publicvoidon(PaymentConfirmed evt){if(paymentConfirmed)return;this.paymentConfirmed = true;if(paymentTimeout != null)scheduler.cancelSchedule(paymentTimeout);checkReadyToShip();}
@SagaEventHandler(associationProperty = "paymentId")publicvoidon(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.}
@SagapublicclassOrderSaga{
@Autowiredtransient CommandGateway commands;
@Autowiredtransient 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")publicvoidon(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")publicvoidon(StockReserved evt){if(stockReserved)return;// idempotency guardthis.stockReserved = true;commands.send(new InitiatePaymentCommand(orderId,paymentId,evt.amount()));}
@SagaEventHandler(associationProperty = "paymentId")publicvoidon(PaymentConfirmed evt){if(paymentConfirmed)return;this.paymentConfirmed = true;if(paymentTimeout != null)scheduler.cancelSchedule(paymentTimeout);checkReadyToShip();}
@SagaEventHandler(associationProperty = "paymentId")publicvoidon(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.}
@SagapublicclassOrderSaga{
@Autowiredtransient CommandGateway commands;
@Autowiredtransient 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")publicvoidon(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")publicvoidon(StockReserved evt){if(stockReserved)return;// idempotency guardthis.stockReserved = true;commands.send(new InitiatePaymentCommand(orderId,paymentId,evt.amount()));}
@SagaEventHandler(associationProperty = "paymentId")publicvoidon(PaymentConfirmed evt){if(paymentConfirmed)return;this.paymentConfirmed = true;if(paymentTimeout != null)scheduler.cancelSchedule(paymentTimeout);checkReadyToShip();}
@SagaEventHandler(associationProperty = "paymentId")publicvoidon(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):
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:
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.
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:
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.
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.
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:
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:
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.