Skip to content
Kafka Is Fun

Enterprise Architecture · Enterprise · Topic 3 of 5

CQRS with Kafka and Read Models

Separate the write (command) path from the read (query) path. Commands produce Kafka events; consumers build read models in stores shaped for queries (a search index, a cache, a relational table). This is an architecture pattern, not a Kafka feature: Kafka carries the events.

OrderReadModelUpdater.javajava
@Service
@Slf4j
public class OrderReadModelUpdater {

    private final OrderSearchRepository elasticsearchRepo;
    private final OrderCacheRepository redisRepo;

    // Update the search read model
    @KafkaListener(topics = {
        "orders.order.created",
        "orders.order.updated",
        "orders.order.cancelled"
    }, groupId = "order-search-updater")
    public void updateSearchIndex(ConsumerRecord<String, Object> record) {
        String eventType = getEventType(record);
        String orderId = record.key();

        switch (eventType) {
            case "OrderCreated", "OrderUpdated" -> {
                OrderSearchDoc doc = buildSearchDoc(record.value());
                elasticsearchRepo.save(doc);  // upsert by orderId
            }
            case "OrderCancelled" -> {
                OrderSearchDoc doc = elasticsearchRepo.findById(orderId).orElseThrow();
                doc.setStatus("CANCELLED");
                elasticsearchRepo.save(doc);
            }
        }
    }

    // Update the cache (hot data)
    @KafkaListener(topics = "orders.order.created", groupId = "order-cache-updater")
    public void updateCache(OrderCreatedEvent event) {
        OrderSummary summary = OrderSummary.from(event);
        redisRepo.save(event.getOrderId(), summary, Duration.ofHours(24));
    }
}

// Query side: reads from the read models, not from Kafka
@RestController
@RequestMapping("/api/orders")
public class OrderQueryController {

    @GetMapping("/search")
    public List<OrderSearchResult> search(@RequestParam String query) {
        return elasticsearchRepo.search(query);
    }

    @GetMapping("/{id}")
    public OrderSummary getOrder(@PathVariable String id) {
        return redisRepo.get(id) // cache first
            .orElseGet(() -> orderRepo.findById(id).map(OrderSummary::from).orElseThrow());
    }
}

Sources

Checked against Spring for Apache Kafka 4.1.1, Spring Boot 4.1.1 and Kafka 4.2 documentation on 11 October 2026. Draft: not yet reviewed by a Kafka specialist, and releases move on, so confirm against the current documentation before you act on it.