← Back to blog

Workflows Now Generally Available in Axoniq Framework 5.4

Workflows are now generally available in Axoniq Framework 5.4. Write long-running business processes as ordinary Java or Kotlin code, and let the engine record every step as events. GA adds sharding and failover, versioning for running workflows, and a testing fixture.

The public preview introduced a simple idea: write a long-running business process as ordinary code, and let the engine record its progress as events. Workflows are now generally available in Axoniq Framework 5.4 and covered under the commercial license, with support for scaling across application instances, evolving running workflows, and testing their behavior.

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

The code looks like this:

@Workflow(startOnEventClass = OrderPlaced.class,
          idProperty = "orderId", workflowNamespace = "orders")
public void execute(SimpleWorkflowContext ctx) {
    var orderId = (String) ctx.workflowPayload().get("orderId");
    ctx.awaitExecute("reserveStock",
                     Map.of("orderId", orderId),
                     inventory::reserve);
    var confirmation = ctx.waitForEvent(
            "awaitConfirmation", PaymentConfirmed.class,
            associate(payloadProperty("orderId"), equalsTo(orderId)),
            step -> step.timeout(Duration.ofMinutes(15)));
    ctx.awaitExecute("initiatePayment",
                     Map.of("orderId", orderId),
                     payment::initiate,
                     step -> step.retryPolicy(RetryPolicy.maxRetries(3)));
    confirmation.await();
    if (!confirmation.success()) {
        ctx.cancel("Payment confirmation was not received");
    }
    ctx.awaitExecute("shipOrder",
                     Map.of("orderId", orderId),
                     shipping::ship);
}
@Workflow(startOnEventClass = OrderPlaced.class,
          idProperty = "orderId", workflowNamespace = "orders")
public void execute(SimpleWorkflowContext ctx) {
    var orderId = (String) ctx.workflowPayload().get("orderId");
    ctx.awaitExecute("reserveStock",
                     Map.of("orderId", orderId),
                     inventory::reserve);
    var confirmation = ctx.waitForEvent(
            "awaitConfirmation", PaymentConfirmed.class,
            associate(payloadProperty("orderId"), equalsTo(orderId)),
            step -> step.timeout(Duration.ofMinutes(15)));
    ctx.awaitExecute("initiatePayment",
                     Map.of("orderId", orderId),
                     payment::initiate,
                     step -> step.retryPolicy(RetryPolicy.maxRetries(3)));
    confirmation.await();
    if (!confirmation.success()) {
        ctx.cancel("Payment confirmation was not received");
    }
    ctx.awaitExecute("shipOrder",
                     Map.of("orderId", orderId),
                     shipping::ship);
}
@Workflow(startOnEventClass = OrderPlaced.class,
          idProperty = "orderId", workflowNamespace = "orders")
public void execute(SimpleWorkflowContext ctx) {
    var orderId = (String) ctx.workflowPayload().get("orderId");
    ctx.awaitExecute("reserveStock",
                     Map.of("orderId", orderId),
                     inventory::reserve);
    var confirmation = ctx.waitForEvent(
            "awaitConfirmation", PaymentConfirmed.class,
            associate(payloadProperty("orderId"), equalsTo(orderId)),
            step -> step.timeout(Duration.ofMinutes(15)));
    ctx.awaitExecute("initiatePayment",
                     Map.of("orderId", orderId),
                     payment::initiate,
                     step -> step.retryPolicy(RetryPolicy.maxRetries(3)));
    confirmation.await();
    if (!confirmation.success()) {
        ctx.cancel("Payment confirmation was not received");
    }
    ctx.awaitExecute("shipOrder",
                     Map.of("orderId", orderId),
                     shipping::ship);
}

Each step receives an explicit input containing orderId, then passes that input to the service. The engine records the step input and result. Application types, injected services, and imports are omitted throughout for brevity. The confirmation is correlated by orderId; if the wait does not succeed, the order is not shipped.

One method. No workflow-instance state fields in the class. No manual scheduler tokens. The engine records durable workflow state and handles crash recovery, configured retries, timeouts, and replay.

That last part is the punchline: the execution history becomes part of your event infrastructure, available for business views and audits under your event-store retention policy.

Why Workflows: Moving Beyond the Saga Pattern

