1. Introduction: The Limits of Traditional CRUD Architectures
Modern enterprise applications demand high availability, low latency, and absolute audit compliance. For decades, software teams built applications around Create, Read, Update, and Delete (CRUD) architectures. In a CRUD model, a single relational schema handles both write operations and read queries. When a customer places an order, modifies their address, or cancels an item, the database updates the existing row in place.
This traditional approach introduces severe architectural limitations when business traffic grows:
- Destructive State Updates: Overwriting database rows discards historical context. Once an update occurs, the previous state vanishes forever. Auditing requires separate audit logging tables that frequently drift out of sync with business data.
- Relational Lock Contention: High-volume write transactions lock database rows and index trees. Concurrent read queries experience latency spikes or database deadlocks.
- Impedance Mismatch: Read queries often require denormalized, flattened representations across multiple tables. Conversely, write operations require strict normalization to enforce database constraints. Optimizing a single schema for both patterns produces suboptimal compromises.
- Inflexible Scalability: Write workloads and read workloads scale at different rates. Most enterprise systems experience ten to a hundred read operations for every write operation. CRUD architectures force you to scale the entire database infrastructure together.
To overcome these bottlenecks, modern systems apply Command Query Responsibility Segregation (CQRS) and Event Sourcing. By combining these architectural patterns with the modern runtime of Spring Boot 4 and Java 25, developers can build scalable, auditable, and resilient distributed applications.
2. Core Principles of CQRS and Event Sourcing
While developers often pair Command Query Responsibility Segregation (CQRS) and Event Sourcing together, they are distinct architectural patterns. Understanding their individual mechanics is essential before combining them.
Understanding CQRS
Command Query Responsibility Segregation (CQRS) separates data modification from data retrieval. Greg Young and Martin Fowler popularized this pattern based on Bertrand Meyer’s Command-Query Separation principle.
Under CQRS, an application divides its domain operations into two distinct pipelines:
- Command Side (Write Pipeline): Commands represent user intents to change application state. Examples include
CreateOrderorCancelPayment. Commands return either an aggregate identifier or void. Commands never return business query data. The write pipeline validates business invariants and enforces consistency rules. - Query Side (Read Pipeline): Queries request information without altering application state. Examples include
FindOrderByIdorListActiveCustomers. Queries execute against specialized read views optimized for UI rendering and search performance.
Understanding Event Sourcing
Event Sourcing is an architectural pattern where application state is stored as an immutable sequence of domain events. Instead of saving only the current snapshot of an entity, the application records every business event in an append-only event store.
A domain event is a statement of fact about something that happened in the past. Events are immutable. Events use past-tense naming conventions, such as OrderCreated, PaymentCaptured, or ShipmentDispatched. To determine the current state of an entity, the application loads the event stream and replays the events in chronological order.
When you combine CQRS with Event Sourcing, the Event Store serves as the authoritative source of truth for the command model. Asynchronous projectors process new events from the stream and populate specialized read models. This architecture eliminates write-side database locks while delivering sub-millisecond query responses.
3. Architectural Blueprint: Separating Commands from Queries
The synergy between CQRS and Event Sourcing establishes a clear unidirectional data flow through your application services. Commands arrive through Representational State Transfer (REST) endpoints, mutate state by generating events, and trigger projections.

Let us trace the complete lifecycle of a write command and a read query through this architecture:
- Command Dispatch: A client issues an Hypertext Transfer Protocol (HTTP) POST request to initiate a business process. The web layer maps the request payload into a strongly typed command Data Transfer Object (DTO).
- Command Handler Validation: The command service validates business invariants against aggregate state. If business validation fails, the service throws a domain exception and halts processing.
- Event Generation and Append: Upon successful validation, the service translates the intent into one or more immutable domain events. The event store saves the events to an append-only database table within an atomic database transaction.
- Event Publication: The system dispatches the saved domain events to internal Spring Boot application listeners or external message brokers such as Apache Kafka. If you publish to Kafka, use the Transactional Outbox pattern to prevent dual-write inconsistencies.
- Projection Updates: Dedicated query projectors consume the domain events. Projectors transform the event data into flat, denormalized read records and update the read database.
- Query Execution: A web client issues an HTTP GET request. The query controller reads directly from the denormalized read table without joining multiple tables.
4. Designing the Append-Only Event Store in PostgreSQL
The Event Store is the backbone of your write model. Because relational database management systems (RDBMS) provide robust Atomicity, Consistency, Isolation, and Durability (ACID) guarantees, PostgreSQL serves as an exceptional event store. Modern PostgreSQL offers high-speed sequential writes and native JavaScript Object Notation (JSON) support.

