An Order Management System (OMS) is the textbook example of where CQRS (Command Query Responsibility Segregation) and Event Sourcing truly shine. In a real-world e-commerce OMS: Reads outnumber writes: Customers check their order status 100x more often than they actually place an order. Complex Business Rules: An order cannot be shipped before payment is confirmed; an order cannot be cancelled after it has shipped. Auditability: You need a perfect, immutable history of what happened to an order and when. By using the Axon Framework with Spring Boot, we can separate the complex state-changing logic (Commands) from the highly optimized, fast data retrieval (Queries), while keeping a perfect audit log (Events). Let's build one from scratch! 🚀 🛠️ 1. Project Setup We will use Spring Boot 3.x and Java 17+ (required for Spring Boot 3 and Java Records). Add the following dependencies to your pom.xml: org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-data-jpa com.h2database h2 runtime org.axonframework axon-spring-boot-starter 4.9.3 Enter fullscreen mode Exit fullscreen mode Note: By default, Axon expects to connect to Axon Server (the event store and routing engine). For local development, you can run Axon Server via Docker, or configure Axon to use an embedded JPA event store. 🗣️ 2. The Ubiquitous Language (Commands & Events) In an OMS, the lifecycle of an order is typically: PLACED -> PAID -> SHIPPED (or CANCELLED). We define this using immutable Java Records. Commands (The Intent) Commands represent the intent to change the system state. package com.example.oms.commands; import java.math.BigDecimal; import java.util.List; public record PlaceOrderCommand(String orderId, String customerId, List productIds, BigDecimal totalAmount) {} public record ConfirmPaymentCommand(String orderId, String paymentTransactionId) {} public record ShipOrderCommand(String orderId, String trackingNumber) {} public record CancelOrderCommand(String orderId, String reason) {} Enter fullscreen mode Exit fullscreen mode Events (The Facts) Events represent facts that have already happened. They are the single source of truth. package com.example.oms.events; import java.math.BigDecimal; import java.util.List; public record OrderPlacedEvent(String orderId, String customerId, List productIds, BigDecimal totalAmount) {} public record PaymentConfirmedEvent(String orderId, String paymentTransactionId) {} public record OrderShippedEvent(String orderId, String trackingNumber) {} public record OrderCancelledEvent(String orderId, String reason) {} Enter fullscreen mode Exit fullscreen mode ✍️ 3. The Write Side: The Order Aggregate The Aggregate enforces business rules. In Event Sourcing, it does not save its state to a traditional database; instead, it rebuilds its state by replaying events from the Event Store. package com.example.oms.domain; import com.example.oms.commands.*; import com.example.oms.events.*; import org.axonframework.commandhandling.CommandHandler; import org.axonframework.eventsourcing.EventSourcingHandler; import org.axonframework.modelling.command.AggregateIdentifier; import org.axonframework.modelling.command.AggregateLifecycle; import org.axonframework.spring.stereotype.Aggregate; import java.math.BigDecimal; import java.util.List; @Aggregate public class OrderAggregate { @AggregateIdentifier private String orderId; private String customerId; private OrderStatus status; private BigDecimal totalAmount; private String trackingNumber; // Required no-arg constructor for Axon public OrderAggregate() {} // --- COMMAND HANDLERS (Enforcing Business Rules) --- @CommandHandler public OrderAggregate(PlaceOrderCommand command) { if (command.totalAmount().compareTo(BigDecimal.ZERO) { view.setStatus("PAID"); repository.save(view); }); } @EventHandler public void on(OrderShippedEvent event) { repository.findById(event.orderId()).ifPresent(view -> { view.setStatus("SHIPPED"); view.setTrackingNumber(event.trackingNumber()); repository.save(view); }); } @EventHandler public void on(OrderCancelledEvent event) { repository.findById(event.orderId()).ifPresent(view -> { view.setStatus("CANCELLED"); repository.save(view); }); } } Enter fullscreen mode Exit fullscreen mode Query Handlers package com.example.oms.query; import org.axonframework.queryhandling.QueryHandler; import org.springframework.stereotype.Component; import java.util.List; @Component public class OrderQueryHandler { private final OrderViewRepository repository; public OrderQueryHandler(OrderViewRepository repository) { this.repository = repository; } @QueryHandler public OrderView handle(FindOrderQuery query) { return repository.findById(query.orderId()) .orElseThrow(() -> new IllegalArgumentException("Order not found")); } @QueryHandler public List handle(FindOrdersByCustomerQuery query) { return repository.findByCustomerId(query.customerId()); } } // Query Definitions public record FindOrderQuery(String orderId) {} public record FindOrdersByCustomerQuery(String customerId) {} Enter fullscreen mode Exit fullscreen mode 🌐 5. The API Layer (REST Controller) We tie it all together using Axon's CommandGateway for writes and QueryGateway for reads. package com.example.oms.api; import com.example.oms.commands.*; import com.example.oms.query.*; import org.axonframework.commandhandling.gateway.CommandGateway; import org.axonframework.queryhandling.QueryGateway; import org.axonframework.messaging.responsetypes.ResponseTypes; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; import java.util.List; import java.util.concurrent.CompletableFuture; @RestController @RequestMapping("/api/orders") public class OrderController { private final CommandGateway commandGateway; private final QueryGateway queryGateway; public OrderController(CommandGateway commandGateway, QueryGateway queryGateway) { this.commandGateway = commandGateway; this.queryGateway = queryGateway; } // --- COMMANDS (Writes) --- @PostMapping public CompletableFuture placeOrder(@RequestBody PlaceOrderRequest request) { return commandGateway.send(new PlaceOrderCommand( request.orderId(), request.customerId(), request.productIds(), request.totalAmount() )); } @PostMapping("/{orderId}/payment") public CompletableFuture confirmPayment(@PathVariable String orderId, @RequestParam String transactionId) { return commandGateway.send(new ConfirmPaymentCommand(orderId, transactionId)); } @PostMapping("/{orderId}/ship") public CompletableFuture shipOrder(@PathVariable String orderId, @RequestParam String trackingNumber) { return commandGateway.send(new ShipOrderCommand(orderId, trackingNumber)); } @PostMapping("/{orderId}/cancel") public CompletableFuture cancelOrder(@PathVariable String orderId, @RequestParam String reason) { return commandGateway.send(new CancelOrderCommand(orderId, reason)); } // --- QUERIES (Reads) --- @GetMapping("/{orderId}") public CompletableFuture getOrder(@PathVariable String orderId) { return queryGateway.query(new FindOrderQuery(orderId), OrderView.class); } @GetMapping("/customer/{customerId}") public CompletableFuture> getCustomerOrders(@PathVariable String customerId) { return queryGateway.query( new FindOrdersByCustomerQuery(customerId), ResponseTypes.multipleInstancesOf(OrderView.class) ); } } public record PlaceOrderRequest(String orderId, String customerId, List productIds, BigDecimal totalAmount) {} Enter fullscreen mode Exit fullscreen mode ⚙️ 6. Application Configuration Configure your application.yml for the H2 read database and Axon Server connection. spring: datasource: url: jdbc:h2:mem:omsdb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: false h2: console: enabled: true # Access H2 UI at http://localhost:8080/h2-console axon: axonserver: servers: localhost:8124 Enter fullscreen mode Exit fullscreen mode 🧩 7. Real-World Extension: Managing Distributed Transactions with Sagas In a real-world OMS, placing an order isn't just about updating the Order database. You also need to reserve inventory and process a payment. If the payment fails, you must release the inventory. This is where Axon Sagas come in. A Saga is a long-running transaction that manages state across multiple microservices/bounded contexts. import org.axonframework.commandhandling.gateway.CommandGateway; import org.axonframework.modelling.saga.SagaEventHandler; import org.axonframework.modelling.saga.SagaLifecycle; import org.axonframework.modelling.saga.StartSaga; import org.axonframework.spring.stereotype.Saga; import org.springframework.beans.factory.annotation.Autowired; @Saga public class OrderProcessSaga { @Autowired private transient CommandGateway commandGateway; @StartSaga @SagaEventHandler(associationProperty = "orderId") public void handle(OrderPlacedEvent event) { // 1. Reserve Inventory in the Inventory Microservice commandGateway.send(new ReserveInventoryCommand(event.orderId(), event.productIds())); } @SagaEventHandler(associationProperty = "orderId") public void handle(InventoryReservedEvent event) { // 2. Inventory reserved successfully, now process payment commandGateway.send(new ProcessPaymentCommand(event.orderId(), event.totalAmount())); } @SagaEventHandler(associationProperty = "orderId") public void handle(PaymentFailedEvent event) { // 3. Payment failed! Rollback inventory reservation (Compensating Action) commandGateway.send(new ReleaseInventoryCommand(event.orderId(), event.productIds())); commandGateway.send(new CancelOrderCommand(event.orderId(), "Payment failed")); } } Enter fullscreen mode Exit fullscreen mode 🔄 Summary of the Flow Customer places an order via POST /api/orders. Controller sends PlaceOrderCommand to the Command Bus. OrderAggregate validates the request and applies OrderPlacedEvent. Axon Server (Event Store) persists the event permanently. OrderEventHandler catches the event and inserts a new row into the H2 Database (Read Model). Customer checks order status via GET /api/orders/{id}. Controller sends FindOrderQuery to the Query Bus. OrderQueryHandler reads directly from the H2 Database and returns the highly optimized view. (Optional) Saga listens to the OrderPlacedEvent to trigger downstream microservices (Inventory, Payment) and handles compensating actions if they fail. Reference: Axon Framework - https://www.axoniq.io/axon-framework Spring Boot - https://spring.io/projects/spring-boot
Building a Scalable Order Management System with CQRS, Event Sourcing, and Axon Framework
Full Article
📰 Original Source
Read full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.