Back to Journal
System Design

How to Build CQRS & Event Sourcing Using Spring Boot

Step-by-step tutorial for building CQRS & Event Sourcing with Spring Boot, from project setup through deployment.

Muneer Puthiya Purayil 19 min read

Spring Boot combined with Axon Framework provides the most mature and feature-complete CQRS and Event Sourcing platform in the JVM ecosystem. This tutorial builds a complete order management system from scratch using Spring Boot 3, Axon Framework 4, and PostgreSQL, covering aggregate design, command and query handling, projections, and REST API endpoints.

Project Setup

Initialize a Spring Boot project with the required dependencies.

xml
1<!-- pom.xml key dependencies -->
2<dependencies>
3 <dependency>
4 <groupId>org.springframework.boot</groupId>
5 <artifactId>spring-boot-starter-web</artifactId>
6 </dependency>
7 <dependency>
8 <groupId>org.springframework.boot</groupId>
9 <artifactId>spring-boot-starter-data-jpa</artifactId>
10 </dependency>
11 <dependency>
12 <groupId>org.axonframework</groupId>
13 <artifactId>axon-spring-boot-starter</artifactId>
14 <version>4.9.3</version>
15 </dependency>
16 <dependency>
17 <groupId>org.postgresql</groupId>
18 <artifactId>postgresql</artifactId>
19 <scope>runtime</scope>
20 </dependency>
21 <dependency>
22 <groupId>org.projectlombok</groupId>
23 <artifactId>lombok</artifactId>
24 <optional>true</optional>
25 </dependency>
26</dependencies>
27 

Configure the application properties:

yaml
1# application.yml
2spring:
3 datasource:
4 url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:orders}
5 username: ${DB_USER:postgres}
6 password: ${DB_PASSWORD:postgres}
7 jpa:
8 hibernate:
9 ddl-auto: validate
10 properties:
11 hibernate:
12 dialect: org.hibernate.dialect.PostgreSQLDialect
13 
14axon:
15 axonserver:
16 enabled: false
17 serializer:
18 general: jackson
19 events: jackson
20 messages: jackson
21 

Setting axonserver.enabled: false configures Axon to use JPA-based event storage instead of Axon Server, keeping the infrastructure simple for this tutorial.

Domain Events

Define domain events as immutable objects. Axon serializes these to JSON and stores them in the event store.

java
1package com.example.order.domain.events;
2 
3import java.time.Instant;
4import java.util.List;
5 
6public record OrderPlacedEvent(
7 String orderId,
8 String customerId,
9 List<OrderLineItem> lineItems,
10 long totalAmountCents,
11 String currency,
12 Instant placedAt
13) {}
14 
15public record OrderConfirmedEvent(
16 String orderId,
17 String confirmedBy,
18 Instant confirmedAt
19) {}
20 
21public record OrderCancelledEvent(
22 String orderId,
23 String reason,
24 String cancelledBy,
25 Instant cancelledAt
26) {}
27 
28public record OrderShippedEvent(
29 String orderId,
30 String trackingNumber,
31 String carrier,
32 Instant shippedAt
33) {}
34 
35public record OrderLineItem(
36 String productId,
37 int quantity,
38 long unitPriceCents
39) {}
40 

Commands

Commands represent intentions to change state. They target a specific aggregate instance via the @TargetAggregateIdentifier.

java
1package com.example.order.domain.commands;
2 
3import org.axonframework.modelling.command.TargetAggregateIdentifier;
4import java.util.List;
5 
6public record PlaceOrderCommand(
7 @TargetAggregateIdentifier String orderId,
8 String customerId,
9 List<OrderLineItem> lineItems,
10 String currency
11) {}
12 
13public record ConfirmOrderCommand(
14 @TargetAggregateIdentifier String orderId,
15 String confirmedBy
16) {}
17 
18public record CancelOrderCommand(
19 @TargetAggregateIdentifier String orderId,
20 String reason,
21 String cancelledBy
22) {}
23 
24public record ShipOrderCommand(
25 @TargetAggregateIdentifier String orderId,
26 String trackingNumber,
27 String carrier
28) {}
29 

Order Aggregate

The aggregate handles commands, enforces invariants, and emits events. Axon manages the lifecycle: loading from event store, applying commands, and persisting new events.