Axoniq gives teams the tools to build an event-sourced system of record. That history supports audits, replays, projections, and analytics that need to look back over months or years.

But the on-ramp can be steep.

  • Event sourcing asks developers to think in events. That is valuable, but it can be a substantial shift for teams starting with a process they want to automate.

  • Developers want simple, imperative code. Most engineers reach for if, for, try/catch, and a linear flow. An Axon 4 Saga expresses that flow across event handlers, stored state, associations, and scheduled timeouts.

  • We still want the event-sourced record. Simplifying the developer experience should preserve the history of how the process progressed.

Here is an illustrative excerpt of order fulfillment as an Axon 4 Saga:

@Saga
public class OrderSaga {
    @Autowired transient CommandGateway commands;
    @Autowired transient EventScheduler scheduler;
    private String orderId;
    private ScheduleToken paymentTimeout;
    private boolean paymentConfirmed;
    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void on(OrderPlaced event) {
        orderId = event.orderId();
        commands.send(new ReserveStockCommand(orderId));
    }
    @SagaEventHandler(associationProperty = "orderId")
    public void on(StockReserved event) {
        paymentTimeout = scheduler.schedule(
                Duration.ofMinutes(15), new PaymentTimedOut(orderId));
        commands.send(new InitiatePaymentCommand(orderId));
    }
    @SagaEventHandler(associationProperty = "orderId")
    public void on(PaymentConfirmed event) {
        if (paymentConfirmed) return;
        paymentConfirmed = true;
        if (paymentTimeout != null) scheduler.cancelSchedule(paymentTimeout);
        commands.send(new ShipOrderCommand(orderId));
    }
    // Timeout, rejection, compensation, and completion handlers omitted.
}
@Saga
public class OrderSaga {
    @Autowired transient CommandGateway commands;
    @Autowired transient EventScheduler scheduler;
    private String orderId;
    private ScheduleToken paymentTimeout;
    private boolean paymentConfirmed;
    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void on(OrderPlaced event) {
        orderId = event.orderId();
        commands.send(new ReserveStockCommand(orderId));
    }
    @SagaEventHandler(associationProperty = "orderId")
    public void on(StockReserved event) {
        paymentTimeout = scheduler.schedule(
                Duration.ofMinutes(15), new PaymentTimedOut(orderId));
        commands.send(new InitiatePaymentCommand(orderId));
    }
    @SagaEventHandler(associationProperty = "orderId")
    public void on(PaymentConfirmed event) {
        if (paymentConfirmed) return;
        paymentConfirmed = true;
        if (paymentTimeout != null) scheduler.cancelSchedule(paymentTimeout);
        commands.send(new ShipOrderCommand(orderId));
    }
    // Timeout, rejection, compensation, and completion handlers omitted.
}
@Saga
public class OrderSaga {
    @Autowired transient CommandGateway commands;
    @Autowired transient EventScheduler scheduler;
    private String orderId;
    private ScheduleToken paymentTimeout;
    private boolean paymentConfirmed;
    @StartSaga
    @SagaEventHandler(associationProperty = "orderId")
    public void on(OrderPlaced event) {
        orderId = event.orderId();
        commands.send(new ReserveStockCommand(orderId));
    }
    @SagaEventHandler(associationProperty = "orderId")
    public void on(StockReserved event) {
        paymentTimeout = scheduler.schedule(
                Duration.ofMinutes(15), new PaymentTimedOut(orderId));
        commands.send(new InitiatePaymentCommand(orderId));
    }
    @SagaEventHandler(associationProperty = "orderId")
    public void on(PaymentConfirmed event) {
        if (paymentConfirmed) return;
        paymentConfirmed = true;
        if (paymentTimeout != null) scheduler.cancelSchedule(paymentTimeout);
        commands.send(new ShipOrderCommand(orderId));
    }
    // Timeout, rejection, compensation, and completion handlers omitted.
}

With the Workflow API, the core sequence is the method above: reserve stock, initiate payment, await confirmation, and ship. The engine records the execution state instead of asking you to maintain it across handlers.

These excerpts focus on the core flow. Business decisions such as releasing reserved stock after a rejected payment still belong in your code, and external operations that may be retried still need idempotency.

