findById(String orderId) {
+ return Optional.ofNullable(orders.get(orderId));
+ }
+}
diff --git a/workflow-saga/src/main/java/io/axoniq/demo/workflowsaga/projection/OrderProcessStatus.java b/workflow-saga/src/main/java/io/axoniq/demo/workflowsaga/projection/OrderProcessStatus.java
new file mode 100644
index 00000000..664ead63
--- /dev/null
+++ b/workflow-saga/src/main/java/io/axoniq/demo/workflowsaga/projection/OrderProcessStatus.java
@@ -0,0 +1,18 @@
+package io.axoniq.demo.workflowsaga.projection;
+
+public record OrderProcessStatus(
+ String orderId,
+ Phase phase,
+ boolean paid,
+ boolean delivered
+) {
+
+ public enum Phase {
+ IN_PROGRESS,
+ COMPLETED
+ }
+
+ public OrderProcessStatus completed(boolean paid, boolean delivered) {
+ return new OrderProcessStatus(orderId, Phase.COMPLETED, paid, delivered);
+ }
+}
diff --git a/workflow-saga/src/main/java/io/axoniq/demo/workflowsaga/workflow/ProcessOrderWorkflow.java b/workflow-saga/src/main/java/io/axoniq/demo/workflowsaga/workflow/ProcessOrderWorkflow.java
new file mode 100644
index 00000000..d53ea3e8
--- /dev/null
+++ b/workflow-saga/src/main/java/io/axoniq/demo/workflowsaga/workflow/ProcessOrderWorkflow.java
@@ -0,0 +1,130 @@
+package io.axoniq.demo.workflowsaga.workflow;
+
+import io.axoniq.demo.workflowsaga.api.OrderPaidEvent;
+import io.axoniq.demo.workflowsaga.api.OrderPaymentCancelledEvent;
+import io.axoniq.demo.workflowsaga.api.ShipmentStatus;
+import io.axoniq.demo.workflowsaga.api.ShipmentStatusUpdatedEvent;
+import io.axoniq.workflow.dsl.simple.SimpleWorkflowContext;
+import io.axoniq.workflow.runtime.api.annotation.Workflow;
+import io.axoniq.workflow.runtime.api.execution.context.EventNameCustomizer;
+import io.axoniq.workflow.runtime.api.execution.state.WorkflowStepResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.UUID;
+
+import static io.axoniq.workflow.dsl.api.AssociationsUtils.associate;
+import static io.axoniq.workflow.dsl.api.Payload.payload;
+import static io.axoniq.workflow.dsl.simple.SimpleWorkflowContext.equalsTo;
+import static io.axoniq.workflow.runtime.association.PayloadPropertyValueRetriever.payloadProperty;
+import static io.axoniq.workflow.runtime.execution.DefaultEventNameCustomizer.Builder.namespace;
+
+/**
+ * Workflow rewrite of the legacy {@code ProcessOrderSaga}.
+ *
+ * The workflow does not call {@code eventGateway.publish(...)} anywhere — every step the workflow
+ * runs (the {@code awaitExecute} calls) automatically produces a Started + Completed event in the
+ * event store. Those emitted events are the durable signal external systems and projections
+ * subscribe to.
+ */
+@Component
+public class ProcessOrderWorkflow {
+
+ private static final Logger logger = LoggerFactory.getLogger(ProcessOrderWorkflow.class);
+ private static final Duration ORDER_DEADLINE = Duration.ofDays(5);
+ private static final Duration STEP_TIMEOUT = Duration.ofSeconds(30);
+
+ /** Emit step events under our own namespace so the projection's @{@code @EventHandler} records line up by FQCN. */
+ private static EventNameCustomizer apiNamespace() {
+ return namespace("io.axoniq.demo.workflowsaga.api");
+ }
+
+ @Workflow(
+ idProperty = "orderId",
+ startOnEvent = "io.axoniq.demo.workflowsaga.api.OrderConfirmedEvent",
+ workflowName = "ProcessOrderWorkflow"
+ )
+ public void execute(SimpleWorkflowContext ctx) {
+ var orderId = (String) ctx.workflowPayload().get("orderId");
+ var paymentId = UUID.randomUUID().toString();
+ var shipmentId = UUID.randomUUID().toString();
+ ctx.setPayload("registerIds", payload("paymentId", paymentId, "shipmentId", shipmentId));
+
+ logger.info("Order {} workflow started (payment {}, shipment {}).", orderId, paymentId, shipmentId);
+
+ var paid = ctx.waitForEvent(
+ "paid",
+ OrderPaidEvent.class,
+ associate(payloadProperty("paymentId"), equalsTo(paymentId)),
+ ORDER_DEADLINE
+ );
+ var paymentCancelled = ctx.waitForEvent(
+ "paymentCancelled",
+ OrderPaymentCancelledEvent.class,
+ associate(payloadProperty("paymentId"), equalsTo(paymentId)),
+ ORDER_DEADLINE
+ );
+ var delivered = ctx.waitForEvent(
+ "delivered",
+ ShipmentStatusUpdatedEvent.class,
+ associate(payloadProperty("shipmentId"), equalsTo(shipmentId))
+ .and(payloadProperty("shipmentStatus"), "=", ShipmentStatus.DELIVERED.name()),
+ ORDER_DEADLINE
+ );
+
+ // The Started events of these steps — `RequestPaymentStarted` and `RequestShipmentStarted`
+ // — are the durable signals projections subscribe to. No manual publishing needed.
+ ctx.awaitExecute(
+ "requestPayment",
+ Map.of("orderId", orderId, "paymentId", paymentId),
+ (pc, p) -> Map.of(),
+ STEP_TIMEOUT,
+ apiNamespace()
+ );
+ ctx.awaitExecute(
+ "requestShipment",
+ Map.of("orderId", orderId, "shipmentId", shipmentId),
+ (pc, p) -> Map.of(),
+ STEP_TIMEOUT,
+ apiNamespace()
+ );
+
+ var paymentOutcome = ctx.anyMatch(WorkflowStepResult::success, paid, paymentCancelled);
+ paymentOutcome.await();
+
+ var matched = paymentOutcome.matched().stream().map(WorkflowStepResult::getStepName).toList();
+ if (matched.contains("paymentCancelled")) {
+ logger.info("Order {} payment cancelled — cancelling shipment.", orderId);
+ ctx.awaitExecute(
+ "cancelShipment",
+ Map.of("orderId", orderId, "shipmentId", shipmentId),
+ (pc, p) -> Map.of(),
+ STEP_TIMEOUT,
+ apiNamespace()
+ );
+ delivered.cancel("payment cancelled");
+ ctx.awaitExecute(
+ "completeOrder",
+ Map.of("orderId", orderId, "paid", false, "delivered", false),
+ (pc, p) -> Map.of(),
+ STEP_TIMEOUT,
+ apiNamespace()
+ );
+ return;
+ }
+
+ delivered.await();
+ var isDelivered = delivered.success();
+ logger.info("Order {} completed (paid {}, delivered {}).", orderId, true, isDelivered);
+ ctx.awaitExecute(
+ "completeOrder",
+ Map.of("orderId", orderId, "paid", true, "delivered", isDelivered),
+ (pc, p) -> Map.of(),
+ STEP_TIMEOUT,
+ apiNamespace()
+ );
+ }
+}
diff --git a/workflow-saga/src/main/resources/application.yaml b/workflow-saga/src/main/resources/application.yaml
new file mode 100644
index 00000000..14c682cb
--- /dev/null
+++ b/workflow-saga/src/main/resources/application.yaml
@@ -0,0 +1,10 @@
+server:
+ port: 9091
+
+spring:
+ application:
+ name: workflow-saga
+
+axon:
+ axon-server:
+ enabled: true
diff --git a/workflow-saga/src/test/java/io/axoniq/demo/workflowsaga/ProcessOrderWorkflowIT.java b/workflow-saga/src/test/java/io/axoniq/demo/workflowsaga/ProcessOrderWorkflowIT.java
new file mode 100644
index 00000000..28edd0da
--- /dev/null
+++ b/workflow-saga/src/test/java/io/axoniq/demo/workflowsaga/ProcessOrderWorkflowIT.java
@@ -0,0 +1,81 @@
+package io.axoniq.demo.workflowsaga;
+
+import io.axoniq.demo.workflowsaga.projection.IdRegistry;
+import io.axoniq.demo.workflowsaga.projection.OrderProcessStatus;
+import io.axoniq.framework.testcontainer.AxonServerContainer;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.resttestclient.TestRestTemplate;
+import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@AutoConfigureTestRestTemplate
+@Testcontainers
+class ProcessOrderWorkflowIT {
+
+ @Container
+ static final AxonServerContainer AXON_SERVER = new AxonServerContainer()
+ .withAxonServerHostname("localhost")
+ .withDevMode(true)
+ .withDcbContext(true);
+
+ @DynamicPropertySource
+ static void axonProperties(DynamicPropertyRegistry registry) {
+ registry.add("axon.axonserver.servers",
+ () -> AXON_SERVER.getHost() + ":" + AXON_SERVER.getMappedPort(8124));
+ }
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void happy_path_paid_then_delivered() {
+ var orderId = restTemplate.postForObject("/orders", null, String.class);
+ assertThat(orderId).isNotBlank();
+
+ var ids = await().atMost(20, TimeUnit.SECONDS).until(
+ () -> restTemplate.getForObject("/orders/" + orderId + "/ids", IdRegistry.Ids.class),
+ i -> i != null && i.paymentId() != null && i.shipmentId() != null);
+
+ restTemplate.postForEntity("/payments/" + ids.paymentId() + "/paid", null, Void.class);
+ restTemplate.postForEntity("/shipments/" + ids.shipmentId() + "/status?status=DELIVERED", null, Void.class);
+
+ await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
+ var status = restTemplate.getForObject("/orders/" + orderId, OrderProcessStatus.class);
+ assertThat(status).isNotNull();
+ assertThat(status.phase()).isEqualTo(OrderProcessStatus.Phase.COMPLETED);
+ assertThat(status.paid()).isTrue();
+ assertThat(status.delivered()).isTrue();
+ });
+ }
+
+ @Test
+ void payment_cancelled_triggers_shipment_cancellation() {
+ var orderId = restTemplate.postForObject("/orders", null, String.class);
+ assertThat(orderId).isNotBlank();
+
+ var ids = await().atMost(20, TimeUnit.SECONDS).until(
+ () -> restTemplate.getForObject("/orders/" + orderId + "/ids", IdRegistry.Ids.class),
+ i -> i != null && i.paymentId() != null && i.shipmentId() != null);
+
+ restTemplate.postForEntity("/payments/" + ids.paymentId() + "/cancel", null, Void.class);
+
+ await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
+ var status = restTemplate.getForObject("/orders/" + orderId, OrderProcessStatus.class);
+ assertThat(status).isNotNull();
+ assertThat(status.phase()).isEqualTo(OrderProcessStatus.Phase.COMPLETED);
+ assertThat(status.paid()).isFalse();
+ assertThat(status.delivered()).isFalse();
+ });
+ }
+}