7 minute read

Traditional CRUD systems store only the current state of an entity. When a record is updated, the previous value is overwritten and lost forever. Event Sourcing inverts this model: instead of persisting state, the system persists the sequence of events that caused each state transition. The current state is never stored directly, but it is always derived by replaying the event history.

Command Query Responsibility Segregation (CQRS) separates the write model from the read model. A command expresses intent to change state, for example PlaceOrder, AddItem, ShipOrder. A query reads state without modifying it. The two sides use separate models, separate logic, and, in a full implementation, separate storage.

CQRS and Event Sourcing are complementary: the event stream is the write side’s source of truth, while one or more projections (read models) are derived from those events for fast querying.

This article aims at showing how to apply, in practice, these concepts, using for illustration purposes a modified version of one of Markus Eisele’s article, from the 27th of December 2025 on Substack. In his article, Markus shows a Quarkus based project implementing a simplified order management system. Here, I’m presenting the Spring Boot implementation of this same system, to change. You can find it here: https://github.com/nicolasduminil/cqrs-showcase.

Introduction

In a classical order management system, by analyzing the associated data model, we can gather a lot of information about orders and their flow in the organization. But while we would be able to account about any order’s current status, the data and the data model analysis wouldn’t allow us to reconstitute the story of how each order got to its current state.

Event sourcing

The event sourcing pattern introduces the dimension of time into the data model. Instead of a schema reflecting the orders’ current state, an event sourcing based system persists events documenting every change in the orders’ lifecycle. Then, by querying these events, we can reconstitute the whole story od a given order, or any other general aggregate, from its initial creation until its current status.

CQRS

The only problem here is that querying a single aggregate instance event story at a time doesn’t allow us to retrieve and consolidate data relative to other aggregates in the data model. Hence, the CQRS pattern closely related to the event sourcing one, designed such that to provide the possibility of materializing projected models into logical data structures, reliable enough to support flexible querying options.

Commands

CQRS dedicates commands to execute operations that modify the system state. The command based execution model is then the only one able to implement business logic, to validate rules and to enforce invariants.

Projections

The system can define as many models as required to provide data to users or to other systems. Thus, a read model is a fast, denormalized and shaped pre-cached projection containing read-only data that the application needs to answer queries. The system project changes from the command execution model to all its read models. The projection notion is similar to the one of materialized view in relational databases, meaning that whenever the source tables are updated, the changes have to be reflected in all the read model views.

Model segregation

In a CQRS architecture, the responsibilities of the system’s models are segregated according to their type. A command can only operate on its only execution model, while a query cannot directly modify any of the system’s persisted state.

How this project illustrates event sourcing and CQRS

This is a true CQRS implementation (not just a naming convention) because:

  1. The write path never reads from the read model. CommandHandler reconstructs state exclusively by replaying events from the event store via EventProjection.replayEvents(). It never touches OrderReadModel or OrderRepository.
  2. The read path never touches the event store. OrderResource.getOrderReadModel() reads directly from the denormalized ORDERS table. It is a pure query with no business logic.
  3. There are two physically distinct storage tables: EVENT_STORE (write side) and ORDERS (read side).
  4. The read model is a projection, not a view. OrderProjection listens to domain events and rebuilds the read model incrementally. The ORDERS table could be dropped and rebuilt from scratch by replaying the event store.
  5. Commands return CommandResult, a sealed type that communicates success or failure without leaking state. The caller must query the read model separately if it needs current state.

Let’s look now at the project’s key implemntation details:

Modeling State with Records

OrderState is a Java record immutable by construction. No setters, no mutation. Every command produces a new state object:

  public record OrderState(
      UUID orderId,
      String customerEmail,
      List<OrderLine> items,
      OrderStatus status,
      BigDecimal total
  ) {                                                                                                                                                                                                                                       
      public static OrderState initial(UUID orderId, String email) { ... }
      public static OrderState empty() { ... }                                                                                                                                                                                              
  }               

OrderLine is likewise a record with a derived field:

  public record OrderLine(String productName, int quantity, BigDecimal price) {
      public BigDecimal lineTotal() {
          return price.multiply(BigDecimal.valueOf(quantity));
      }                                                                                                                                                                                                                                     
  }

lineTotal() is a derived record component: it is computed, not stored, demonstrating that records can carry behavior alongside data.

Events as a Sealed Type Hierarchy

OrderEvent is a sealed interface, restricting all permitted implementations to a known, closed set:

  public sealed interface OrderEvent
      permits OrderEvent.OrderPlaced,
              OrderEvent.ItemAdded,                                                                                                                                                                                                         
              OrderEvent.ItemRemoved,
              OrderEvent.OrderCancelled,                                                                                                                                                                                                    
              OrderEvent.OrderShipped {

      UUID orderId();
      OrderState applyTo(OrderState current);
                                                                                                                                                                                                                                            
      record OrderPlaced(UUID orderId, String customerEmail) implements OrderEvent {
          public OrderState applyTo(OrderState s) {                                                                                                                                                                                         
              return OrderState.initial(orderId, customerEmail);                                                                                                                                                                            
          }
      }                                                                                                                                                                                                                                     
      // ... other event types
  }                                                                                                                                                                                                                                         

