In CQRS, data lives in two places: the write-store (aggregates) and the read-store (projections for queries). As soon as something changes on the write side, the read side has to find out about it. This is exactly where most problems show up — if you get the synchronization wrong, the data drifts apart and the application starts lying to users.
This article covers a reliable way to synchronize the read-model through events: the outbox pattern, Kafka, and an idempotent consumer.
Why you can't just update the read-model in the same transaction
The first instinct is to add a line to the command-handler that updates the read-model right after saving the aggregate:
@Transactional
public OrderId handle(ConfirmOrderCommand cmd) {
Order order = orderRepository.findById(cmd.orderId());
order.confirm();
orderRepository.save(order);
orderSummaryRepository.updateStatus(order.id(), "CONFIRMED"); // don't do this
return order.id();
}
At first glance it looks convenient. In practice it has several serious consequences:
- If the read-store lives in a different database, there is no atomicity. One operation goes through, the other doesn't. The data drifts apart.
- The read-store and the write-store become tightly coupled. Changing the structure of a read-table affects write-transactions. A failure in the read-store takes down the entire write request.
- Synchronization between services through a method call doesn't work at all.
The solution is to break the coupling with an event.
The outbox pattern: the event and the aggregate in one transaction
The core idea of the outbox: instead of sending the event to Kafka immediately, we write it into an outbox table — in the same database and the same transaction as the change to the aggregate.
One transaction:
1. UPDATE order SET status = 'CONFIRMED' WHERE id = 42
2. INSERT INTO outbox (event_type, payload, aggregate_id)
VALUES ('OrderConfirmed', '{...}', 42)
→ either both rows, or neither
Once the transaction has committed, the outbox-relay takes over — a separate scheduled bean that periodically reads unpublished rows from outbox and publishes them to Kafka:
@Scheduled(fixedDelay = 200)
@Transactional
public void relay() {
List<OutboxRecord> pending = outboxRepository.findPending(100);
for (OutboxRecord record : pending) {
kafkaTemplate.send(record.topic(), record.key(), record.payload());
outboxRepository.markPublished(record.id());
}
}
Why do it this way? Without the outbox there are two unpleasant scenarios:
- The transaction commits, Kafka is unavailable — the event is lost, the read-model is out of sync.
- Kafka received the event, the transaction rolled back — a change appears in the read-model that doesn't exist in the write-store.
The outbox solves both: the event lives in the database until the relay confirms it has reached Kafka.
Idempotent consumer: what to do about duplicates
Kafka guarantees at-least-once delivery. That means the consumer can receive the same event twice. Without protection, the read-model will be corrupted.
There are two ways to make a consumer idempotent.
A table of processed events
A separate table stores the IDs of already-processed events. Before updating the read-model, we check whether we've seen this event before:
CREATE TABLE processed_event (
event_id UUID PRIMARY KEY,
consumer TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@KafkaListener(topics = "order.events", groupId = "order-summary-projector")
@Transactional
public void onOrderConfirmed(@Payload OrderConfirmed event) {
if (processedEventRepository.exists(event.eventId(), "order-summary-projector")) {
return; // already processed
}
processedEventRepository.markProcessed(event.eventId(), "order-summary-projector");
orderSummaryRepository.updateStatus(event.orderId(), "CONFIRMED", event.confirmedAt());
}
Reliable, but it requires an extra table with write load.
Idempotent UPDATE by aggregate version
If every event carries an aggregateVersion, the UPDATE is applied only when the event is newer than the current state of the read-model:
@KafkaListener(topics = "order.events", groupId = "order-summary-projector")
@Transactional
public void onOrderConfirmed(@Payload OrderConfirmed event) {
int updated = orderSummaryRepository.updateStatusIfNewer(
event.orderId(),
"CONFIRMED",
event.confirmedAt(),
event.aggregateVersion()
);
if (updated == 0) {
log.debug("skipping stale or duplicate event: {}", event.eventId());
}
}
UPDATE order_summary
SET status = 'CONFIRMED', confirmed_at = ?, version = ?, updated_at = NOW()
WHERE order_id = ? AND version < ?
No separate table needed, but it requires events for a single aggregate to arrive in order (one Kafka partition per aggregate_id).
Bootstrap: what to do when the read-model is empty
Imagine you've added a new projection — for example, you've started storing an order summary in Elasticsearch. Or the read-store was lost and needs to be restored. Waiting for the last 30 days of events to arrive through Kafka is the wrong approach.
The solution is a batch rebuild: at application startup, check whether the read-model is empty, and if so — populate it directly from the write-store:
@Component
@RequiredArgsConstructor
public class OrderSummaryBootstrap implements ApplicationRunner {
private final OrderSummaryRepository orderSummaryRepository;
private final OrderRepository orderRepository;
@Override
public void run(ApplicationArguments args) {
if (!orderSummaryRepository.isEmpty()) {
return; // already populated, skip
}
log.info("read-model order_summary is empty — starting rebuild");
rebuildAll();
}
void rebuildAll() {
long lastId = 0;
while (true) {
List<Order> batch = orderRepository.findAllAfter(lastId, 1000);
if (batch.isEmpty()) break;
orderSummaryRepository.upsertBatch(batch.stream().map(this::toSummary).toList());
lastId = batch.getLast().id().value();
}
}
}
A rebuild is needed in three cases:
- A new read-model (just created, no data yet).
- Disaster recovery (the read-store is lost or corrupted).
- A structural migration (a column was added and existing records need to be backfilled).
Eventual consistency: say it out loud
When the read-model is updated asynchronously, time passes between the write and the change appearing in the projection — usually less than a second, but that's not a guarantee. The client needs to know this up front — otherwise you get bugs that are impossible to reproduce.
The rule is simple: if an endpoint serves the read-model, state it in the API documentation:
paths:
/orders/{id}/summary:
get:
summary: Order summary (read-projection)
description: |
Returns the read-projection of the order.
A delay of up to 1 second is possible between a change to the order and
the update of this projection.
For immediate consistency (for example, right after POST /orders),
use GET /orders/{id}.
The client understands: right after creating an order, a request to /summary may return stale data or a 404. That's not a bug — it's an architectural property.
Read-your-writes: when you need to guarantee freshness
Sometimes a user must immediately see the result of their action. For example: create an order → open the order list → see it there. Three ways to handle this, from simple to complex.
Two endpoints with different guarantees
The most straightforward approach: one endpoint reads from the write-store (immediately consistent), the other from the read-model (eventual consistency):
/orders/{id}:
get:
summary: Order (from the write-store, immediate consistency)
/orders/{id}/summary:
get:
summary: Order summary (from the read-projection, eventual consistency)
The client chooses what it needs. No magic, everything explicit.
Sticky session through the gateway
Requests from a single client are routed to the same pod. If the read-model is updated locally in memory — the client sees fresh data immediately. This works only within a single pod and is fragile across restarts.
Polling after the write
After the commit, the command-handler waits until the change appears in the read-model:
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
Order order = orderFactory.createFor(cmd.customerId(), cmd.items());
orderRepository.save(order);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() {
pollUntilVisible(order.id(), Duration.ofSeconds(2));
}
}
);
return order.id();
}
The downside: the POST request becomes slow (up to 2 seconds in the worst case). Suitable only for rare, critical operations.
Common mistakes
Updating the read-model in the command-handler. When the write side and read side are coupled directly through a method call, fault tolerance is lost, and a distributed scenario (different services) won't work. The correct way is through the outbox and a consumer.
Postgres triggers for synchronization. Tempting: put a trigger on the write-table, and it updates the read-table on its own. But a trigger is invisible magic: a developer reads the Java code and has no idea that an UPDATE on one table writes to another. On bulk operations the trigger fires per row — slow. It doesn't work across different databases and services.
An event as a snapshot of the write-table. If the event is just a POJO generated from the write-table's structure, any schema change will break the consumers. An event should be an independent record:
// Schema-dependent — dangerous
public record OrderConfirmed(OrderRecord row) {}
// Independent — correct
public record OrderConfirmed(
UUID eventId,
Long orderId,
OrderStatus status,
Instant confirmedAt,
long aggregateVersion
) {}
An independent record can be versioned (OrderConfirmedV2), and the write-schema can be changed without breaking consumers.
A consumer with no protection against duplicates. Kafka delivers at-least-once. Without idempotency, the read-model will eventually be corrupted.
In short
- Write → read synchronization goes through the outbox + Kafka, not directly. The outbox is written in the same transaction as the aggregate — a guarantee of atomicity.
- The outbox-relay reads unpublished records from the outbox and publishes them to Kafka. Until they're published, it keeps retrying.
- An idempotent consumer is mandatory: Kafka may deliver an event twice. The protection is a
processed_eventtable or an UPDATE by aggregate version. - A bootstrap-rebuild is needed when creating a new read-model or after a failure: a batched pass over the write-store, not waiting for old events from Kafka.
- Eventual consistency is declared in the API: the client must know that after a write, the projection updates with a delay.
- Read-your-writes: two endpoints with different guarantees is the simplest solution. Polling and sticky sessions are for special cases.
- An event is an independent record, not a snapshot of the write-table. Otherwise any schema migration breaks the consumers.
Further reading
- CQRS: separating writes and reads — the basics of the pattern.
- The read-model in CQRS: where and how to store the projection — storage options and projection structure.
- The command side in CQRS — how an outbox event is registered in the write-handler.