How Durable Execution Works: It’s ‘Just’ Events”

The core insight: the engine rebuilds workflow state from recorded events.

For ctx.awaitExecute("reserveStock", ...), the engine records a started event, runs the action, and records its completion and result. Step names give those events meaningful names, such as orders.ReserveStockStarted and orders.ReserveStockCompleted. These are event message types; the engine does not generate Java event classes for you.

When the workflow waits for a matching event, its virtual thread is suspended. The wait does not occupy a dedicated operating-system thread, although the live workflow still uses JVM memory.

A successful order run has a history like this, with namespaces omitted for readability:

ReserveStockStarted → ReserveStockCompleted
AwaitConfirmationStarted
InitiatePaymentStarted → InitiatePaymentCompleted
AwaitConfirmationCompleted
ShipOrderStarted → ShipOrderCompleted
ReserveStockStarted → ReserveStockCompleted
AwaitConfirmationStarted
InitiatePaymentStarted → InitiatePaymentCompleted
AwaitConfirmationCompleted
ShipOrderStarted → ShipOrderCompleted
ReserveStockStarted → ReserveStockCompleted
AwaitConfirmationStarted
InitiatePaymentStarted → InitiatePaymentCompleted
AwaitConfirmationCompleted
ShipOrderStarted → ShipOrderCompleted

The confirmation wait is registered before payment begins, so a fast confirmation cannot arrive before the workflow starts listening. Its completion can interleave with payment-step completion.

Three things follow from this design:

  1. Crash recovery is built in. The engine restores recorded state and reuses completed step results. If a crash leaves an execute attempt's outcome unknown, that attempt is reported as indeterminate rather than silently executed again. Configure retries deliberately and make retryable external effects idempotent.

  2. Workflows participate in your event-driven application. Streaming event processors can build projections from retained step history using ordinary event handlers. For a stable business contract, publish an explicit domain event with awaitPublish or publish; projections and other workflows can consume it.

@Component
public class ShippingDashboard {
    @EventHandler(eventName = "orders.ShipOrderCompleted")
    public void on(Map<String, Object> payload,
                   @MetadataValue("workflowId") String workflowId) {
        board.markNode(workflowId, "shipped", DONE);
    }
}
@Component
public class ShippingDashboard {
    @EventHandler(eventName = "orders.ShipOrderCompleted")
    public void on(Map<String, Object> payload,
                   @MetadataValue("workflowId") String workflowId) {
        board.markNode(workflowId, "shipped", DONE);
    }
}
@Component
public class ShippingDashboard {
    @EventHandler(eventName = "orders.ShipOrderCompleted")
    public void on(Map<String, Object> payload,
                   @MetadataValue("workflowId") String workflowId) {
        board.markNode(workflowId, "shipped", DONE);
    }
}
  1. History remains useful after execution. You can build an audit view or a new projection from retained workflow events, alongside the rest of your application's event history.

The Workflow API: A Small Vocabulary of Durable Steps

The API is a small set of named operations. They compose with normal Java and Kotlin control flow.

Operation

Methods

What it does

Execute

awaitExecute · execute

Run an action and record its outcome. The blocking form returns the result; the non-blocking form returns a WorkflowStepResult. Configure retries and timeouts on the step.

Wait

awaitEvent · waitForEvent · sleep

Wait for a correlated event or a duration. Event waits support timeouts; their progress is durable across restarts.

Publish

awaitPublish · publish

Publish a business event as a durable step. Replaying a completed publish step does not publish it again.

Coordinate

anyMatch · allMatch · noneMatch

Combine step results using a predicate, for example waiting for any or all steps to succeed.

Update state

setPayload

Durably update the workflow payload carried between steps.

End a workflow

fail · cancel

End with a failure or an intentional cancellation. Returning normally completes the workflow.

The same approach in Kotlin, applied to a loan application:

@Workflow(startOnEventClass = LoanApplicationSubmitted::class,
          idProperty = "applicationId", workflowNamespace = "loans")