java
1package com.example.order.domain;
2 
3import com.example.order.domain.commands.*;
4import com.example.order.domain.events.*;
5import lombok.NoArgsConstructor;
6import org.axonframework.commandhandling.CommandHandler;
7import org.axonframework.eventsourcing.EventSourcingHandler;
8import org.axonframework.modelling.command.AggregateIdentifier;
9import org.axonframework.modelling.command.AggregateLifecycle;
10import org.axonframework.spring.stereotype.Aggregate;
11 
12import java.time.Instant;
13import java.util.ArrayList;
14import java.util.List;
15 
16@Aggregate
17@NoArgsConstructor
18public class OrderAggregate {
19 
20 @AggregateIdentifier
21 private String orderId;
22 private OrderStatus status;
23 private String customerId;
24 private List<OrderLineItem> lineItems;
25 private long totalAmountCents;
26 private String currency;
27 
28 @CommandHandler
29 public OrderAggregate(PlaceOrderCommand cmd) {
30 if (cmd.lineItems() == null || cmd.lineItems().isEmpty()) {
31 throw new IllegalArgumentException("Order must have at least one line item");
32 }
33 
34 long total = cmd.lineItems().stream()
35 .mapToLong(item -> item.unitPriceCents() * item.quantity())
36 .sum();
37 
38 AggregateLifecycle.apply(new OrderPlacedEvent(
39 cmd.orderId(),
40 cmd.customerId(),
41 cmd.lineItems(),
42 total,
43 cmd.currency(),
44 Instant.now()
45 ));
46 }
47 
48 @CommandHandler
49 public void handle(ConfirmOrderCommand cmd) {
50 if (status != OrderStatus.PLACED) {
51 throw new IllegalStateException(
52 "Cannot confirm order in status: " + status
53 );
54 }
55 AggregateLifecycle.apply(new OrderConfirmedEvent(
56 cmd.orderId(),
57 cmd.confirmedBy(),
58 Instant.now()
59 ));
60 }
61 
62 @CommandHandler
63 public void handle(CancelOrderCommand cmd) {
64 if (status == OrderStatus.CANCELLED) {
65 throw new IllegalStateException("Order already cancelled");
66 }
67 if (status == OrderStatus.SHIPPED) {
68 throw new IllegalStateException("Cannot cancel shipped order");
69 }
70 AggregateLifecycle.apply(new OrderCancelledEvent(
71 cmd.orderId(),
72 cmd.reason(),
73 cmd.cancelledBy(),
74 Instant.now()
75 ));
76 }
77 
78 @CommandHandler
79 public void handle(ShipOrderCommand cmd) {
80 if (status != OrderStatus.CONFIRMED) {
81 throw new IllegalStateException(
82 "Cannot ship order in status: " + status
83 );
84 }
85 AggregateLifecycle.apply(new OrderShippedEvent(
86 cmd.orderId(),
87 cmd.trackingNumber(),
88 cmd.carrier(),
89 Instant.now()
90 ));
91 }
92 
93 @EventSourcingHandler
94 public void on(OrderPlacedEvent event) {
95 this.orderId = event.orderId();
96 this.status = OrderStatus.PLACED;
97 this.customerId = event.customerId();
98 this.lineItems = new ArrayList<>(event.lineItems());
99 this.totalAmountCents = event.totalAmountCents();
100 this.currency = event.currency();
101 }
102 
103 @EventSourcingHandler
104 public void on(OrderConfirmedEvent event) {
105 this.status = OrderStatus.CONFIRMED;
106 }
107 
108 @EventSourcingHandler
109 public void on(OrderCancelledEvent event) {
110 this.status = OrderStatus.CANCELLED;
111 }
112 
113 @EventSourcingHandler
114 public void on(OrderShippedEvent event) {
115 this.status = OrderStatus.SHIPPED;
116 }
117}
118 
119enum OrderStatus {
120 PLACED, CONFIRMED, CANCELLED, SHIPPED
121}
122 

Key Axon concepts:

  • The constructor @CommandHandler creates the aggregate (first event)
  • Instance @CommandHandler methods handle subsequent commands
  • @EventSourcingHandler methods rebuild state from events during rehydration
  • AggregateLifecycle.apply() emits an event and immediately applies it

Projection (Read Model)

Projections listen to domain events and maintain denormalized read models.

