CQRS with Event Sourcing in Spring Boot
Fast recall
Quick revision
Core answer
@TransactionalEventListener (or Axon) to publish events; the read path uses async projectors that update denormalized tables consumed by query services. The trade-off is eventual consistency between write and read sides, which the UI must handle via optimistic updates or read-your-writes routing.Whether you can wire CQRS and event sourcing together in a real Spring Boot application with command handlers, an event store, and projections that build read models
Using JPA entity state as the source of truth and emitting events as side-effects — the events must be the source of truth, with the aggregate state derived from replaying them
Discuss snapshot frequency, idempotent projectors, the read-your-writes problem, and when to use Spring Application Events vs Axon Framework vs raw Kafka
Interview-ready guide
Interview answer
In CQRS with event sourcing the events become the source of truth and the aggregate's current state is whatever you get by replaying them. That's the inversion from a normal Spring Boot service.
Write side in Spring Boot:
- The aggregate is a plain Java class with no JPA annotations — every state change goes through
apply(event)so the event is what mutates state - The command handler loads the aggregate by replaying its events from the event store, invokes a domain method that produces new events, and appends them with optimistic concurrency on
(aggregate_id, version) - The event store is a simple append-only table —
aggregate_id,version,event_type,payload,created_at
Publishing events to projectors:
- For local in-process projectors, use Spring's
@TransactionalEventListener(AFTER_COMMIT)— the read model updates as soon as the write commits, giving fast read-your-writes for the same service's UI - For cross-service projectors, use the outbox pattern: write the event-store row and an outbox row in the same transaction, and a separate poller or Debezium publishes them to Kafka
- Cross-service projectors consume from Kafka and update their own read stores — Postgres for transactional reads, Elasticsearch for search, ClickHouse for analytics
Operational concerns:
- Snapshots every 50-200 events keep load time acceptable for hot aggregates
- Projectors must be idempotent because events get redelivered during restarts and rebuilds — check applied event IDs or do upserts keyed by primary key
- Eventual consistency is real — the UI either uses optimistic updates, returns projection-shaped data from the command response, or shows pending status
Framework choice:
- For a single bounded context, Spring Application Events + your own event store table is simplest
- Axon Framework removes the boilerplate but locks you in
- Raw Kafka as the event store scales the highest but makes optimistic concurrency awkward
My default for a new system is Spring Application Events plus a Postgres event store table plus an outbox to Kafka — it gives you the model without the framework lock-in.
CQRS with Event Sourcing in Spring Boot
Direct Answer
In a Spring Boot CQRS+ES system, commands mutate an aggregate that emits events, those events are appended to an event store and become the source of truth, and projections subscribe to the event stream to build read models. The write path uses a command handler + aggregate + Spring @TransactionalEventListener (or Axon) to publish events; the read path uses async projectors that update denormalized tables consumed by query services. The trade-off is eventual consistency between write and read sides, which the UI must handle via optimistic updates or read-your-writes routing.
Code Example
Projectors run on the read side. To survive consumer restarts and to fan out to other services, the events should be relayed to a message broker — the outbox pattern keeps the relay atomic with the event-store append because both are in the same DB transaction.
@Entity @Table(name = "order_events_outbox")
public class OutboxRecord {
@Id @GeneratedValue Long id;
String aggregateId;
String eventType;
@Column(columnDefinition = "jsonb") String payload;
Instant createdAt = Instant.now();
Instant publishedAt;
}
// Same DB transaction as event-store append — see PlaceOrderHandler.
@Component
public class OutboxAppender {
private final OutboxRepository repo;
private final ObjectMapper mapper;
@EventListener
@Transactional(propagation = Propagation.MANDATORY)
public void on(DomainEvent e) throws IOException {
OutboxRecord r = new OutboxRecord();
r.aggregateId = e.aggregateId();
r.eventType = e.getClass().getSimpleName();
r.payload = mapper.writeValueAsString(e);
repo.save(r);
}
}
// Local in-process projector (read-your-writes side, transactional with the write).
@Component
public class OrderSummaryProjector {
private final OrderSummaryRepository summaries;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCreated e) {
summaries.save(new OrderSummary(e.orderId(), e.customerId(), "DRAFT"));
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderPlaced e) {
summaries.findById(e.orderId()).ifPresent(s -> {
s.markPlaced(e.placedAt());
summaries.save(s);
});
}
}
// Cross-service projectors run as Kafka consumers reading the relayed events.
@Component
public class SearchIndexProjector {
private final SearchClient search;
@KafkaListener(topics = "order-events", groupId = "search-projector")
public void on(DomainEvent e) {
// Idempotent upsert keyed by event id so replays are safe.
search.upsert(e);
}
}
The outbox row and the event-store append commit together; a separate poller (or Debezium reading the WAL) publishes the outbox to Kafka. Cross-service projectors consume from Kafka and update their own read stores. Local in-process projectors using @TransactionalEventListener(AFTER_COMMIT) give the write side near-instant read-your-writes consistency for the same service's UI.
Common Mistakes & Pitfalls
Using JPA entity state as the source of truth and emitting events as side-effects — the events must be the source of truth, with the aggregate state derived from replaying them
Check your understanding
Answer these without looking back at the article.
- How do you handle read-your-writes consistency with eventual projection lag?
- When should you take aggregate snapshots and how do you store them?
- Why must projectors be idempotent, and how do you enforce that?
- Spring Application Events vs Axon Framework vs raw Kafka — which would you pick and why?
Related interview prep categories
2Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.