Rethinking Java CRUDs With Event Sourcing and CQRS Patterns
A short overview of the Event Sourcing and CQRS patterns, applied to Java CRUD applications, illustrated by a simple order management system.
Join the DZone community and get the full member experience.
Join For FreeTraditional 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.
Terminology
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 for 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 of 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 is closely related to the event sourcing one, designed 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 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 that of a 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 own execution model, while a query cannot directly modify any of the system's persisted state.
A Use Case
The use case presented here is a *true* CQRS implementation (not just a naming convention) because:
- The write path never reads from the read model.
CommandHandlerreconstructs state exclusively by replaying events from the event store viaEventProjection.replayEvents(). It never touchesOrderReadModelorOrderRepository. - The read path never touches the event store.
OrderResource.getOrderReadModel()reads directly from the denormalizedORDERStable. It is a pure query with no business logic. - There are two physically distinct storage tables:
EVENT_STORE(write side) andORDERS(read side). - The read model is a projection, not a view.
OrderProjectionlistens to domain events and rebuilds the read model incrementally. TheORDERStable could be dropped and rebuilt from scratch by replaying the event store. - 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 implementation 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.
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 switchexpressions. 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:
- Persists the event to
EVENT_STORE(JPA viaEventRepository). - 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": "[email protected]" }
Add an item:
POST /orders/{id}/items
{ "productName": "Widget", "quantity": 3, "price": 9.99 }
Ship an order:
POST /orders/{id}/ship
{ "trackingNumber": "TRACK-001" }
Opinions expressed by DZone contributors are their own.
Comments