java
1package com.example.order.projection;
2 
3import com.example.order.domain.events.*;
4import jakarta.persistence.*;
5import lombok.Data;
6import lombok.NoArgsConstructor;
7import org.axonframework.eventhandling.EventHandler;
8import org.axonframework.eventhandling.Timestamp;
9import org.springframework.stereotype.Component;
10 
11import java.time.Instant;
12 
13@Entity
14@Table(name = "order_summary")
15@Data
16@NoArgsConstructor
17public class OrderSummaryEntity {
18 @Id
19 private String orderId;
20 private String customerId;
21 private String status;
22 private long totalAmountCents;
23 private String currency;
24 private int itemCount;
25 private Instant placedAt;
26 private Instant confirmedAt;
27 private Instant cancelledAt;
28 private Instant shippedAt;
29 private Instant updatedAt;
30}
31 
32// Repository
33public interface OrderSummaryRepository extends JpaRepository<OrderSummaryEntity, String> {
34 List<OrderSummaryEntity> findByCustomerIdOrderByPlacedAtDesc(String customerId);
35 List<OrderSummaryEntity> findByStatus(String status);
36}
37 
38// Projection handler
39@Component
40public class OrderSummaryProjection {
41 
42 private final OrderSummaryRepository repository;
43 
44 public OrderSummaryProjection(OrderSummaryRepository repository) {
45 this.repository = repository;
46 }
47 
48 @EventHandler
49 public void on(OrderPlacedEvent event, @Timestamp Instant timestamp) {
50 var entity = new OrderSummaryEntity();
51 entity.setOrderId(event.orderId());
52 entity.setCustomerId(event.customerId());
53 entity.setStatus("PLACED");
54 entity.setTotalAmountCents(event.totalAmountCents());
55 entity.setCurrency(event.currency());
56 entity.setItemCount(event.lineItems().size());
57 entity.setPlacedAt(event.placedAt());
58 entity.setUpdatedAt(timestamp);
59 repository.save(entity);
60 }
61 
62 @EventHandler
63 public void on(OrderConfirmedEvent event, @Timestamp Instant timestamp) {
64 repository.findById(event.orderId()).ifPresent(entity -> {
65 entity.setStatus("CONFIRMED");
66 entity.setConfirmedAt(event.confirmedAt());
67 entity.setUpdatedAt(timestamp);
68 repository.save(entity);
69 });
70 }
71 
72 @EventHandler
73 public void on(OrderCancelledEvent event, @Timestamp Instant timestamp) {
74 repository.findById(event.orderId()).ifPresent(entity -> {
75 entity.setStatus("CANCELLED");
76 entity.setCancelledAt(event.cancelledAt());
77 entity.setUpdatedAt(timestamp);
78 repository.save(entity);
79 });
80 }
81 
82 @EventHandler
83 public void on(OrderShippedEvent event, @Timestamp Instant timestamp) {
84 repository.findById(event.orderId()).ifPresent(entity -> {
85 entity.setStatus("SHIPPED");
86 entity.setShippedAt(event.shippedAt());
87 entity.setUpdatedAt(timestamp);
88 repository.save(entity);
89 });
90 }
91}
92 

Need a second opinion on your system design architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Query Handling

Axon provides a query bus for structured query dispatching.

java
1package com.example.order.query;
2 
3import com.example.order.projection.OrderSummaryEntity;
4import com.example.order.projection.OrderSummaryRepository;
5import org.axonframework.queryhandling.QueryHandler;
6import org.springframework.stereotype.Component;
7 
8import java.util.List;
9 
10// Query definitions
11public record GetOrderQuery(String orderId) {}
12public record ListOrdersByCustomerQuery(String customerId) {}
13 
14@Component
15public class OrderQueryHandler {
16 
17 private final OrderSummaryRepository repository;
18 
19 public OrderQueryHandler(OrderSummaryRepository repository) {
20 this.repository = repository;
21 }
22 
23 @QueryHandler
24 public OrderSummaryEntity handle(GetOrderQuery query) {
25 return repository.findById(query.orderId())
26 .orElseThrow(() -> new OrderNotFoundException(query.orderId()));
27 }
28 
29 @QueryHandler
30 public List<OrderSummaryEntity> handle(ListOrdersByCustomerQuery query) {
31 return repository.findByCustomerIdOrderByPlacedAtDesc(query.customerId());
32 }
33}
34 
35class OrderNotFoundException extends RuntimeException {
36 OrderNotFoundException(String orderId) {
37 super("Order not found: " + orderId);
38 }
39}
40 

REST Controller