Using a sealed interface means the compiler enforces exhaustiveness in switch expressions. Adding a new event type without handling it is a compile error, not a runtime surprise.

Each event carries only the data it needs and knows how to apply itself to the current state via applyTo(OrderState). This is the self-describing event pattern.

The Fold (Event Replay)

A fold, also known as left reduction, is the process of reconstructing state from a list of events over the event stream:

  // EventProjection.java
  public OrderState replayEvents(List<OrderEvent> events) {                                                                                                                                                                                 
      return events.stream()
          .reduce(OrderState.empty(), this::apply, (a, b) -> b);                                                                                                                                                                            
  }                                                                                                                                                                                                                                         
   
  private OrderState apply(OrderState state, OrderEvent event) {                                                                                                                                                                            
      return event.applyTo(state);                                                                                                                                                                                                          
  }

OrderState.empty() is the identity element or the seed. Each event is a step function that transforms one immutable state into the next. This is pure functional programming: no side effects, no shared mutable state, entirely deterministic and testable in isolation.

Commands as Sealed Records

Commands are sealed records grouped in a container interface:

  public sealed interface Command
      permits Command.PlaceOrderCommand,
              Command.AddItemCommand,                                                                                                                                                                                                       
              Command.ShipOrderCommand,
              Command.CancelOrderCommand {                                                                                                                                                                                                  
                  
      record PlaceOrderCommand(String customerEmail) implements Command {}
      record AddItemCommand(UUID orderId, String productName,
                            int quantity, BigDecimal price) implements Command {}                                                                                                                                                           
      // ...
  }                                                                                                                                                                                                                                         

Sealed records give commands value semantics, (equality by content, toString for free. and type safety (exhaustive pattern matching in the handler).

Command Results as Sealed Types

CommandResult is a sealed interface expressing all possible outcomes without exceptions:

  public sealed interface CommandResult
      permits CommandResult.Success,
              CommandResult.InvalidState,
              CommandResult.NotFound,
              CommandResult.ValidationError {

      record Success(UUID aggregateId) implements CommandResult {}
      record InvalidState(String message) implements CommandResult {}                                                                                                                                                                       
      record NotFound(String message) implements CommandResult {}
      record ValidationError(String message) implements CommandResult {}                                                                                                                                                                    
  }               

The caller can switch on the result exhaustively. There are no checked exceptions, no nullable returns, and the type system documents all possible failure modes.

The Event Store

EventStore is the write-side infrastructure. It does two things atomically:

  1. Persists the event to EVENT_STORE (JPA via EventRepository).
  2. Publishes the event to the Spring application event bus.
  public void append(UUID aggregateId, String aggregateType, OrderEvent event) {
      int version = nextVersion(aggregateId);                                                                                                                                                                                               
      String json = objectMapper.writeValueAsString(event);
      StoredEvent entity = new StoredEvent(aggregateId, aggregateType,                                                                                                                                                                      
                                           version, eventType, json);
      eventRepository.save(entity);                                                                                                                                                                                                         
      applicationEventPublisher.publishEvent(event);
  }                                                                                                                                                                                                                                         

Versioning provides a lightweight optimistic concurrency guard, by preventing concurrent writes from corrupting the stream, based on the unique value aggregateId + version.

The Read-Side Projection

OrderProjection is a Spring component that listens for domain events and updates the read model:

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void on(OrderEvent event) {
      OrderReadModel model = orderRepository.findByOrderId(event.orderId())
          .orElse(new OrderReadModel());                                                                                                                                                                                                    
      // update fields from event ...
      orderRepository.save(model);                                                                                                                                                                                                          
  }               

@TransactionalEventListener(phase = AFTER_COMMIT) ensures the read model is only updated after the event store transaction commits successfully, preventing this way phantom updates if the write-side transaction rolls back.


Running the Application

Prerequisites: Java 21, Maven, Docker (for PostgreSQL via TestContainers in tests).

  # Build and run all tests (requires Docker)
  ./mvnw clean package
                                                                                                                                                                                                                                            
  # Run the application (requires a running PostgreSQL instance)
  ./mvnw spring-boot:run                                                                                                                                                                                                                    
                  
  # Skip tests
  ./mvnw clean package -DskipTests

API Reference

Method Path Description
POST /orders Place a new order
POST /orders/{id}/items Add an item to an order
POST /orders/{id}/ship Ship an order
POST /orders/{id}/cancel Cancel an order
GET /orders/{id} Reconstruct current state from events
GET /orders/{id}/events Retrieve the full event stream
GET /orders/{id}/read-model Retrieve the denormalized read model

Place an order:

  POST /orders                                                                                                                                                                                                                              
  { "customerEmail": "alice@example.com" }

Add an item:

  POST /orders/{id}/items                                                                                                                                                                                                                   
  { "productName": "Widget", "quantity": 3, "price": 9.99 }

Ship an order:

  POST /orders/{id}/ship
  { "trackingNumber": "TRACK-001" }