The event store table requires minimal metadata columns. It stores the primary key, the aggregate identifier, the event type name, the event creation timestamp, and the serialized JSON payload.
Below is the Jakarta Persistence API (JPA) entity for our PostgreSQL event store:
package com.webcodein.cqrs.core;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "event_store")
public class EventStoreEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private UUID aggregateId;
@Column(nullable = false)
private String eventType;
@Column(nullable = false)
private Instant timestamp;
@Column(columnDefinition = "TEXT", nullable = false)
private String payload;
public EventStoreEntity() {}
public EventStoreEntity(UUID aggregateId, String eventType, String payload, Instant timestamp) {
this.aggregateId = aggregateId;
this.eventType = eventType;
this.payload = payload;
this.timestamp = timestamp;
}
public Long getId() { return id; }
public UUID getAggregateId() { return aggregateId; }
public String getEventType() { return eventType; }
public String getPayload() { return payload; }
public Instant getTimestamp() { return timestamp; }
}
We create a standard Spring Data JPA repository to query and append events by aggregate Universally Unique Identifier (UUID):
package com.webcodein.cqrs.core;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface EventStoreRepository extends JpaRepository<EventStoreEntity, Long> {
List<EventStoreEntity> findByAggregateIdOrderByIdAsc(UUID aggregateId);
}
Using sequential BIGSERIAL identifiers guarantees strict chronological ordering. The findByAggregateIdOrderByIdAsc method retrieves all historical events for an entity in the exact sequence they occurred. This ordering guarantees deterministic state replay.
5. Modeling Domain Events with Java 25 Sealed Interfaces and Records
Java 25 brings modern type safety and immutability to domain-driven design. By combining sealed interfaces with records, you define exhaustive, immutable domain event hierarchies that the Java compiler verifies at compile time.
A sealed interface restricts which classes or records may implement it. This prevents arbitrary classes from masquerading as valid domain events. Records provide built-in immutability, canonical constructors, and clean accessor methods without Lombok or boilerplate code.
The Sealed Event Hierarchy
We define our core contract with the DomainEvent sealed interface. Every event must supply an aggregate identifier and a timestamp:
package com.webcodein.cqrs.events;
import java.time.Instant;
import java.util.UUID;
public sealed interface DomainEvent permits OrderCreatedEvent, OrderConfirmedEvent {
UUID aggregateId();
Instant timestamp();
}
Concrete Event Records
Next, we create the concrete event records representing business state transitions. Notice how records cleanly implement our sealed contract:
package com.webcodein.cqrs.events;
import java.time.Instant;
import java.util.UUID;
public record OrderCreatedEvent(
UUID aggregateId,
String customerId,
double amount,
Instant timestamp
) implements DomainEvent {}
When the order manager confirms an order, the system captures an OrderConfirmedEvent:
package com.webcodein.cqrs.events;
import java.time.Instant;
import java.util.UUID;
public record OrderConfirmedEvent(
UUID aggregateId,
Instant timestamp
) implements DomainEvent {}
Because these records are completely immutable, they eliminate thread-safety issues during asynchronous processing. You can safely pass them across thread boundaries and message queues.
6. Command Processing and Event Store Publishing
The command processing layer bridges incoming client intents and persistent domain events. This layer consists of two components: the EventStorePublisher and the OrderCommandService.
The Event Store Publisher
The EventStorePublisher serializes domain events into JSON format, persists them to the PostgreSQL event store, and publishes them to the Spring application event bus:
package com.webcodein.cqrs.core;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.webcodein.cqrs.events.DomainEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
public class EventStorePublisher {
private final EventStoreRepository repository;
private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper;
public EventStorePublisher(EventStoreRepository repository,
ApplicationEventPublisher eventPublisher,
ObjectMapper objectMapper) {
this.repository = repository;
this.eventPublisher = eventPublisher;
this.objectMapper = objectMapper;
}
public void saveAndPublish(DomainEvent event) {
try {
String payload = objectMapper.writeValueAsString(event);
EventStoreEntity entity = new EventStoreEntity(
event.aggregateId(),
event.getClass().getSimpleName(),
payload,
event.timestamp()
);
repository.save(entity);
eventPublisher.publishEvent(event);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize event", e);
}
}
}
The Transactional Command Service
The OrderCommandService receives commands, executes domain logic, and delegates persistence to the publisher. Notice that every command method runs within an active Spring database transaction:
package com.webcodein.cqrs.command;
import com.webcodein.cqrs.core.EventStorePublisher;
import com.webcodein.cqrs.events.OrderConfirmedEvent;
import com.webcodein.cqrs.events.OrderCreatedEvent;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
@Service
public class OrderCommandService {
private final EventStorePublisher eventPublisher;
public OrderCommandService(EventStorePublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Transactional
public UUID createOrder(String customerId, double amount) {
if (amount <= 0.0) {
throw new IllegalArgumentException("Order amount must be greater than zero");
}
UUID orderId = UUID.randomUUID();
OrderCreatedEvent event = new OrderCreatedEvent(orderId, customerId, amount, Instant.now());
eventPublisher.saveAndPublish(event);
return orderId;
}
@Transactional
public void confirmOrder(UUID orderId) {
OrderConfirmedEvent event = new OrderConfirmedEvent(orderId, Instant.now());
eventPublisher.saveAndPublish(event);
}
}
By saving the event inside the database transaction, we guarantee that invalid operations rollback before committing any state to disk.
7. Building Asynchronous Read Projections
In traditional applications, querying an order with customer details and line items requires multiple database joins. In a CQRS application, the read side maintains denormalized views that match user interface requirements exactly.

A projector listens for domain events and transforms them into read model entities. In Java 25, we use pattern matching in switch statements to handle sealed event hierarchies cleanly.
The Read Model Entity
The read model represents a flattened database record optimized for simple SELECT queries:
package com.webcodein.cqrs.query;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
@Entity
@Table(name = "order_read_model")
public class OrderReadModel {
@Id
private UUID id;
private String customerId;
private double amount;
private String status;
public OrderReadModel() {}
public OrderReadModel(UUID id, String customerId, double amount, String status) {
this.id = id;
this.customerId = customerId;
this.amount = amount;
this.status = status;
}
public UUID getId() { return id; }
public String getCustomerId() { return customerId; }
public double getAmount() { return amount; }
public String getStatus() { return status; }
public void setStatus(String status) {
this.status = status;
}
}
The Order Projector Service
The projector component subscribes to DomainEvent instances using Spring’s @EventListener. Notice how Java 25 pattern matching eliminates manual type casting and enhances readability:
package com.webcodein.cqrs.query;
import com.webcodein.cqrs.events.DomainEvent;
import com.webcodein.cqrs.events.OrderConfirmedEvent;
import com.webcodein.cqrs.events.OrderCreatedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderProjector {
private final OrderReadRepository repository;
public OrderProjector(OrderReadRepository repository) {
this.repository = repository;
}
@EventListener
@Transactional
public void on(DomainEvent event) {
switch (event) {
case OrderCreatedEvent created -> {
OrderReadModel readModel = new OrderReadModel(
created.aggregateId(),
created.customerId(),
created.amount(),
"PENDING"
);
repository.save(readModel);
}
case OrderConfirmedEvent confirmed -> {
repository.findById(confirmed.aggregateId()).ifPresent(readModel -> {
readModel.setStatus("CONFIRMED");
repository.save(readModel);
});
}
}
}
}
Because DomainEvent is sealed, the Java compiler verifies that all permitted event types are handled. If a developer adds a new event type without updating the switch statement, the compiler flags the omission immediately.
8. State Hydration, Snapshots, and Historical Event Replay
One of the most powerful capabilities of Event Sourcing is state hydration. Because all business facts are stored permanently in the event store, an entity’s current state can be reconstructed by replaying its historical event stream.

Mechanics of State Hydration
When the command service needs to validate an operation against an existing aggregate, it follows a simple hydration process:
- Retrieve all events matching the aggregate identifier ordered by sequence number ascending.
- Instantiate a blank domain aggregate instance.
- Iterate through each historical event, applying state mutations sequentially.
- Return the fully hydrated aggregate ready to process new commands.
Performance Optimization with Snapshots
Replaying an aggregate with a dozen events takes less than two milliseconds. However, long-lived aggregates such as financial ledgers or smart contracts may accumulate thousands of events. Replaying ten thousand events on every command introduces unacceptable latency.
To optimize hydration, production systems implement aggregate snapshotting:
- Every N events (for example, every 100 events), the system serializes the current aggregate state into a separate
snapshotstable. - When hydrating the aggregate, the system loads the latest snapshot first.
- The system then queries only the events that occurred after the snapshot timestamp.
- This reduces hydration time to a constant O(1) operation regardless of aggregate age.
9. Architectural Comparison: CRUD vs CQRS vs Event Sourcing
Selecting the appropriate architecture requires balancing complexity against business value. The table below compares Traditional CRUD, CQRS without Event Sourcing, Event Sourcing without CQRS, and Full CQRS with Event Sourcing.
| Architectural Dimension | Traditional CRUD | CQRS Only | Event Sourcing Only | Full CQRS + Event Sourcing |
|---|---|---|---|---|
| Authoritative Source | Normalized RDBMS Tables | Separate Read/Write Tables | Append-Only Event Store | Append-Only Event Store |
| Historical Auditability | None (destructive updates) | None (destructive updates) | Full (native immutable stream) | Full (native immutable stream) |
| Temporal Queries | Not supported | Not supported | Supported via event replay | Supported via event replay |
| Query Performance | Medium (complex SQL joins) | High (denormalized views) | Low (replay required) | Ultra-High (denormalized views) |
| Write Throughput | Medium (table locks) | High (isolated write model) | Ultra-High (sequential appends) | Ultra-High (sequential appends) |
| Consistency Model | Immediate (ACID) | Immediate or Eventual | Immediate for stream | Eventual for Read Models |
| Cognitive Complexity | Low | Medium | High | High |
10. Enterprise Real-World Use Cases
CQRS and Event Sourcing are not general-purpose hammers for simple CRUD applications. They excel in mission-critical domains characterized by high audit requirements and complex business rules.
1. Core Banking and Financial Ledgers
Banks and payment processors cannot tolerate destructive updates. In financial systems, a balance is not a mutable number in a table. A balance is the mathematical sum of every historical credit and debit transaction.
By applying Event Sourcing, every deposit, withdrawal, and transfer exists as an immutable event. If an account experiences fraudulent activity or a calculation error, auditors can replay the ledger to any point in time. This provides verifiable mathematical proof of account balances.
2. High-Volume E-Commerce Order Fulfillment
Global e-commerce platforms experience extreme spikes during flash sales. Write models must handle thousands of checkouts per second without locking the product inventory catalog. Read models must serve millions of customer browsing queries.
Separating the checkout command pipeline from the product catalog read views prevents checkout locks from impacting shoppers. If shipping logistics fail, compensating events such as ShipmentRerouted or RefundIssued record the corrective actions seamlessly.
3. Healthcare Patient Audit Trails
Healthcare regulations such as HIPAA mandate strict audit logging of electronic health records. Modifying a patient’s prescription or allergy status must preserve who changed the record, when the change occurred, and the medical rationale behind it.
Event Sourcing guarantees regulatory compliance out of the box. Medical practitioners can reconstruct a patient’s historical treatment timeline exactly as it existed on any given date.
11. Production Hardening: Schema Evolution, Idempotency, and Consistency
Deploying CQRS and Event Sourcing into enterprise production environments introduces operational challenges that standard CRUD tutorials overlook. Robust systems require event versioning, idempotent projectors, and read-after-write consistency strategies.
Event Schema Evolution and Upcasting
Because historical events are permanent and immutable, you cannot alter existing event records in the event store. As your business evolves, event structures change. New fields are added, fields are renamed, or event semantics change.
Production systems solve this using event upcasting. An upcaster sits between the event store repository and the deserializer. When reading a version 1 event (for example, missing a currency field), the upcaster dynamically enriches the JSON payload with default values before passing it to the Java application. The database record remains untouched, while the application receives modern domain objects.
Idempotent Event Projectors
In distributed architectures, message brokers deliver events with at-least-once delivery guarantees. Network retries and service restarts can deliver duplicate events to projectors. If a projector processes an event twice, read models become corrupted.
To ensure idempotency:
- Store the processed event sequence number or event UUID directly within the read model entity.
- Use database upserts (for example, PostgreSQL
ON CONFLICT DO UPDATE) rather than blind inserts. - Verify that the event sequence number is strictly greater than the last processed sequence number before updating state.
Handling Read-After-Write Consistency
Because projections update asynchronously, a user might submit an order and immediately navigate to their dashboard before the projection updates. This lag is called eventual consistency.
Production systems address read-after-write consistency using several patterns:
- Synchronous In-Memory Projections: For single-instance applications, Spring’s
@EventListenerexecutes within the same thread, updating read views before the HTTP response returns. - Optimistic Client Updates: The frontend user interface immediately updates its local state using the command payload while waiting for background synchronization.
- Version Token Polling: The command endpoint returns the newly generated event version number. The client includes this version header when querying read models. If the read model has not reached that version, the query service polls briefly or returns a pending status.
12. Common Pitfalls and How to Avoid Them
Adopting CQRS and Event Sourcing without understanding their trade-offs can lead to unnecessary architectural complexity. Watch out for these three common pitfalls:
Pitfall 1: Designing Oversized Aggregates
The Mistake: Modeling an entire department or catalog as a single aggregate. This forces the event store to replay hundreds of unrelated events for every command and causes severe optimistic locking contention.
The Solution: Keep aggregates small and focused. An aggregate boundary should only encompass data required to enforce immediate business invariants. Use eventual consistency and projections to link related aggregates together.
Pitfall 2: Modifying Query Models Directly
The Mistake: Allowing web controllers or batch jobs to issue SQL UPDATE statements directly against read model tables. This causes read models to diverge permanently from the authoritative event store.
The Solution: Treat read model tables as disposable caches. All state modifications must originate from the command side as validated domain events. Ensure you can completely wipe and rebuild your read database from the event store at any time.
Pitfall 3: Applying CQRS to Simple CRUD Domains
The Mistake: Implementing CQRS and Event Sourcing for administrative screens, lookups, or simple profile management modules with no complex invariants.
The Solution: Use hybrid architectures. Apply traditional Spring Data JPA CRUD to basic lookups. Reserve CQRS and Event Sourcing for critical business domains with complex workflows and strict audit requirements.
13. Frequently Asked Questions
When should I choose CQRS and Event Sourcing over traditional CRUD?
Choose CQRS and Event Sourcing when your domain has high audit requirements, complex business rules, asynchronous workflows, or asymmetric read/write traffic. Financial systems, logistics tracking, healthcare auditing, and order fulfillment systems benefit significantly. Avoid this architecture for simple data entry or administrative applications.
Can I use PostgreSQL as an Event Store in high-volume production?
Yes. PostgreSQL easily handles thousands of append operations per second with ACID guarantees. While dedicated event stores like EventStoreDB offer specialized indexing, PostgreSQL avoids introducing new operational infrastructure. For distributed microservice topologies, pair PostgreSQL with Apache Kafka using the Transactional Outbox pattern.
How do you handle data deletions and GDPR compliance in Event Sourcing?
General Data Protection Regulation (GDPR) mandates the “Right to be Forgotten”. Because event logs are immutable, you do not delete historical event rows. Instead, implement cryptographic erasure. Encrypt personally identifiable information (PII) using a per-user encryption key stored in a secure key vault. When a user requests data deletion, destroy their encryption key. The event remains in the log, but its payload becomes permanently unreadable.
What happens if a bug corrupts our read model database?
One of the greatest benefits of Event Sourcing is effortless disaster recovery. If a bug corrupts your read database, truncate the read tables, deploy the bug fix, and replay the event stream from the beginning of time. Your read models will re-hydrate into a perfect, consistent state.
How does Java 25 improve CQRS and Event Sourcing implementations?
Java 25 provides sealed interfaces and pattern matching for switch statements. Sealed interfaces restrict domain event types to a known hierarchy. Pattern matching switch expressions allow projectors to process domain events without type casts or reflection. If you add a new event to a sealed hierarchy, the compiler immediately identifies any projectors missing a handler case.
14. Summary and Next Steps
Command Query Responsibility Segregation (CQRS) and Event Sourcing transform how enterprise systems process and store business data. By decoupling the command write model from the query read views, you eliminate database lock contention and optimize query performance. Java 25 sealed interfaces and records bring type safety and immutability to domain event handling.
To dive deeper into distributed architectures, explore these related guides:
- What is Spring Boot? A Complete Guide to Spring Boot 4 in 2026
- Kafka Transactional Outbox Pattern with Spring Boot 4
- Event-Driven Architecture with Kafka and Spring Boot 4
- Orchestrating Distributed Sagas with Spring Boot 4 and Temporal
You can inspect, build, and run the complete companion workshop code on GitHub:
👉 View the Complete CQRS & Event Sourcing Workshop Code on GitHub