fun execute(ctx: WorkflowKontext) = ctx.runWorkflow {
    val applicationId = payload["applicationId"] as String
    val customerId = payload["customerId"] as String
    val credit = awaitExecute(
        "check-credit",
        inputPayload = mapOf("customerId" to customerId),
        retryPolicy = RetryPolicy.maxRetries(3)
    ) { pc, input -> creditBureau.check(pc, input) }
    val creditScore = (credit["score"] as Number).toInt()
    if (creditScore < 650) {
        cancel("Credit score below 650")
        return@runWorkflow
    }
    val documents = try {
        awaitEvent<DocumentsUploaded>(
            stepName = "await-documents",
            conditions = associate(payloadProperty("applicationId"), equalsTo(applicationId)),
            timeout = 7.days
        )
    } catch (_: StepTimedOutException) {
        cancel("Documents were not uploaded within seven days")
        return@runWorkflow
    }
    val approval = awaitExecute(
        "underwriter-review",
        inputPayload = mapOf("creditScore" to creditScore, "documents" to documents)
    ) { pc, input -> underwriter.review(pc, input) }
    awaitPublish("loan-approved", LoanApproved(applicationId, approval["contractId"] as String))
}
@Workflow(startOnEventClass = LoanApplicationSubmitted::class,
          idProperty = "applicationId", workflowNamespace = "loans")
fun execute(ctx: WorkflowKontext) = ctx.runWorkflow {
    val applicationId = payload["applicationId"] as String
    val customerId = payload["customerId"] as String
    val credit = awaitExecute(
        "check-credit",
        inputPayload = mapOf("customerId" to customerId),
        retryPolicy = RetryPolicy.maxRetries(3)
    ) { pc, input -> creditBureau.check(pc, input) }
    val creditScore = (credit["score"] as Number).toInt()
    if (creditScore < 650) {
        cancel("Credit score below 650")
        return@runWorkflow
    }
    val documents = try {
        awaitEvent<DocumentsUploaded>(
            stepName = "await-documents",
            conditions = associate(payloadProperty("applicationId"), equalsTo(applicationId)),
            timeout = 7.days
        )
    } catch (_: StepTimedOutException) {
        cancel("Documents were not uploaded within seven days")
        return@runWorkflow
    }
    val approval = awaitExecute(
        "underwriter-review",
        inputPayload = mapOf("creditScore" to creditScore, "documents" to documents)
    ) { pc, input -> underwriter.review(pc, input) }
    awaitPublish("loan-approved", LoanApproved(applicationId, approval["contractId"] as String))
}
@Workflow(startOnEventClass = LoanApplicationSubmitted::class,
          idProperty = "applicationId", workflowNamespace = "loans")
fun execute(ctx: WorkflowKontext) = ctx.runWorkflow {
    val applicationId = payload["applicationId"] as String
    val customerId = payload["customerId"] as String
    val credit = awaitExecute(
        "check-credit",
        inputPayload = mapOf("customerId" to customerId),
        retryPolicy = RetryPolicy.maxRetries(3)
    ) { pc, input -> creditBureau.check(pc, input) }
    val creditScore = (credit["score"] as Number).toInt()
    if (creditScore < 650) {
        cancel("Credit score below 650")
        return@runWorkflow
    }
    val documents = try {
        awaitEvent<DocumentsUploaded>(
            stepName = "await-documents",
            conditions = associate(payloadProperty("applicationId"), equalsTo(applicationId)),
            timeout = 7.days
        )
    } catch (_: StepTimedOutException) {
        cancel("Documents were not uploaded within seven days")
        return@runWorkflow
    }
    val approval = awaitExecute(
        "underwriter-review",
        inputPayload = mapOf("creditScore" to creditScore, "documents" to documents)
    ) { pc, input -> underwriter.review(pc, input) }
    awaitPublish("loan-approved", LoanApproved(applicationId, approval["contractId"] as String))
}

No graph editor or JSON state machine is required. The Kotlin DSL provides typed results, named parameters, and Kotlin durations through the Kontext DSL receiver inside runWorkflow. Keep external effects inside durable steps and preserve step identities so replay can match them to their recorded history.

Build Your Own DSL on Top

The primitives are the foundation. You can extend SimpleWorkflowContext, register a factory for your custom context, and expose business verbs. With those application-defined helpers in place, a workflow can read like this:

@Workflow(startOnEventClass = PurchaseRequestSubmitted.class,
          idProperty = "requestId")
