CQRS & Event Sourcing in Spring Boot: Read/Write Separation, Snapshots & Compensation

This guide details implementing CQRS and Event Sourcing in Spring Boot, covering command/query separation, event bus choices (Outbox pattern, Kafka), projection idempotency, state snapshots for performance, Saga-based compensation for distributed transactions, and practical tuning tips for production workloads.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
CQRS & Event Sourcing in Spring Boot: Read/Write Separation, Snapshots & Compensation

Why CRUD Fails at Scale

Traditional CRUD architectures tightly couple business logic to database tables. Write operations must maintain normalization while read operations require wide-table joins, causing conflicting index strategies, deadlocks from row and gap locks, and redundant columns that violate single responsibility. Worse, state changes via UPDATE erase history, leaving audit trails dependent on fragile triggers or manual logging.

CQRS and Event Sourcing Core Idea

CQRS separates "change state" (commands) from "query state" (queries). The command side validates invariants and emits events; the query side builds denormalized read models optimized for retrieval. Event Sourcing goes further: instead of storing current state, it persists the event stream. Current state is derived by replaying events in order, making audit logs a first-class architectural artifact and enabling multiple read models from the same event stream.

Read/Write Separation and Event Bus Selection

Command and query models must be physically separated. The write side follows:

receive command → validate invariants → change state → emit events → persist events

. The read side uses denormalized stores (MongoDB, Elasticsearch, MySQL wide tables) without business logic.

The event bus bridges them. Choose based on scenario:

Single app / ultra-low latency: Spring's ApplicationEventPublisher with an Outbox table (Outbox Pattern). Spring events are synchronous in-JVM; for cross-service or reliable delivery, add a background task or Debezium to push Outbox events to a message queue.

Distributed systems: Kafka is preferred. Event Sourcing demands strict ordering; partition by AggregateId hash to guarantee per-aggregate sequence. RabbitMQ works but requires careful exchange/routing-key design to avoid reordering and duplicates.

@Entity
@Table(name = "event_outbox")
public class OutboxEvent {
    @Id @GeneratedValue
    private Long id;
    private String aggregateId;
    private String eventType;
    private String payload; // event JSON, reserve schema version field
    private LocalDateTime createdAt;
    private Boolean published = false; // marks delivery to MQ
}

Core Pipeline: Command Publishing, Projection Updates, and Production Pitfalls

Spring Boot can implement CQRS without heavy frameworks like Axon. ApplicationEventPublisher suffices for internal flows. The critical rule: events must be published after the database transaction commits, otherwise a rollback leaves the read side with dirty data.

@Service
@RequiredArgsConstructor
public class OrderCommandService {
    private final OrderAggregateRepository repo;
    private final ApplicationEventPublisher publisher;

    @Transactional
    public void createOrder(CreateOrderCommand cmd) {
        Order order = new Order(cmd.getOrderId(), cmd.getUserId(), cmd.getAmount());
        List<OrderEvent> events = order.process(cmd); // aggregate emits events
        repo.save(events); // persist to event store
        // publish Spring application event; @TransactionalEventListener ensures post-commit trigger
        publisher.publishEvent(new OrderEventsPublishedEvent(order.getAggregateId(), events));
    }
}

Projections are asynchronous consumers. The hardest part is not data sync but idempotency and out-of-order delivery. Many tutorials check existsById before insert, which fails under concurrency. The correct approach: create a unique index on event_id in the read store and use INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) or INSERT IGNORE (MySQL).

@Component
@RequiredArgsConstructor
public class OrderProjection {
    private final OrderReadRepository readRepo;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        // rely on DB unique constraint for idempotency, not existsById
        OrderReadDTO dto = OrderReadDTO.builder()
            .orderId(event.getOrderId())
            .userId(event.getUserId())
            .status("CREATED")
            .totalAmount(event.getAmount())
            .eventId(event.getEventId()) // must be persisted
            .build();
        readRepo.save(dto); // internally executes ON CONFLICT logic
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPaid(OrderPaidEvent event) {
        // updates also carry eventId for optimistic version control
        readRepo.updateStatus(event.getOrderId(), "PAID", event.getEventId());
    }
}

