Build Event-Sourced Systems in Quarkus with Java Records and CQRS
A complete hands-on tutorial for modern, data-oriented application design
When I first tried to explain event sourcing to Java developers in workshops, the room usually split in two. One half loved the idea of immutable events and time travel. The other half saw infrastructure complexity, new vocabulary, and a lot of ways to get it wrong.
With modern Java and Quarkus, this can be much simpler.
In this tutorial we build a small but complete event sourced “Order Service” that:
Stores only immutable events in PostgreSQL
Rebuilds current state by replaying events with a pure function
Uses Java records and sealed types for data and events
Uses Panache and Jackson for a minimal event store
Exposes a simple REST API for commands and queries
You can copy and paste everything into your project or use it as a starting point for a real system.
What We Are Going To Build
The domain is intentionally simple:
An Order is represented by a derived
OrderStateWe never store
OrderStatein the databaseWe only store events like
OrderPlaced,ItemAdded,OrderShippedThe state is rebuilt by folding events through a pure
applyfunction
We also have a tiny read model that keeps the latest state in a separate table for fast queries. Writes use the event store. Reads use the projection.
This keeps the core idea visible without adding Kafka, sagas, or external event stores.
Prerequisites
You need:
Java 21
Maven 3.9+
Quarkus CLI (recommended)
Docker or Podman for Dev Services PostgreSQL
Make sure Docker or Podman is running so Quarkus can start PostgreSQL automatically.
Project Setup with Quarkus and Panache
We start by generating a new Quarkus application with REST, Jackson, Hibernate ORM, and PostgreSQL. (Or, if you want to, you can decide to start from the code in my Github repository.)
From an empty directory, run:
quarkus create app com.example:event-sourcing-quarkus \
--extension=rest-jackson,hibernate-orm-panache,jdbc-postgresql \
--java=21
cd event-sourcing-quarkusNext, we configure Dev Services and Hibernate.
Edit src/main/resources/application.properties:
# Use PostgreSQL via Dev Services
quarkus.datasource.db-kind=postgresql
# Auto-generate schema for this tutorial
quarkus.hibernate-orm.schema-management.strategy = drop-and-create
# Show SQL to understand what happens
quarkus.hibernate-orm.log.sql=trueWith the project skeleton in place, we can model our domain using Java records and a sealed interface for events.
Modeling Events and State with Records
We start with the domain model. The key idea is:
Events are immutable records implementing a sealed interface
State is a record derived from a list of events
Order Status and Order Line
Create src/main/java/com/example/events/OrderStatus.java:
package com.example.events;
public enum OrderStatus {
DRAFT,
SHIPPED,
CANCELLED
}Create src/main/java/com/example/events/OrderLine.java:
package com.example.events;
import java.math.BigDecimal;
public record OrderLine(
String productName,
int quantity,
BigDecimal price
) {
public BigDecimal lineTotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}Order State as a Derived Record
Create src/main/java/com/example/events/OrderState.java:
package com.example.events;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
public record OrderState(
UUID orderId,
String customerEmail,
List<OrderLine> items,
OrderStatus status,
BigDecimal total
) {
public static OrderState initial(UUID id, String email) {
return new OrderState(
id,
email,
List.of(),
OrderStatus.DRAFT,
BigDecimal.ZERO
);
}
public static OrderState empty() {
return new OrderState(
null,
null,
List.of(),
OrderStatus.DRAFT,
BigDecimal.ZERO
);
}
}empty() is useful as a neutral starting point when we replay events.
Events as a Sealed Type Hierarchy
Now we define the event types. Each event:
Implements
OrderEventHas
orderIdandtimestampIs an immutable record
Create src/main/java/com/example/events/OrderEvent.java:
package com.example.events;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
// Sealed interface: the compiler knows all event types
public sealed interface OrderEvent
permits OrderEvent.OrderPlaced,
OrderEvent.ItemAdded,
OrderEvent.ItemRemoved,
OrderEvent.OrderCancelled,
OrderEvent.OrderShipped {
UUID orderId();
Instant timestamp();
record OrderPlaced(
UUID orderId,
String customerEmail,
Instant timestamp) implements OrderEvent {
}
record ItemAdded(
UUID orderId,
String productName,
int quantity,
BigDecimal price,
Instant timestamp) implements OrderEvent {
}
record ItemRemoved(
UUID orderId,
String productName,
int quantity,
BigDecimal price,
Instant timestamp) implements OrderEvent {
}
record OrderCancelled(
UUID orderId,
String reason,
Instant timestamp) implements OrderEvent {
}
record OrderShipped(
UUID orderId,
String trackingNumber,
Instant timestamp) implements OrderEvent {
}
}We now have a clear, type safe set of immutable facts. The next step is to define how these events change our state.
Pure Event Application: The Fold
The heart of event sourcing is a pure function that takes:
A previous
OrderStateAn
OrderEvent
and returns a new OrderState.
Create src/main/java/com/example/events/EventProjection.java:
package com.example.events;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
public final class EventProjection {
private EventProjection() {
// utility class
}
public static OrderState apply(OrderState state, OrderEvent event) {
return switch (event) {
case OrderEvent.OrderPlaced e -> OrderState.initial(
e.orderId(),
e.customerEmail()
);
case OrderEvent.ItemAdded e -> {
var items = new ArrayList<>(state.items());
items.add(new OrderLine(
e.productName(),
e.quantity(),
e.price()
));
var newTotal = state.total().add(
e.price().multiply(BigDecimal.valueOf(e.quantity()))
);
yield new OrderState(
state.orderId(),
state.customerEmail(),
List.copyOf(items),
state.status(),
newTotal
);
}
case OrderEvent.ItemRemoved e -> {
var items = new ArrayList<>(state.items());
// very naive removal: remove first matching product
items.removeIf(line ->
line.productName().equals(e.productName())
&& line.price().compareTo(e.price()) == 0
);
var newTotal = state.total().subtract(
e.price().multiply(BigDecimal.valueOf(e.quantity()))
);
yield new OrderState(
state.orderId(),
state.customerEmail(),
List.copyOf(items),
state.status(),
newTotal
);
}
case OrderEvent.OrderCancelled e -> new OrderState(
state.orderId(),
state.customerEmail(),
state.items(),
OrderStatus.CANCELLED,
state.total()
);
case OrderEvent.OrderShipped e -> new OrderState(
state.orderId(),
state.customerEmail(),
state.items(),
OrderStatus.SHIPPED,
state.total()
);
};
}
public static OrderState replayEvents(List<OrderEvent> events) {
return events.stream()
.reduce(
OrderState.empty(),
EventProjection::apply,
(left, right) -> right
);
}
}
We now have a fully pure core. You can test EventProjection without Quarkus, without a database, and without mocks. Before we add tests, we need to persist events.
Event Store with Panache and Jackson
We will use a single event_store table that stores:
Aggregate ID (the order id)
Aggregate type
Version number
Event type
JSON payload
Timestamp
First we define the entity, then an EventStore service that handles append and load operations.
StoredEvent Entity
Create src/main/java/com/example/events/StoredEvent.java:
package com.example.events;
import java.time.Instant;
import java.util.UUID;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
@Entity
@Table(name = “event_store”, indexes = {
@Index(name = “idx_event_aggregate”, columnList = “aggregateId, version”)
})
public class StoredEvent extends PanacheEntity {
@Column(nullable = false, columnDefinition = “uuid”)
public UUID aggregateId;
@Column(nullable = false, length = 64)
public String aggregateType;
@Column(nullable = false)
public long version;
@Column(nullable = false, length = 64)
public String eventType;
@Column(nullable = false, columnDefinition = “text”)
public String eventData;
@Column(nullable = false)
public Instant timestamp;
}Using PanacheEntity gives us an auto-generated id field and helpers like list and count. Next we build the store service.
EventStore Service
Create src/main/java/com/example/events/EventStore.java:
package com.example.events;
import static jakarta.transaction.Transactional.TxType.SUPPORTS;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class EventStore {
@Inject
ObjectMapper objectMapper;
@Inject
Event<OrderEvent> orderEventBus;
@Transactional
public void append(UUID aggregateId, String aggregateType, OrderEvent event) {
StoredEvent stored = new StoredEvent();
stored.aggregateId = aggregateId;
stored.aggregateType = aggregateType;
stored.version = nextVersion(aggregateId);
stored.eventType = eventType(event);
stored.eventData = serialize(event);
stored.timestamp = event.timestamp();
stored.persist();
// Publish CDI event for projections and other listeners
orderEventBus.fire(event);
}
@Transactional(SUPPORTS)
public List<OrderEvent> loadEvents(UUID aggregateId) {
List<StoredEvent> rows = StoredEvent.list(
“aggregateId = ?1 ORDER BY version”,
aggregateId);
return rows.stream()
.map(this::deserialize)
.toList();
}
private long nextVersion(UUID aggregateId) {
long count = StoredEvent.count(”aggregateId”, aggregateId);
return count + 1;
}
private String eventType(OrderEvent event) {
return switch (event) {
case OrderEvent.OrderPlaced ignored -> “OrderPlaced”;
case OrderEvent.ItemAdded ignored -> “ItemAdded”;
case OrderEvent.ItemRemoved ignored -> “ItemRemoved”;
case OrderEvent.OrderCancelled ignored -> “OrderCancelled”;
case OrderEvent.OrderShipped ignored -> “OrderShipped”;
};
}
private String serialize(OrderEvent event) {
try {
return objectMapper.writeValueAsString(event);
} catch (JsonProcessingException e) {
throw new IllegalStateException(”Could not serialize event “ + event, e);
}
}
private OrderEvent deserialize(StoredEvent stored) {
try {
return switch (stored.eventType) {
case “OrderPlaced” ->
objectMapper.readValue(stored.eventData, OrderEvent.OrderPlaced.class);
case “ItemAdded” ->
objectMapper.readValue(stored.eventData, OrderEvent.ItemAdded.class);
case “ItemRemoved” ->
objectMapper.readValue(stored.eventData, OrderEvent.ItemRemoved.class);
case “OrderCancelled” ->
objectMapper.readValue(stored.eventData, OrderEvent.OrderCancelled.class);
case “OrderShipped” ->
objectMapper.readValue(stored.eventData, OrderEvent.OrderShipped.class);
default ->
throw new IllegalArgumentException(”Unknown event type “ + stored.eventType);
};
} catch (IOException e) {
throw new IllegalStateException(”Could not deserialize event “ + stored.id, e);
}
}
}This is deliberately simple and synchronous. In a real system you would add proper optimistic locking and dedicated error handling.
Now that we can create and load event streams, we need a way to express commands and handle them.
Commands and Command Results
Commands represent user intent. The handler validates business rules, generates events, and appends them to the store.
Command Result
Create src/main/java/com/example/events/CommandResult.java:
package com.example.events;
import java.util.UUID;
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 {
}
}Commands
Create src/main/java/com/example/events/Commands.java:
package com.example.events;
import java.math.BigDecimal;
import java.util.UUID;
public final class Commands {
private Commands() {
}
public record PlaceOrderCommand(
String customerEmail) {
}
public record AddItemCommand(
UUID orderId,
String productName,
int quantity,
BigDecimal price) {
}
public record ShipOrderCommand(
UUID orderId,
String trackingNumber) {
}
public record CancelOrderCommand(
UUID orderId,
String reason) {
}
}With commands defined, we can now implement the handler that applies business rules and talks to the EventStore.
Order Command Handler
The handler is where we connect commands to domain logic, event creation, and persistence.
Create src/main/java/com/example/events/OrderCommandHandler.java:
package com.example.events;
import static jakarta.transaction.Transactional.TxType.SUPPORTS;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import com.example.events.Commands.AddItemCommand;
import com.example.events.Commands.CancelOrderCommand;
import com.example.events.Commands.PlaceOrderCommand;
import com.example.events.Commands.ShipOrderCommand;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class OrderCommandHandler {
private static final String AGGREGATE_TYPE = “Order”;
@Inject
EventStore eventStore;
@Transactional
public CommandResult placeOrder(PlaceOrderCommand cmd) {
if (cmd.customerEmail() == null || cmd.customerEmail().isBlank()) {
return new CommandResult.ValidationError(”Customer email must not be empty”);
}
UUID orderId = UUID.randomUUID();
var event = new OrderEvent.OrderPlaced(
orderId,
cmd.customerEmail(),
Instant.now());
eventStore.append(orderId, AGGREGATE_TYPE, event);
return new CommandResult.Success(orderId);
}
@Transactional
public CommandResult addItem(AddItemCommand cmd) {
List<OrderEvent> events = eventStore.loadEvents(cmd.orderId());
if (events.isEmpty()) {
return new CommandResult.NotFound(”Order not found: “ + cmd.orderId());
}
OrderState current = EventProjection.replayEvents(events);
if (current.status() != OrderStatus.DRAFT) {
return new CommandResult.InvalidState(
“Cannot add items to order in status “ + current.status());
}
if (cmd.quantity() <= 0) {
return new CommandResult.ValidationError(”Quantity must be positive”);
}
var event = new OrderEvent.ItemAdded(
cmd.orderId(),
cmd.productName(),
cmd.quantity(),
cmd.price(),
Instant.now());
eventStore.append(cmd.orderId(), AGGREGATE_TYPE, event);
return new CommandResult.Success(cmd.orderId());
}
@Transactional
public CommandResult shipOrder(ShipOrderCommand cmd) {
List<OrderEvent> events = eventStore.loadEvents(cmd.orderId());
if (events.isEmpty()) {
return new CommandResult.NotFound(”Order not found: “ + cmd.orderId());
}
OrderState current = EventProjection.replayEvents(events);
if (current.status() != OrderStatus.DRAFT) {
return new CommandResult.InvalidState(
“Only DRAFT orders can be shipped. Current status: “ + current.status());
}
var event = new OrderEvent.OrderShipped(
cmd.orderId(),
cmd.trackingNumber(),
Instant.now());
eventStore.append(cmd.orderId(), AGGREGATE_TYPE, event);
return new CommandResult.Success(cmd.orderId());
}
@Transactional
public CommandResult cancelOrder(CancelOrderCommand cmd) {
List<OrderEvent> events = eventStore.loadEvents(cmd.orderId());
if (events.isEmpty()) {
return new CommandResult.NotFound(”Order not found: “ + cmd.orderId());
}
OrderState current = EventProjection.replayEvents(events);
if (current.status() == OrderStatus.SHIPPED) {
return new CommandResult.InvalidState(”Cannot cancel shipped order”);
}
var event = new OrderEvent.OrderCancelled(
cmd.orderId(),
cmd.reason(),
Instant.now());
eventStore.append(cmd.orderId(), AGGREGATE_TYPE, event);
return new CommandResult.Success(cmd.orderId());
}
@Transactional(SUPPORTS)
public OrderState loadCurrentState(UUID orderId) {
List<OrderEvent> events = eventStore.loadEvents(orderId);
if (events.isEmpty()) {
return null;
}
return EventProjection.replayEvents(events);
}
}The command handler is now ready. Next we expose it via a REST API so we can drive everything with HTTP.
REST API for Orders
The REST resource will:
Accept commands as JSON
Call the handler
Return proper HTTP codes
Expose current state and events for debugging
Create src/main/java/com/example/events/OrderResource.java:
package com.example.events;
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import com.example.events.Commands.AddItemCommand;
import com.example.events.Commands.CancelOrderCommand;
import com.example.events.Commands.PlaceOrderCommand;
import com.example.events.Commands.ShipOrderCommand;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/orders”)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class OrderResource {
@Inject
OrderCommandHandler handler;
@Inject
EventStore eventStore;
@POST
public Response placeOrder(PlaceOrderRequest request) {
var cmd = new PlaceOrderCommand(request.customerEmail());
CommandResult result = handler.placeOrder(cmd);
return switch (result) {
case CommandResult.Success s -> Response
.created(URI.create(”/orders/” + s.aggregateId()))
.entity(s)
.build();
case CommandResult.ValidationError v -> Response
.status(Response.Status.BAD_REQUEST)
.entity(v)
.build();
default -> Response
.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(result)
.build();
};
}
@POST
@Path(”/{id}/items”)
public Response addItem(@PathParam(”id”) UUID orderId, AddItemRequest request) {
var cmd = new AddItemCommand(
orderId,
request.productName(),
request.quantity(),
request.price());
CommandResult result = handler.addItem(cmd);
return switch (result) {
case CommandResult.Success s -> Response.ok(s).build();
case CommandResult.NotFound n -> Response.status(Response.Status.NOT_FOUND).entity(n).build();
case CommandResult.InvalidState i -> Response.status(Response.Status.CONFLICT).entity(i).build();
case CommandResult.ValidationError v -> Response.status(Response.Status.BAD_REQUEST).entity(v).build();
};
}
@POST
@Path(”/{id}/ship”)
public Response ship(@PathParam(”id”) UUID orderId, ShipOrderRequest request) {
var cmd = new ShipOrderCommand(orderId, request.trackingNumber());
CommandResult result = handler.shipOrder(cmd);
return switch (result) {
case CommandResult.Success s -> Response.ok(s).build();
case CommandResult.NotFound n -> Response.status(Response.Status.NOT_FOUND).entity(n).build();
case CommandResult.InvalidState i -> Response.status(Response.Status.CONFLICT).entity(i).build();
default -> Response.status(Response.Status.BAD_REQUEST).entity(result).build();
};
}
@POST
@Path(”/{id}/cancel”)
public Response cancel(@PathParam(”id”) UUID orderId, CancelOrderRequest request) {
var cmd = new CancelOrderCommand(orderId, request.reason());
CommandResult result = handler.cancelOrder(cmd);
return switch (result) {
case CommandResult.Success s -> Response.ok(s).build();
case CommandResult.NotFound n -> Response.status(Response.Status.NOT_FOUND).entity(n).build();
case CommandResult.InvalidState i -> Response.status(Response.Status.CONFLICT).entity(i).build();
default -> Response.status(Response.Status.BAD_REQUEST).entity(result).build();
};
}
@GET
@Path(”/{id}”)
public Response getState(@PathParam(”id”) UUID orderId) {
OrderState state = handler.loadCurrentState(orderId);
if (state == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(state).build();
}
@GET
@Path(”/{id}/events”)
public List<OrderEvent> getEvents(@PathParam(”id”) UUID orderId) {
return eventStore.loadEvents(orderId);
}
// DTOs for the REST layer
public record PlaceOrderRequest(
String customerEmail) {
}
public record AddItemRequest(
String productName,
int quantity,
BigDecimal price) {
}
public record ShipOrderRequest(
String trackingNumber) {
}
public record CancelOrderRequest(
String reason) {
}
}This is enough to drive the event sourced aggregate. Now we add a small read model to show how CQRS and projections plug in.
Read Model and Projector
The read model is a simple materialized view:
One row per order
Stores customer email, status, total, lastUpdated
OrderReadModel Entity
Create src/main/java/com/example/events/OrderReadModel.java:
package com.example.events;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = “order_read_model”)
public class OrderReadModel extends PanacheEntity {
@Column(nullable = false, columnDefinition = “uuid”, unique = true)
public UUID orderId;
@Column(nullable = false)
public String customerEmail;
@Column(nullable = false)
public String status;
@Column(nullable = false)
public BigDecimal total;
@Column(nullable = false)
public Instant lastUpdated;
public static OrderReadModel findByOrderId(UUID orderId) {
return find(”orderId”, orderId).firstResult();
}
}OrderProjector
The projector listens for OrderEvent CDI events, rebuilds the current OrderState from the event store, and updates the read model.
Create src/main/java/com/example/events/OrderProjector.java:
package com.example.events;
import java.util.List;
import java.util.UUID;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class OrderProjector {
@Inject
EventStore eventStore;
@Transactional
void on(@Observes OrderEvent event) {
UUID orderId = event.orderId();
// Recompute current state from all events for this order
List<OrderEvent> events = eventStore.loadEvents(orderId);
OrderState state = EventProjection.replayEvents(events);
OrderReadModel readModel = OrderReadModel.findByOrderId(orderId);
if (readModel == null) {
readModel = new OrderReadModel();
readModel.orderId = orderId;
}
readModel.customerEmail = state.customerEmail();
readModel.status = state.status().name();
readModel.total = state.total();
readModel.lastUpdated = event.timestamp();
readModel.persist();
}
}To expose the read model via REST, you can add a small endpoint on OrderResource or create a new resource. For simplicity we keep everything together.
Add this method to OrderResource:
@GET
@Path(”/{id}/read-model”)
public Response getReadModel(@PathParam(”id”) UUID orderId) {
OrderReadModel readModel = OrderReadModel.findByOrderId(orderId);
if (readModel == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(readModel).build();
}Now we have:
Event store with immutable events
Derived state via pure fold
Read model projection
REST API for commands and queries
Before we run the application, we add a simple unit test to show the advantage of pure data oriented logic.
Testing the Event Projection
We can test EventProjection without Quarkus. This is one of the big wins of this style.
Create src/test/java/com/example/events/EventProjectionTest.java:
package com.example.events;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
public class EventProjectionTest {
@Test
void testOrderLifecycle() {
UUID orderId = UUID.randomUUID();
Instant now = Instant.now();
BigDecimal price = new BigDecimal(”999.99”);
List<OrderEvent> events = List.of(
new OrderEvent.OrderPlaced(orderId, “test@example.com”, now),
new OrderEvent.ItemAdded(orderId, “Laptop”, 1, price, now),
new OrderEvent.OrderShipped(orderId, “TRACK-123”, now));
OrderState finalState = EventProjection.replayEvents(events);
assertEquals(orderId, finalState.orderId());
assertEquals(”test@example.com”, finalState.customerEmail());
assertEquals(OrderStatus.SHIPPED, finalState.status());
assertEquals(price, finalState.total());
}
}This test does not start Quarkus, does not touch the database, and does not know anything about JPA. It tests only the pure core.
But wait! I have heard about this! This is CQRS, right?
The architecture we have assembled is not only event sourced. It is also a clear example of CQRS: Command Query Responsibility Segregation.
Many teams use CQRS as a slogan and stop there. In practice, it becomes much easier to understand once you work through an event sourced system, because the separation emerges naturally.
The Write Side
Commands mutate state by producing events. That is the only thing they do. They never update a row, never manipulate a read model, never return the new state.
// Commands create events that represent intent
handler.placeOrder(new PlaceOrderCommand(email));
handler.addItem(new AddItemCommand(orderId, product, qty, price));
handler.shipOrder(new ShipOrderCommand(orderId, tracking));
// The write side always persists new events
eventStore.append(orderId, “Order”, event);On the write path you only see:
A command
Business validation
Event creation
Append to the event store
There is no read model involved and no concept of “current state” stored anywhere.
The Read Side
Reads live on their own. They never reconstruct state from scratch. They do not evaluate business rules. They simply return the latest projection.
// Optimized for queries
OrderReadModel.findByOrderId(id)
OrderReadModel.find(”status”, “SHIPPED”).list()
OrderReadModel.find(”customerEmail”, email).list()This side uses:
Pre-materialized views
Denormalized tables
Direct database queries
The read model exists only to serve queries efficiently.
Why This Is “True” CQRS
CQRS is not about frameworks or messaging. It is about separating intent (commands) from information retrieval (queries). Event sourcing tends to push you in this direction because each side has a different shape.
The separation shows up in four places.
Separate Models
The write model is defined by pure domain state reconstructed from events:
OrderState state = EventProjection.replayEvents(events);The read model is a fully different model optimized for lookups:
@Entity
class OrderReadModel { ... }Neither model is a mirror of the other.
Separate Persistence
The event store is an append-only log:
@Table(name = “event_store”)
class StoredEvent { ... }The read model is a mutable table:
@Table(name = “order_read_model”)
class OrderReadModel { ... }They do not share schemas or constraints and can live in separate databases.
Eventual Consistency
Commands complete before projections update:
@ApplicationScoped
class OrderProjector {
@Transactional
void on(@Observes OrderEvent event) {
updateReadModel(event);
}
}A client might read slightly stale data. This is a feature, not a bug, and it enables scale.
Independent Scaling
You can scale the write side for durability and ordering. You can scale the read side for query volume. Nothing forces them to run at the same pace.
The Important Nuance
Event sourcing and CQRS are related, but not the same. You can have one without the other.
You can have CQRS without event sourcing by separating write entities from read entities. You can also technically have event sourcing without CQRS by replaying events directly on every query, but this collapses quickly as load increases.
In practice, event sourcing leads naturally to CQRS because:
Replaying events for every read is inefficient
Multiple read models often emerge from real requirements
Commands and queries want different shapes and performance characteristics
In this tutorial, the CQRS split is visible but not forced. The system remains small and clear, but the architectural boundaries are already in place if you want to grow it later.
Running and Trying It Out
With everything in place, start the application in dev mode:
quarkus devQuarkus will:
Start a PostgreSQL container via Dev Services
Create the
event_storeandorder_read_modeltablesStart the HTTP server on http://localhost:8080
You can now drive the system with curl or HTTPie.
Place an Order
curl -i -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"customerEmail": "alice@example.com"}'You should see a 201 Created response with the new order id.
Add an Item
Replace {id} with your order id:
curl -i -X POST http://localhost:8080/orders/{id}/items \
-H "Content-Type: application/json" \
-d '{"productName": "Laptop","quantity": 1,"price": 1499.00}'Ship the Order
curl -i -X POST http://localhost:8080/orders/{id}/ship \
-H "Content-Type: application/json" \
-d '{"trackingNumber": "TRACK-123"}'Inspect State and Events
Current state derived from events:
curl -s http://localhost:8080/orders/{id} | jqEvent stream:
curl -s http://localhost:8080/orders/{id}/events | jqRead model:
curl -s http://localhost:8080/orders/{id}/read-model | jqYou could also stop and restart the app. The current state should always be rebuilt from the events in PostgreSQL. BUT we have the drop-and-create strategy set in the application.properties. So, you’d need to change that first.
Commands Are Not Events: Why Some Systems Store Both
But Markus, looking at the event stream, I am wondering if it would be better to also store the commands. I do not really see what happened, right?
In event-sourced systems, it is easy to focus exclusively on events, because events are the canonical source of truth. But many production-grade systems also persist commands alongside events. Understanding why helps clarify the mental model behind event sourcing.
At a high level, commands and events represent two different categories of information.
A command describes intent: what a client wanted to do. It reflects the user’s request, including who issued it, when, and from where. A event describes a fact: what actually happened to the system after validation and business logic.
Distinguishing them leads to cleaner reasoning and better auditability.
Commands vs Events
Commands can fail. Events cannot. This is the core difference.
A command such as AddItemCommand might be rejected because the quantity is invalid or because the order is already shipped. A system that only stores events will never record these failed attempts. For many applications that is acceptable. For others, especially in regulated industries, this information matters a great deal.
A command also carries valuable context. It may include the account ID of the requester, the IP address, the correlation ID, the user agent, or the exact timestamp before any processing occurred. Events often contain only the domain-relevant pieces of data. The rest of the context is lost unless it is captured explicitly.
Why Some Systems Store Commands
The main motivations fall into a few categories.
Storing commands gives you an audit trail of intent. You can see not only what happened, but also what someone tried to do. This becomes important when debugging validation issues or reconstructing user behavior.
It also provides visibility into rejected actions. With event-only storage, a failed command leaves no trace. With command storage, you can analyze patterns of failure, detect malicious activity, or generate user feedback reports.
Another benefit is better tooling for debugging and replay. When you store both the command and the resulting event, you can understand the causality chain: how long the command took to process, what arguments were passed, and what validation logic ran.
Finally, storing commands opens the door to additional operational features such as idempotency. A commandId stored alongside the command can prevent duplicate execution in distributed systems.
Extending the Event Store Schema
A common production pattern is to enrich the event store with command metadata. This keeps the write-path simple while providing an audit trail.
A richer event table might look like this:
@Entity
@Table(name = “event_store”)
class StoredEvent extends PanacheEntity {
public UUID aggregateId;
public long version;
// The event that happened
public String eventType;
public String eventData; // JSON: {”productName”: “Laptop”, ...}
public Instant eventTimestamp;
// The command that caused it (optional but valuable)
public String commandType;
public String commandData; // Original command payload
public String commandId; // For idempotency
public String userId; // Who issued the command
public String correlationId; // Track across services
// Metadata
public String causationId; // Which event triggered this command?
public Map<String, String> metadata; // IP, user agent, etc.
}Not all fields are required. Many teams store only a subset, depending on audit requirements.
What This Looks Like in an API
Once you record both sides of the lifecycle, the history of an aggregate becomes more complete. Instead of a list of raw events, you can provide a timeline that includes commands, their results, and the events they produced.
A /history endpoint might return:
[
{
“sequence”: 1,
“command”: {
“type”: “PlaceOrderCommand”,
“customerEmail”: “alice@example.com”
},
“event”: {
“type”: “OrderPlaced”,
“orderId”: “793e...”
},
“result”: “SUCCESS”
},
{
“sequence”: 2,
“command”: {
“type”: “AddItemCommand”,
“productName”: “OutOfStockItem”,
“quantity”: 1
},
“event”: null,
“result”: “REJECTED”,
“reason”: “Product not found”
}
]
This timeline is useful for monitoring, debugging, compliance, and operational analysis.
How This Fits Into the Tutorial
For this hands-on article, we focus on events only, because it keeps the core ideas clear:
Immutable events
Pure state reconstruction
CQRS read models
Simple projections
Storing commands would add a layer of infrastructure that is valuable in real-world systems but would distract from the main principles we are demonstrating.
What matters is understanding the difference:
Commands capture intent
Events capture fact
Systems decide how much of each they want to persist
Where To Go From Here
You now have a minimal but fully working event sourced service in Quarkus that uses:
Java 21 records and sealed types for immutable events and state
A pure fold function for state reconstruction
A simple Panache based event store with Jackson JSON
CDI events to update a read model projection
A REST API for commands and queries
From here, the natural next steps are:
Snapshots to avoid replaying long histories
Event versioning and upcasting for schema evolution
Separate read models for different query patterns
Outbox or messaging integration for cross service communication
The important part is that the core stays simple: events as data, state as a pure function of those events, and Quarkus as a lean runtime around it.
Event sourcing stops being magic when you treat it as data and a fold.