public void execute(ApprovalWorkflowContext ctx) {
    if (!ctx.requestApproval("team-lead", Duration.ofDays(3))) {
        ctx.cancel("Team-lead approval was not received");
    }
    if (ctx.amount() > 10_000
            && !ctx.requestApproval("cfo", Duration.ofDays(2))) {
        ctx.cancel("Second approval was not received");
    }
    ctx.notifyOutcome(ctx.requester(), "approved");
}
@Workflow(startOnEventClass = PurchaseRequestSubmitted.class,
          idProperty = "requestId")
public void execute(ApprovalWorkflowContext ctx) {
    if (!ctx.requestApproval("team-lead", Duration.ofDays(3))) {
        ctx.cancel("Team-lead approval was not received");
    }
    if (ctx.amount() > 10_000
            && !ctx.requestApproval("cfo", Duration.ofDays(2))) {
        ctx.cancel("Second approval was not received");
    }
    ctx.notifyOutcome(ctx.requester(), "approved");
}
@Workflow(startOnEventClass = PurchaseRequestSubmitted.class,
          idProperty = "requestId")
public void execute(ApprovalWorkflowContext ctx) {
    if (!ctx.requestApproval("team-lead", Duration.ofDays(3))) {
        ctx.cancel("Team-lead approval was not received");
    }
    if (ctx.amount() > 10_000
            && !ctx.requestApproval("cfo", Duration.ofDays(2))) {
        ctx.cancel("Second approval was not received");
    }
    ctx.notifyOutcome(ctx.requester(), "approved");
}

Each helper composes the underlying durable operations, including its event correlation and timeout behavior. The workflow method expresses the business story.

Axoniq Workflows vs. Other Workflow Engines

Several mature workflow engines provide durable execution and recovery. History retention, archival, and extensibility vary by engine and configuration. For teams using Axoniq, the useful distinction is how naturally workflow history fits into the event-driven application.

What matters

Workflows in Axoniq Framework

Durable execution

Recorded steps and results support recovery after restart.

Business history

Named step events and explicitly published domain events share the application's event infrastructure.

Projections and audits

Familiar event handlers can turn retained history into views for the business.

Programming model

Imperative Java and Kotlin, with durable operations and explicit support for code evolution.

Domain-specific APIs

Custom workflow contexts let teams express their own business vocabulary.

A completed workflow can remain useful long after it finishes. Retain the events and you can build a new projection, investigate a past order, or make selected history available to an analytics or AI application. The event store's retention policy determines how long that history remains available.

For systems where history is an asset, such as payments, supply chains, and approvals, that connection between execution and events matters.

What General Availability Adds: Sharding, Versioning, and Testing

Workflows brings imperative process code and event-sourced execution together. The workflow you write today also supplies a history that your application can use tomorrow.

With general availability, that model extends to operating and evolving workflows:

  • Sharding and failover. Workflow instances are distributed across processor segments. Multiple instances of the same application share the work through the same event store and a shared durable token store. If one node stops, another can claim its segments and restore unfinished workflows; append conditions prevent stale owners from committing conflicting history.

  • Versioning and migration. Keep old and new workflow definitions registered alongside each other: new instances use the highest version, while in-flight instances continue on their recorded version. Use migrateVersion for explicit additive branches within a definition.

  • Testing. The workflow BDD fixture lets you drive steps, supply results or failures, advance time, and check recorded history without invoking production side effects.

Durable event publishing also lets workflows signal business outcomes and start or resume other workflows. These capabilities build on the same Java and Kotlin model, with Spring Boot integration available through the Axoniq Framework starter.

Simple code. Durable execution. A history you can build on.

If you have a process you have been putting off because the Saga felt too heavy, this is a good place to start. Write a workflow, run it, and look at what ends up in your event store.

👉 Get started with workflows · Explore the reference guide · Saga to workflow upgrade guide

🤖 Explore the playable workflow simulator

See it in production

Otto, Nets, MoneyLion, DPG Media and other teams run event-sourced systems in production with Axoniq.

Read customer stories
Join the Thousands of Developers

Already Building with Axon in Open Source

Talk to us about LTS →
Join the Thousands of Developers

Already Building with Axon in Open Source

Talk to us about LTS →