00:00

CQRS Pattern in Microservices

Last updated:

The CQRS pattern (Command Query Responsibility Segregation) separates the code that changes data from the code that reads it. Commands, such as "place order", go to a write model that enforces business rules. Queries, such as "show my orders", read from a separate read model that is shaped for fast display. In microservices, the read model is often a different database, kept up to date by events.

This tutorial explains why the CQRS pattern exists, how the two sides stay in sync, and the problems that appear once they are separate. Three runnable Java 21 examples show a simple CQRS service, an event-driven projection, and how to handle the delay between writing and reading. Interview questions follow at the end.

1. What Is the CQRS Pattern?

CQRS builds on an older idea called Command Query Separation, from Bertrand Meyer: a method should either change state (a command) or return data (a query), never both. Greg Young applied the same idea to a whole system around 2010.

In a normal CRUD application (Create, Read, Update, Delete), one model and one set of tables serve both purposes. The CQRS pattern splits them:

  • The write model handles commands. It checks rules, such as "a shipped order cannot be cancelled", and stores data in a normalised form, where each fact is stored once.
  • The read model answers queries. It stores data denormalised, which means copied and pre-joined into exactly the shape a screen needs, so a query is one simple lookup.

2. Why Microservices Use CQRS

Reads and writes usually have very different needs:

  • Volume. An online shop may serve a hundred product and order views for every order placed. With CQRS, you can scale the read side on its own.
  • Shape. Writes care about rules and consistency. Reads want joined, filtered and sorted data. One model that serves both ends up being a compromise.
  • Data in many services. With the database per service pattern, a page like "orders with customer names and delivery status" needs data from three services. A SQL join across them is impossible. A CQRS read model can subscribe to all three services' events and keep one ready-made table.
  • Different storage. The write side can use PostgreSQL for transactions, while the read side uses Elasticsearch for search or Redis for fast lookups.

3. How the CQRS Pattern Works

CQRS architectureA client sends commands such as POST /orders to the command side, which validates them, applies the business rules and saves the change in the write database with normalised tables. The change is published as an event, such as OrderPlaced, through a message broker. A projector consumes the events asynchronously, usually milliseconds to seconds later, and updates denormalised views in the read database. The client sends queries such as GET /my-orders to the query side, which only reads from the read database and has no business rules. Client web or mobile Command side validates, applies rules Write database normalised tables Message broker OrderPlaced, ... Query side reads only, no rules Read database denormalised views Projector builds the views POST /orders GET /my-orders events async: ms to seconds behind read write model read model
Figure 1: commands change the write model; events keep a separate read model up to date for fast queries.
  1. The client sends a command, for example POST /orders. The command side validates it, applies the business rules and saves the change in the write database.
  2. The change is published as an event, such as OrderPlaced. A reliable way to do this is the transactional outbox, which saves the event in the same transaction as the data.
  3. A projector (also called a projection handler) consumes the events and updates the read database.
  4. The client sends queries, for example GET /my-orders, which read only from the read database.
CQRS comes in levels. Many tutorials miss that you do not have to start with two databases and Kafka. The simplest form is separate command and query classes over the same database, which is what example 5.1 does. Next comes a separate read table in the same database. Separate databases synced by events are the most powerful, and the most complex. CQRS is also not the same as event sourcing: the two are often used together, but either can be used without the other.

4. Core Concepts

4.1 Commands and Queries

A command is named after an intention, such as PlaceOrder or CancelOrder, not a generic "update". It can be rejected. A query never changes anything, so it can be cached and repeated safely.

4.2 Read Models and Projections

A projection is code that turns a stream of events into a read model. You can have several read models built from the same events: one for the customer's "My orders" page, another for the warehouse dashboard.

4.3 Eventual Consistency

When the read side is updated asynchronously, it lags behind the write side, usually by milliseconds, sometimes by seconds. This is called eventual consistency: if no new changes arrive, the read model will catch up and match. Every CQRS design must decide what users see during that gap.

5. CQRS Pattern in Java: Examples

These programs use only the JDK on Java 21, with maps standing in for databases. In a Spring Boot service, the write side would use Spring Data JPA, and the projector would be a Kafka listener.

5.1 Separate Commands and Queries in One Service

