CQRS

Separate Writes from Reads

CQRS (Command Query Responsibility Segregation) uses one model for commands (place order, cancel order: validation, rules, transactions) and a different, denormalized read model for queries (my orders page, admin dashboard, search). Events keep the read model up to date.

Commands                                         Queries
 POST /orders, POST /orders/1/cancel              GET /my-orders, GET /admin/sales
        |                                                 ^
        v                                                 |
 order-service (write model)                      order-query-service (read model)
 MySQL: orders, order_items (normalized)          Elasticsearch / MongoDB / Redis:
        |                                         one document per customer, ready to show
        +-- OrderPlaced / OrderCancelled --> Kafka --> projection updates the read model
Write sideRead side
ModelRich domain objects, rulesFlat documents shaped for each screen
StoreRelational DB (ACID)Whatever is fastest for the query: Elasticsearch, Mongo, Redis
ScalingFew instancesMany replicas: reads are often 100x more than writes
ConsistencyImmediateEventual (milliseconds to seconds behind)

Example: Commands, Events and Two Read Models

Example

The command side validates and emits events. Two projections build different read models from the same events: "orders per customer" and "units sold per product". Cancelling twice is rejected by the write model; the read models never contain business rules:

import java.util.*;
import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        List<Consumer<OrderEvent>> subscribers = new ArrayList<>();
        OrderCommands commands = new OrderCommands(e -> subscribers.forEach(s -> s.accept(e)));
        CustomerOrdersView customerView = new CustomerOrdersView();
        ProductSalesView salesView = new ProductSalesView();
        subscribers.add(customerView::on);
        subscribers.add(salesView::on);

        commands.place(1001, "asha", "boAt Rockerz 450", 2);
        commands.place(1002, "ravi", "Noise ColorFit Pro", 1);
        commands.place(1003, "asha", "boAt Rockerz 450", 1);
        commands.cancel(1002);
        try { commands.cancel(1002); } catch (IllegalStateException e) { System.out.println("command rejected: " + e.getMessage()); }

        System.out.println("GET /my-orders (asha) -> " + customerView.byCustomer.get("asha"));
        System.out.println("GET /my-orders (ravi) -> " + customerView.byCustomer.get("ravi"));
        System.out.println("GET /admin/units-sold -> " + salesView.unitsSold);
    }
}

sealed interface OrderEvent permits OrderPlaced, OrderCancelled {}
record OrderPlaced(long orderId, String customer, String product, int qty) implements OrderEvent {}
record OrderCancelled(long orderId, String customer, String product, int qty) implements OrderEvent {}

class OrderCommands {                                   // write model: rules live here
    private final Map<Long, OrderPlaced> orders = new HashMap<>();
    private final Set<Long> cancelled = new HashSet<>();
    private final Consumer<OrderEvent> publish;
    OrderCommands(Consumer<OrderEvent> publish) { this.publish = publish; }

    void place(long id, String customer, String product, int qty) {
        OrderPlaced e = new OrderPlaced(id, customer, product, qty);
        orders.put(id, e);
        publish.accept(e);
    }
    void cancel(long id) {
        if (!cancelled.add(id)) throw new IllegalStateException("order " + id + " is already cancelled");
        OrderPlaced o = orders.get(id);
        publish.accept(new OrderCancelled(id, o.customer(), o.product(), o.qty()));
    }
}

class CustomerOrdersView {                              // read model 1
    final Map<String, List<String>> byCustomer = new TreeMap<>();
    void on(OrderEvent event) {
        switch (event) {
            case OrderPlaced e -> byCustomer.computeIfAbsent(e.customer(), c -> new ArrayList<>())
                    .add(e.orderId() + " " + e.product() + " x" + e.qty() + " PLACED");
            case OrderCancelled e -> byCustomer.get(e.customer())
                    .replaceAll(s -> s.startsWith(e.orderId() + " ") ? s.replace("PLACED", "CANCELLED") : s);
        }
    }
}

class ProductSalesView {                                // read model 2
    final Map<String, Integer> unitsSold = new TreeMap<>();
    void on(OrderEvent event) {
        switch (event) {
            case OrderPlaced e -> unitsSold.merge(e.product(), e.qty(), Integer::sum);
            case OrderCancelled e -> unitsSold.merge(e.product(), -e.qty(), Integer::sum);
        }
    }
}

Output

command rejected: order 1002 is already cancelled
GET /my-orders (asha) -> [1001 boAt Rockerz 450 x2 PLACED, 1003 boAt Rockerz 450 x1 PLACED]
GET /my-orders (ravi) -> [1002 Noise ColorFit Pro x1 CANCELLED]
GET /admin/units-sold -> {Noise ColorFit Pro=0, boAt Rockerz 450=3}

A Projection in Spring

@Document(indexName = "customer-orders")               // Spring Data Elasticsearch
public record CustomerOrderView(@Id String id, Long userId, Long orderId, String status,
                                BigDecimal total, List<String> productNames, Instant placedAt) {}

@Component
@RequiredArgsConstructor
public class CustomerOrdersProjection {
    private final CustomerOrderViewRepository views;
    private final ProductClient products;

    @KafkaListener(topics = "order-events", groupId = "order-query-service")
    public void on(OrderPlaced e) {
        List<String> names = products.namesFor(e.productIds());          // enrich once, at write time
        views.save(new CustomerOrderView(e.orderId().toString(), e.userId(), e.orderId(),
                "PLACED", e.total(), names, e.placedAt()));               // save() by id = idempotent upsert
    }

    @KafkaListener(topics = "order-events", groupId = "order-query-service-cancel")
    public void on(OrderCancelled e) {
        views.findById(e.orderId().toString())
             .ifPresent(v -> views.save(new CustomerOrderView(v.id(), v.userId(), v.orderId(), "CANCELLED",
                     v.total(), v.productNames(), v.placedAt())));
    }
}

Note: In real code, both event types usually share one listener (a class-level @KafkaListener with @KafkaHandler methods) so that events of one order are applied in order. Because the read model lags behind, the UI should show "Order placed" from the command response instead of re-reading it immediately.

Tip: Event sourcing goes one step further: the write side stores only events (no orders table) and rebuilds state by replaying them. It pairs naturally with CQRS but adds complexity. Use CQRS alone first.