Projections must be stateless. Read-store latency is normal; do not expect strong consistency with the write side. For complex domains, split projections (e.g., one for order lookup, another for dashboards) to avoid interference.

State Reconstruction and Snapshots: Taming Event Growth

Event streams grow indefinitely, slowing aggregate reconstruction. Snapshots cut off infinite replay. When an aggregate's event count exceeds a threshold (e.g., 500), serialize its current state into a snapshot table. On load, fetch the latest snapshot for a baseline version, then replay only events after that version.

public Order loadAggregate(String aggregateId) {
    SnapshotEntity snap = snapshotRepo.findLatest(aggregateId);
    int baseVersion = (snap != null) ? snap.getVersion() : 0;
    // fetch only events after baseline
    List<Event> events = eventStore.findEventsAfter(aggregateId, baseVersion);

    Order order = (snap != null) ? deserialize(snap.getState()) : new Order();
    events.forEach(order::applyEvent);
    return order;
}

Snapshot creation must not block the primary write path. Typically, a background job or the projection side generates snapshots asynchronously, then updates a snapshot marker in the event stream. Concurrency conflicts are handled via version numbers: the aggregate records expectedVersion on load; on persist, a mismatch throws ConcurrencyException. The caller decides retry, reject, or compensate — never silently overwrite, or consistency collapses.

Storage Selection, Saga Compensation, and Performance Tuning

Event Store: Start with PostgreSQL or MySQL using JSONB for event payloads, partitioned by time or aggregate. This handles millions of daily active users. Introduce EventStoreDB or Kafka as the distribution backbone only when infrastructure matures; keep the database for Outbox and snapshots.

Distributed Transactions: Two-phase commit is abandoned in favor of Saga. Choreography-style event-driven Sagas are clean but require rigorous compensation loops. On forward-action failure, immediately emit a compensation event. Compensation commands must carry the original event ID for idempotency. Each step's state machine must define both forward and reverse paths (e.g., RESERVECANCEL_RESERVE). Failed compensations go to a dead-letter queue (DLQ) for manual intervention — safer than infinite automated retries.

@EventListener
public void onStockReservedFailed(StockReserveFailedEvent e) {
    // propagate original order creation event ID for idempotent compensation
    publisher.publishEvent(new CompensateOrderCommand(e.getOrderId(), "STOCK_RESERVE_FAILED", e.getCorrelationId()));
}

Tuning: Command-side bottlenecks are usually database connection pools and batch commits; enabling rewriteBatchedStatements reduces latency. Projection latency (P99) is the key metric. If high, check Kafka consumer parallelism and whether read-store batch upserts can be merged. Annotate projection handlers with @Async bound to a dedicated thread pool — never share Tomcat's pool. Monitor event backlog via Prometheus + Kafka Exporter; scaling consumers is often more effective than code changes. Remember read/write ratios are typically 8:2 or 9:1; load tests must mimic real peak/valley traffic, not uniform distribution.

Final Thoughts

CQRS plus Event Sourcing trades architectural complexity for extensibility and traceability. Before adopting, the team must agree on: how to explain read-side latency to business, how to version event schemas, and whether to replay logs or patch the database on incidents. For simple CRUD, this model adds ops burden and cognitive friction. But when you need multi-client sync, strong audit, high-frequency state changes, or vastly different read models, this paradigm pulls the system out of the "change one thing, break everything" cycle. Once events are persisted, they become digital assets; backward compatibility is non-negotiable. Spring Boot's ecosystem is sufficient: Outbox guarantees delivery, Kafka handles throughput, projections isolate views. Evolve incrementally — get it running, then refine; production feedback dictates the next step.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

KafkaSpring BootIdempotencyCQRSSnapshotsEvent SourcingSagaProjectionEvent BusOutbox Pattern
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.