SimpleCqrs.java
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class SimpleCqrs {

    // ---------- write side: commands, business rules, normalised data ----------
    enum Status { PLACED, SHIPPED, CANCELLED }
    record OrderLine(String sku, int quantity, BigDecimal price) {}

    static final class Order {
        final String id, customerId;
        final List<OrderLine> lines;
        Status status = Status.PLACED;
        Order(String id, String customerId, List<OrderLine> lines) {
            this.id = id; this.customerId = customerId; this.lines = lines;
        }
    }
    static final Map<String, Order> orders = new HashMap<>();

    // ---------- read side: one ready-made row per order for the "My orders" screen ----------
    record OrderSummary(String orderId, int items, BigDecimal total, Status status) {}
    static final Map<String, Map<String, OrderSummary>> myOrders = new HashMap<>();

    static void placeOrder(String orderId, String customerId, List<OrderLine> lines) {
        if (lines.isEmpty()) throw new IllegalArgumentException("an order needs at least one line");
        Order order = new Order(orderId, customerId, lines);
        orders.put(orderId, order);
        updateReadModel(order);
    }

    static void cancelOrder(String orderId) {
        Order order = orders.get(orderId);
        if (order.status == Status.SHIPPED) throw new IllegalStateException(orderId + " is already shipped");
        order.status = Status.CANCELLED;
        updateReadModel(order);
    }

    static void shipOrder(String orderId) {
        Order order = orders.get(orderId);
        order.status = Status.SHIPPED;
        updateReadModel(order);
    }

    // The totals are calculated once, on write, instead of on every read
    static void updateReadModel(Order order) {
        int items = order.lines.stream().mapToInt(OrderLine::quantity).sum();
        BigDecimal total = order.lines.stream()
                .map(line -> line.price().multiply(BigDecimal.valueOf(line.quantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        myOrders.computeIfAbsent(order.customerId, c -> new HashMap<>())
                .put(order.id, new OrderSummary(order.id, items, total, order.status));
    }

    // A query only reads; it never changes anything and has no business rules
    static List<OrderSummary> getMyOrders(String customerId) {
        List<OrderSummary> rows = new ArrayList<>(myOrders.getOrDefault(customerId, Map.of()).values());
        rows.sort((a, b) -> a.orderId().compareTo(b.orderId()));
        return rows;
    }

    public static void main(String[] args) {
        placeOrder("ORD-1", "C-7", List.of(new OrderLine("KETTLE", 1, new BigDecimal("1499.00")),
                                            new OrderLine("MUG", 4, new BigDecimal("199.00"))));
        placeOrder("ORD-2", "C-7", List.of(new OrderLine("TEA-500G", 2, new BigDecimal("350.00"))));
        shipOrder("ORD-1");
        cancelOrder("ORD-2");

        try {
            cancelOrder("ORD-1");                     // the write side enforces the rule
        } catch (IllegalStateException e) {
            System.out.println("Command rejected: " + e.getMessage());
        }
        getMyOrders("C-7").forEach(System.out::println);
    }
}
Output
Command rejected: ORD-1 is already shipped
OrderSummary[orderId=ORD-1, items=5, total=2295.00, status=SHIPPED]
OrderSummary[orderId=ORD-2, items=2, total=700.00, status=CANCELLED]

The commands hold the rules; the query only reads rows that were calculated when the data was written. Here both models are updated in the same call, so there is no lag yet.

5.2 Building a Read Model From Events

Now the read model is built from events coming from two services. Two problems most tutorials skip are handled here: a redelivered event, and a change to data that was copied into many rows:

OrderSummaryProjector.java
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class OrderSummaryProjector {

    // Events published by two different services
    sealed interface Event permits CustomerRegistered, CustomerRenamed, OrderPlaced, OrderShipped {}
    record CustomerRegistered(String customerId, String name) implements Event {}
    record CustomerRenamed(String customerId, String newName) implements Event {}
    record OrderPlaced(String orderId, String customerId, BigDecimal total, long version) implements Event {}
    record OrderShipped(String orderId, long version) implements Event {}

    // Read model: one row per order, with the customer's name copied in (no join at query time)
    record OrderRow(String orderId, String customerId, String customerName, BigDecimal total, String status, long version) {}
    static final Map<String, OrderRow> view = new TreeMap<>();
    static final Map<String, String> customerNames = new HashMap<>();

    static void apply(Event event) {
        switch (event) {
            case CustomerRegistered e -> customerNames.put(e.customerId(), e.name());
            case CustomerRenamed e -> {
                customerNames.put(e.customerId(), e.newName());
                view.replaceAll((id, row) -> row.customerId().equals(e.customerId())   // the copy lives in many rows
                        ? new OrderRow(id, row.customerId(), e.newName(), row.total(), row.status(), row.version())
                        : row);
            }
            case OrderPlaced e -> upsert(e.orderId(), e.version(),
                    new OrderRow(e.orderId(), e.customerId(), customerNames.get(e.customerId()), e.total(), "PLACED", e.version()));
            case OrderShipped e -> {
                OrderRow row = view.get(e.orderId());
                upsert(e.orderId(), e.version(),
                        new OrderRow(e.orderId(), row.customerId(), row.customerName(), row.total(), "SHIPPED", e.version()));
            }
        }
    }

    // Skip events the view has already seen: brokers can deliver the same event again
    static void upsert(String orderId, long version, OrderRow newRow) {
        OrderRow current = view.get(orderId);
        if (current != null && current.version() >= version) {
            System.out.println("  skipped old event for " + orderId + " (v" + version + ", view is at v" + current.version() + ")");
            return;
        }
        view.put(orderId, newRow);
    }

    public static void main(String[] args) {
        List<Event> delivered = List.of(
                new CustomerRegistered("C-7", "Asha Rao"),
                new OrderPlaced("ORD-1", "C-7", new BigDecimal("2295.00"), 1),
                new OrderPlaced("ORD-2", "C-7", new BigDecimal("700.00"), 1),
                new OrderShipped("ORD-1", 2),
                new OrderPlaced("ORD-1", "C-7", new BigDecimal("2295.00"), 1),   // redelivered after a consumer restart
                new CustomerRenamed("C-7", "Asha Mehta"));

        delivered.forEach(OrderSummaryProjector::apply);
        view.values().forEach(System.out::println);
    }
}
Output
  skipped old event for ORD-1 (v1, view is at v2)
OrderRow[orderId=ORD-1, customerId=C-7, customerName=Asha Mehta, total=2295.00, status=SHIPPED, version=2]
OrderRow[orderId=ORD-2, customerId=C-7, customerName=Asha Mehta, total=700.00, status=PLACED, version=1]

Without the version check, the redelivered OrderPlaced would reset ORD-1 from SHIPPED back to PLACED. And because the customer's name is copied into every order row, one rename has to update all of them. That is the price of fast, join-free reads.

5.3 Reading Your Own Writes

A user places an order and is sent straight to "My orders". If the projector has not run yet, the order is missing, and the user may order again. One fix is to return a version number from the command and let the query check it:

ReadYourOwnWrites.java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;

public class ReadYourOwnWrites {

    static long writeVersion = 0;                       // increases with every accepted command
    static final Queue<String> pendingEvents = new ArrayDeque<>();
    static final List<String> readModel = new ArrayList<>();
    static long readModelVersion = 0;                   // the last version the projector has applied

    // The command returns the new version, so the client knows what it is waiting for
    static long placeOrder(String orderId) {
        writeVersion++;
        pendingEvents.add(orderId);
        return writeVersion;
    }

    // Runs in the background in a real system, usually milliseconds to seconds behind
    static void runProjector() {
        while (!pendingEvents.isEmpty()) {
            readModel.add(pendingEvents.poll());
            readModelVersion++;
        }
    }

    static String getMyOrders(long minVersion) {
        if (readModelVersion < minVersion) {
            return "view at v" + readModelVersion + ", need v" + minVersion + " -> show \"Your order is being processed\"";
        }
        return "orders " + readModel;
    }

    public static void main(String[] args) {
        long version = placeOrder("ORD-1");
        System.out.println("Command accepted, version " + version);

        System.out.println("Naive query right away:   orders " + readModel + "   <- the user thinks the order was lost");
        System.out.println("Version-aware query:      " + getMyOrders(version));

        runProjector();
        System.out.println("After the projector runs: " + getMyOrders(version));
    }
}
Output
Command accepted, version 1
Naive query right away:   orders []   <- the user thinks the order was lost
Version-aware query:      view at v0, need v1 -> show "Your order is being processed"
After the projector runs: orders [ORD-1]

Other options are to show the new order from the command's response in the UI straight away, or to read that single record from the write side for a few seconds.

6. CQRS vs CRUD vs API Composition

AspectCRUDAPI compositionCQRS read model
ModelsOne for reads and writesEach service's own APISeparate write and read models
FreshnessAlways currentAlways currentSlightly behind (eventual)
Query speedGood for simple screensAs slow as the slowest serviceFast, one prepared lookup
Cross-service search and sortingNot possiblePoor, filtered in memoryGood, indexed in one store
ComplexityLowMediumHigh: events, projector, sync issues
Best forSimple servicesDetail pages from a few servicesHeavy reads, search, dashboards

API composition calls each service at query time, like the aggregator pattern. CQRS does the joining in advance, when events arrive.

7. Common Mistakes and Pitfalls

  • Using CQRS everywhere. A simple settings service gains nothing but extra moving parts. Apply it to the parts of the system with heavy or complex reads.
  • Ignoring the lag. Users who do not see their own change assume it failed, and retry.
  • Non-idempotent projectors. Brokers deliver events at least once, so the same event can arrive twice. Track versions or processed event IDs.
  • Assuming events arrive in order. Kafka keeps order only within a partition. Use the entity ID, such as the order ID, as the message key so all events for one order go to the same partition.
  • Queries that read from the write database "just this once". The two sides then become coupled again.
  • No way to rebuild a read model. A projector bug can corrupt the view. If the events are not kept, for example because Kafka deleted them after its retention period, you cannot replay them to fix it.

8. Best Practices

  • Start simple: separate command and query code first, and add a separate store only when reads need it.
  • Publish events reliably with a transactional outbox, never with "save, then send" as two independent steps.
  • Make projectors idempotent and version-aware.
  • Design the UI for lag: show pending states, or use the command's response directly.
  • Keep events, or a way to backfill, so any read model can be rebuilt from scratch.
  • Monitor projection lag, for example Kafka consumer lag, and alert when it grows.
  • Combine with Sagas for commands that span several services, and with event-driven architecture for the sync.

9. Summary

  • CQRS separates commands, which change data under business rules, from queries, which read prepared data.
  • In microservices, a read model can combine events from many services into one fast, searchable view.
  • It comes in levels, from separate classes over one database to separate databases synced by events, and it does not require event sourcing.
  • Separate stores bring eventual consistency, duplicate events, ordering and data-copy problems that must be designed for.
  • Use the CQRS pattern where reads are heavy or complex, and keep simple CRUD everywhere else.