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:
<dependencies>
<!-- Spring Boot Web & JPA for the Read Model -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database for the Read Model (Query Side) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Axon Framework Spring Boot Starter -->
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.9.3</version> <!-- Check for the latest 4.x version -->
</dependency>
</dependencies>
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<String> productIds, BigDecimal totalAmount) {}
public record ConfirmPaymentCommand(String orderId, String paymentTransactionId) {}
public record ShipOrderCommand(String orderId, String trackingNumber) {}
public record CancelOrderCommand(String orderId, String reason) {}
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<String> productIds, BigDecimal totalAmount) {}
public record PaymentConfirmedEvent(String orderId, String paymentTransactionId) {}
public record OrderShippedEvent(String orderId, String trackingNumber) {}
public record OrderCancelledEvent(String orderId, String reason) {}
✍️ 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) <= 0) {
throw new IllegalArgumentException("Order total must be greater than zero");
}
AggregateLifecycle.apply(new OrderPlacedEvent(
command.orderId(), command.customerId(), command.productIds(), command.totalAmount()
));
}
@CommandHandler
public void handle(ConfirmPaymentCommand command) {
if (this.status != OrderStatus.PLACED) {
throw new IllegalStateException("Cannot confirm payment for an order in status: " + this.status);
}
AggregateLifecycle.apply(new PaymentConfirmedEvent(command.orderId(), command.paymentTransactionId()));
}
@CommandHandler
public void handle(ShipOrderCommand command) {
if (this.status != OrderStatus.PAID) {
throw new IllegalStateException("Cannot ship order. Payment not confirmed. Status: " + this.status);
}
AggregateLifecycle.apply(new OrderShippedEvent(command.orderId(), command.trackingNumber()));
}
@CommandHandler
public void handle(CancelOrderCommand command) {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel an order that has already been shipped");
}
AggregateLifecycle.apply(new OrderCancelledEvent(command.orderId(), command.reason()));
}
// --- EVENT SOURCING HANDLERS (Rebuilding State) ---
@EventSourcingHandler
protected void on(OrderPlacedEvent event) {
this.orderId = event.orderId();
this.customerId = event.customerId();
this.totalAmount = event.totalAmount();
this.status = OrderStatus.PLACED;
}
@EventSourcingHandler
protected void on(PaymentConfirmedEvent event) {
this.status = OrderStatus.PAID;
}
@EventSourcingHandler
protected void on(OrderShippedEvent event) {
this.status = OrderStatus.SHIPPED;
this.trackingNumber = event.trackingNumber();
}
@EventSourcingHandler
protected void on(OrderCancelledEvent event) {
this.status = OrderStatus.CANCELLED;
}
}
public enum OrderStatus {
PLACED, PAID, SHIPPED, CANCELLED
}
👓 4. The Read Side: Projections and Queries
The read side uses a traditional relational database (JPA) optimized for fast querying. We "project" our events into this read model.
JPA Entity (The View)
package com.example.oms.query;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
@Entity
public class OrderView {
@Id
private String orderId;
private String customerId;
private String status;
private BigDecimal totalAmount;
private String trackingNumber;
protected OrderView() {} // JPA requirement
// Getters and Setters omitted for brevity...
public String getOrderId() { return orderId; }
public void setOrderId(String orderId) { this.orderId = orderId; }
public String getCustomerId() { return customerId; }
public void setCustomerId(String customerId) { this.customerId = customerId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
public String getTrackingNumber() { return trackingNumber; }
public void setTrackingNumber(String trackingNumber) { this.trackingNumber = trackingNumber; }
}
Event Handlers (Projecting Events to the Read Model)
When an event is successfully committed to the Event Store, Axon triggers @EventHandler methods to update our relational database.
package com.example.oms.query;
import com.example.oms.events.*;
import org.axonframework.eventhandling.EventHandler;
import org.springframework.stereotype.Component;
@Component
public class OrderEventHandler {
private final OrderViewRepository repository;
public OrderEventHandler(OrderViewRepository repository) {
this.repository = repository;
}
@EventHandler
public void on(OrderPlacedEvent event) {
OrderView view = new OrderView();
view.setOrderId(event.orderId());
view.setCustomerId(event.customerId());
view.setTotalAmount(event.totalAmount());
view.setStatus("PLACED");
repository.save(view);
}
@EventHandler
public void on(PaymentConfirmedEvent event) {
repository.findById(event.orderId()).ifPresent(view -> {
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);
});
}
}
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<OrderView> handle(FindOrdersByCustomerQuery query) {
return repository.findByCustomerId(query.customerId());
}
}
// Query Definitions
public record FindOrderQuery(String orderId) {}
public record FindOrdersByCustomerQuery(String customerId) {}
🌐 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<String> placeOrder(@RequestBody PlaceOrderRequest request) {
return commandGateway.send(new PlaceOrderCommand(
request.orderId(), request.customerId(), request.productIds(), request.totalAmount()
));
}
@PostMapping("/{orderId}/payment")
public CompletableFuture<Void> confirmPayment(@PathVariable String orderId, @RequestParam String transactionId) {
return commandGateway.send(new ConfirmPaymentCommand(orderId, transactionId));
}
@PostMapping("/{orderId}/ship")
public CompletableFuture<Void> shipOrder(@PathVariable String orderId, @RequestParam String trackingNumber) {
return commandGateway.send(new ShipOrderCommand(orderId, trackingNumber));
}
@PostMapping("/{orderId}/cancel")
public CompletableFuture<Void> cancelOrder(@PathVariable String orderId, @RequestParam String reason) {
return commandGateway.send(new CancelOrderCommand(orderId, reason));
}
// --- QUERIES (Reads) ---
@GetMapping("/{orderId}")
public CompletableFuture<OrderView> getOrder(@PathVariable String orderId) {
return queryGateway.query(new FindOrderQuery(orderId), OrderView.class);
}
@GetMapping("/customer/{customerId}")
public CompletableFuture<List<OrderView>> getCustomerOrders(@PathVariable String customerId) {
return queryGateway.query(
new FindOrdersByCustomerQuery(customerId),
ResponseTypes.multipleInstancesOf(OrderView.class)
);
}
}
public record PlaceOrderRequest(String orderId, String customerId, List<String> productIds, BigDecimal totalAmount) {}
⚙️ 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
🧩 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"));
}
}
🔄 Summary of the Flow
- Customer places an order via
POST /api/orders. - Controller sends
PlaceOrderCommandto the Command Bus. -
OrderAggregatevalidates the request and appliesOrderPlacedEvent. - Axon Server (Event Store) persists the event permanently.
-
OrderEventHandlercatches the event and inserts a new row into the H2 Database (Read Model). - Customer checks order status via
GET /api/orders/{id}. - Controller sends
FindOrderQueryto the Query Bus. -
OrderQueryHandlerreads directly from the H2 Database and returns the highly optimized view. -
(Optional) Saga listens to the
OrderPlacedEventto 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
Top comments (0)