Skip to content
Apache Kafka kf patterns 5 min read

CQRS with Kafka

Command Query Responsibility Segregation (CQRS) splits a system into two independently modelled halves: a write side that validates commands and emits events, and a read side that consumes those events to build query-optimized views. Kafka is the natural seam between them — the durable, ordered event log carries every state change from the command model to as many read models as you need. The payoff is that writes and reads stop fighting over a single schema, and each side can scale and evolve on its own. The cost you pay for that freedom is eventual consistency, which you must design for explicitly.

Why separate writes from reads

In a classic CRUD system one normalized schema serves both update and query traffic. That works until your read patterns diverge from your write patterns: dashboards need denormalized aggregates, search needs an inverted index, mobile needs a flattened document, and your transactional tables groan under conflicting indexes and lock contention.

CQRS removes the conflict. Commands mutate a small, strongly-validated write model. Every accepted command produces an event on Kafka. Consumers then project those events into whatever shape each query needs — a Postgres materialized table, an Elasticsearch index, a Redis cache, a wide column store — without the write side knowing or caring.

ConcernWrite side (command model)Read side (query model)
GoalEnforce invariants, accept/reject commandsServe fast, denormalized queries
ShapeNormalized, aggregate-orientedDenormalized, view-specific
Source of truthYesNo — rebuildable from events
ScalingScales with write throughputScales per read pattern, independently
ConsistencyStrong within an aggregateEventually consistent

The write side: commands to events

A command expresses intent (PlaceOrder). The command handler loads the aggregate, validates business rules, and on success publishes a past-tense event. Keying by the aggregate ID guarantees per-aggregate ordering on the partition.

public record OrderPlaced(
        String orderId,
        String customerId,
        List<Line> lines,
        BigDecimal total,
        Instant occurredAt) {

    public record Line(String sku, int quantity, BigDecimal unitPrice) {}
}
@Service
public class OrderCommandHandler {

    private final KafkaTemplate<String, OrderPlaced> kafkaTemplate;

    public OrderCommandHandler(KafkaTemplate<String, OrderPlaced> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void handle(PlaceOrder cmd) {
        if (cmd.lines().isEmpty()) {
            throw new IllegalArgumentException("Order must contain at least one line");
        }
        var total = cmd.lines().stream()
                .map(l -> l.unitPrice().multiply(BigDecimal.valueOf(l.quantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        var event = new OrderPlaced(cmd.orderId(), cmd.customerId(),
                cmd.lines(), total, Instant.now());
        kafkaTemplate.send("orders.events", event.orderId(), event);
    }
}

Tip: To avoid a “dual write” where the database commit and the Kafka publish can diverge, persist the event in the same transaction as your write model and ship it with the Outbox Pattern. Never assume a bare kafkaTemplate.send is atomic with a JPA save.

The read side: projecting into a read model

A projection is just an idempotent consumer that translates events into rows of a query store. Each read model is its own consumer group, so they progress independently and a slow projection never stalls the others.

@Component
public class OrderSummaryProjection {

    private final OrderSummaryRepository repository;

    public OrderSummaryProjection(OrderSummaryRepository repository) {
        this.repository = repository;
    }

    @KafkaListener(topics = "orders.events", groupId = "order-summary-view")
    public void on(OrderPlaced event) {
        var summary = new OrderSummary(
                event.orderId(),
                event.customerId(),
                event.lines().size(),
                event.total(),
                "PLACED");
        repository.save(summary); // upsert — safe to replay
    }
}

The query API now reads exclusively from the projected table — no joins across normalized tables, no load on the command model.

@GetMapping("/orders/{id}/summary")
public OrderSummary summary(@PathVariable String id) {
    return repository.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

Write side vs read side

        COMMANDS                                         QUERIES
           │                                                ▲
           ▼                                                │
  ┌──────────────────┐     events     ┌──────────────────────────────────┐
  │  Command model   │  ┌──────────┐   │  Read model A (SQL summary view) │◄─┐
  │  (validate +     ├─►│  Kafka   ├──►│  Read model B (Elasticsearch)    │◄─┤ each is
  │   emit events)   │  │  topic   │   │  Read model C (Redis cache)      │◄─┘ its own
  └──────────────────┘  └──────────┘   └──────────────────────────────────┘  consumer group
       strong, normalized                  denormalized, eventually consistent

The eventual-consistency gap

There is a real window — usually milliseconds, occasionally longer under lag — between a command being accepted and the read model reflecting it. A client that places an order and immediately re-queries the summary may get a 404 or stale data. Design for it rather than pretend it away:

  • Return the new state (or a generated ID) directly in the command response so the UI can render optimistically.
  • Expose a per-aggregate version/offset so clients can poll “read-your-writes” or display a “processing” state.
  • Monitor projection consumer lag and alert when it grows.

Pairing CQRS with event sourcing

CQRS and event sourcing are distinct but complementary. CQRS only mandates separate models; the write side could still be a normal mutable table. With event sourcing, the event log itself becomes the source of truth — the write side stores no current-state rows at all, only the sequence of events. CQRS is then the obvious read strategy: project the event stream into query models.

Because Kafka retains the log, a read model is fully rebuildable. To add a new view or fix a projection bug, create a new consumer group and reset its offsets to the beginning:

kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group order-summary-view --topic orders.events \
  --reset-offsets --to-earliest --execute

Output:

GROUP               TOPIC          PARTITION  NEW-OFFSET
order-summary-view  orders.events  0          0
order-summary-view  orders.events  1          0
order-summary-view  orders.events  2          0

Restart the projection and it rebuilds the entire read model from history — no data migration script required. See Event Sourcing for the write-side details.

Best Practices

  • Treat the write model as the source of truth and read models as disposable, rebuildable derivatives.
  • Make every projection idempotent (upsert by aggregate ID) so replays and at-least-once delivery are safe.
  • Give each read model its own consumer group so views advance and fail independently.
  • Publish events atomically with the write using the outbox pattern; never rely on a naked dual write.
  • Design clients and APIs to embrace the consistency gap with optimistic responses and version/offset signals.
  • Key events by aggregate ID to preserve ordering, and version your event schemas with a registry for safe evolution.
  • Keep enough data in the read model to answer queries without calling back to the command side.
Last updated June 1, 2026
Was this helpful?