java
1package com.example.order.api;
2 
3import com.example.order.domain.commands.*;
4import com.example.order.domain.events.OrderLineItem;
5import com.example.order.projection.OrderSummaryEntity;
6import com.example.order.query.*;
7import org.axonframework.commandhandling.gateway.CommandGateway;
8import org.axonframework.queryhandling.QueryGateway;
9import org.springframework.http.HttpStatus;
10import org.springframework.web.bind.annotation.*;
11 
12import java.util.List;
13import java.util.UUID;
14import java.util.concurrent.CompletableFuture;
15 
16@RestController
17@RequestMapping("/api/orders")
18public class OrderController {
19 
20 private final CommandGateway commandGateway;
21 private final QueryGateway queryGateway;
22 
23 public OrderController(CommandGateway commandGateway, QueryGateway queryGateway) {
24 this.commandGateway = commandGateway;
25 this.queryGateway = queryGateway;
26 }
27 
28 @PostMapping
29 @ResponseStatus(HttpStatus.CREATED)
30 public CompletableFuture<CreateOrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
31 String orderId = UUID.randomUUID().toString();
32 return commandGateway.send(new PlaceOrderCommand(
33 orderId,
34 request.customerId(),
35 request.lineItems(),
36 request.currency()
37 )).thenApply(result -> new CreateOrderResponse(orderId));
38 }
39 
40 @PostMapping("/{orderId}/confirm")
41 public CompletableFuture<Void> confirmOrder(
42 @PathVariable String orderId,
43 @RequestBody ConfirmRequest request
44 ) {
45 return commandGateway.send(new ConfirmOrderCommand(orderId, request.confirmedBy()));
46 }
47 
48 @PostMapping("/{orderId}/cancel")
49 public CompletableFuture<Void> cancelOrder(
50 @PathVariable String orderId,
51 @RequestBody CancelRequest request
52 ) {
53 return commandGateway.send(new CancelOrderCommand(orderId, request.reason(), request.cancelledBy()));
54 }
55 
56 @PostMapping("/{orderId}/ship")
57 public CompletableFuture<Void> shipOrder(
58 @PathVariable String orderId,
59 @RequestBody ShipRequest request
60 ) {
61 return commandGateway.send(new ShipOrderCommand(orderId, request.trackingNumber(), request.carrier()));
62 }
63 
64 @GetMapping("/{orderId}")
65 public CompletableFuture<OrderSummaryEntity> getOrder(@PathVariable String orderId) {
66 return queryGateway.query(new GetOrderQuery(orderId), OrderSummaryEntity.class);
67 }
68 
69 @GetMapping
70 public CompletableFuture<List<OrderSummaryEntity>> listOrders(@RequestParam String customerId) {
71 return queryGateway.query(
72 new ListOrdersByCustomerQuery(customerId),
73 List.class
74 );
75 }
76}
77 
78record CreateOrderRequest(String customerId, List<OrderLineItem> lineItems, String currency) {}
79record CreateOrderResponse(String orderId) {}
80record ConfirmRequest(String confirmedBy) {}
81record CancelRequest(String reason, String cancelledBy) {}
82record ShipRequest(String trackingNumber, String carrier) {}
83 

Saga for Cross-Aggregate Coordination

Axon sagas manage long-running business processes that span multiple aggregates.

java
1package com.example.order.saga;
2 
3import com.example.order.domain.commands.ShipOrderCommand;
4import com.example.order.domain.events.OrderConfirmedEvent;
5import com.example.order.domain.events.OrderShippedEvent;
6import lombok.NoArgsConstructor;
7import org.axonframework.commandhandling.gateway.CommandGateway;
8import org.axonframework.modelling.saga.EndSaga;
9import org.axonframework.modelling.saga.SagaEventHandler;
10import org.axonframework.modelling.saga.StartSaga;
11import org.axonframework.spring.stereotype.Saga;
12import org.springframework.beans.factory.annotation.Autowired;
13 
14@Saga
15@NoArgsConstructor
16public class OrderFulfillmentSaga {
17 
18 @Autowired
19 private transient CommandGateway commandGateway;
20 
21 private String orderId;
22 
23 @StartSaga
24 @SagaEventHandler(associationProperty = "orderId")
25 public void handle(OrderConfirmedEvent event) {
26 this.orderId = event.orderId();
27 // In production: trigger inventory reservation, warehouse assignment, etc.
28 // For this example, we simulate automatic shipping
29 }
30 
31 @EndSaga
32 @SagaEventHandler(associationProperty = "orderId")
33 public void handle(OrderShippedEvent event) {
34 // Saga complete — order has been shipped
35 }
36}
37 

Integration Testing

java
1@SpringBootTest
2class OrderIntegrationTest {
3 
4 @Autowired
5 private CommandGateway commandGateway;
6 
7 @Autowired
8 private QueryGateway queryGateway;
9 
10 @Test
11 void shouldPlaceAndConfirmOrder() {
12 String orderId = UUID.randomUUID().toString();
13 
14 // Place order
15 commandGateway.sendAndWait(new PlaceOrderCommand(
16 orderId, "customer-1",
17 List.of(new OrderLineItem("product-1", 2, 2500)),
18 "USD"
19 ));
20 
21 // Wait for projection
22 await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
23 var order = queryGateway.query(
24 new GetOrderQuery(orderId), OrderSummaryEntity.class
25 ).join();
26 assertThat(order.getStatus()).isEqualTo("PLACED");
27 assertThat(order.getTotalAmountCents()).isEqualTo(5000);
28 });
29 
30 // Confirm order
31 commandGateway.sendAndWait(new ConfirmOrderCommand(orderId, "admin"));
32 
33 await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
34 var order = queryGateway.query(
35 new GetOrderQuery(orderId), OrderSummaryEntity.class
36 ).join();
37 assertThat(order.getStatus()).isEqualTo("CONFIRMED");
38 });
39 }
40 
41 @Test
42 void shouldRejectCancellingShippedOrder() {
43 String orderId = UUID.randomUUID().toString();
44 
45 commandGateway.sendAndWait(new PlaceOrderCommand(
46 orderId, "customer-1",
47 List.of(new OrderLineItem("product-1", 1, 1000)),
48 "USD"
49 ));
50 commandGateway.sendAndWait(new ConfirmOrderCommand(orderId, "admin"));
51 commandGateway.sendAndWait(new ShipOrderCommand(orderId, "TRK-123", "FedEx"));
52 
53 assertThatThrownBy(() ->
54 commandGateway.sendAndWait(new CancelOrderCommand(orderId, "Changed mind", "customer-1"))
55 ).hasMessageContaining("Cannot cancel shipped order");
56 }
57}
58 

Aggregate Testing with Axon Test Fixtures

java
1class OrderAggregateTest {
2 
3 private FixtureConfiguration<OrderAggregate> fixture;
4 
5 @BeforeEach
6 void setup() {
7 fixture = new AggregateTestFixture<>(OrderAggregate.class);
8 }
9 
10 @Test
11 void shouldPlaceOrder() {
12 fixture.givenNoPriorActivity()
13 .when(new PlaceOrderCommand(
14 "order-1", "customer-1",
15 List.of(new OrderLineItem("prod-1", 2, 1500)),
16 "USD"
17 ))
18 .expectSuccessfulHandlerExecution()
19 .expectEventsMatching(exactSequenceOf(
20 messageWithPayload(Matchers.isA(OrderPlacedEvent.class))
21 ));
22 }
23 
24 @Test
25 void shouldNotConfirmDraftOrder() {
26 fixture.givenNoPriorActivity()
27 .when(new ConfirmOrderCommand("order-1", "admin"))
28 .expectException(IllegalStateException.class);
29 }
30 
31 @Test
32 void shouldConfirmPlacedOrder() {
33 fixture.given(new OrderPlacedEvent(
34 "order-1", "cust-1",
35 List.of(new OrderLineItem("p1", 1, 1000)),
36 1000, "USD", Instant.now()
37 ))
38 .when(new ConfirmOrderCommand("order-1", "admin"))
39 .expectSuccessfulHandlerExecution()
40 .expectEvents(Matchers.isA(OrderConfirmedEvent.class));
41 }
42}
43 

Conclusion

Spring Boot with Axon Framework provides the most batteries-included CQRS and Event Sourcing experience available. Axon handles aggregate lifecycle management, event store persistence, saga coordination, and query routing, letting you focus on domain logic rather than infrastructure plumbing. The @CommandHandler, @EventSourcingHandler, and @EventHandler annotations make the pattern explicit and discoverable.

For teams already using Spring Boot, adding Axon is the fastest path to a production-quality CQRS/ES system. The Axon Test Fixtures provide a given-when-then DSL specifically designed for testing event-sourced aggregates, making it straightforward to verify business rules without touching a database.

FAQ

Need expert help?

Building with system design?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026