CQRS Pattern in Microservices
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
- 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. - 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. - A projector (also called a projection handler) consumes the events and updates the read database.
- The client sends queries, for example
GET /my-orders, which read only from the read database.
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
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);
}
}
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:
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);
}
}
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:
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));
}
}
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
| Aspect | CRUD | API composition | CQRS read model |
|---|---|---|---|
| Models | One for reads and writes | Each service's own API | Separate write and read models |
| Freshness | Always current | Always current | Slightly behind (eventual) |
| Query speed | Good for simple screens | As slow as the slowest service | Fast, one prepared lookup |
| Cross-service search and sorting | Not possible | Poor, filtered in memory | Good, indexed in one store |
| Complexity | Low | Medium | High: events, projector, sync issues |
| Best for | Simple services | Detail pages from a few services | Heavy 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.
CQRS Pattern: Interview Questions
Common interview questions on the CQRS pattern, from basic to scenario-based, with short answers.
Basic questions
Q. What is the difference between a command and an event?
Answer: A command is a request to do something, named in the imperative, such as PlaceOrder. It is handled by one owner and can be rejected. An event is a fact that already happened, named in the past tense, such as OrderPlaced. It cannot be rejected, and any number of services can react to it.
Q. Is a database read replica the same as CQRS?
Answer: No. A read replica is a copy of the same schema, used to spread read load. A CQRS read model has a different shape, designed for specific queries, and can live in a different kind of database. A replica helps with volume; CQRS helps with both volume and shape.
Q. Should a command return data?
Answer: Strictly, a command returns nothing, but in practice it is fine to return a small result such as the new order's ID or version number. That lets the client find the new record or wait for the read model to catch up. Avoid returning full query results from commands, or the two sides start to merge again.
Q. Which databases are commonly used for the read side?
Answer: It depends on the queries. Elasticsearch or OpenSearch suit full-text search and filtering, Redis suits very fast key lookups, MongoDB suits document-shaped views, and a denormalised table in PostgreSQL is often enough. One system can have several read models in different stores, built from the same events.
Intermediate questions
Q. How are CQRS and event sourcing related?
Answer: With event sourcing, the write side stores the list of events as the source of truth instead of current state, and the current state is rebuilt by replaying them. Those events are an ideal feed for CQRS projections, which is why the two often appear together. But CQRS works with a normal database on the write side, and event sourcing can be used without separate read models.
Q. How do you add a new read model to a running system?
Answer: Build it next to the existing ones by replaying events from the beginning, from an event store or a Kafka topic with long enough retention. If old events are gone, first load a snapshot from the write side, then apply new events from that point. Switch queries to the new model once it has caught up.
Q. How do you check a rule such as "email must be unique" in CQRS?
Answer: On the write side, not against the read model. The read model may be seconds old, so two sign-ups with the same email could both pass a check against it. Use a unique constraint in the write database, which is checked inside the same transaction as the insert.
Q. What does Axon Framework provide for CQRS in Java?
Answer: Axon is a Java framework for CQRS and event sourcing that integrates with Spring Boot. It provides a command bus, an event bus and store (for example Axon Server), and a query bus, so you write handlers instead of plumbing:
@QueryHandler List<OrderSummary> handle(FindMyOrders query) { ... }
Q. What should a projector do when one event keeps failing?
Answer: Retry it a few times, and if it still fails, move it to a dead-letter topic and raise an alert, so the rest of the stream is not blocked forever. In Spring for Apache Kafka, a DefaultErrorHandler with a DeadLetterPublishingRecoverer does this. Never skip it silently, because the read model would then be wrong without anyone knowing.
Q. How do you measure how far behind the read model is?
Answer: Watch the consumer lag, which is how many events are waiting to be processed, for example Kafka consumer group lag. Also record the time of the last applied event and compare it with the current time. Alert when either keeps growing.
Tricky and scenario questions
Q. Two requests try to change the same order at the same moment. How does the write side stay correct?
Answer: Use optimistic locking: each order row has a version number, and an update succeeds only if the version has not changed since it was read. In JPA, a field annotated with @Version does this. The second request fails with an optimistic locking exception, and it can be retried against the latest state.
Q. An OrderPlaced event arrives before the CustomerRegistered event for the same customer. What should the projector do?
Answer: Events from different services are not ordered relative to each other, so this can happen. The projector can save the row with the customer ID and fill in the name when the customer event arrives. Alternatively, it can briefly retry the event later, or fetch the name from the Customer Service. What it must not do is fail or drop the order row.
Q. A bank wants to show the account balance with CQRS. Any concerns?
Answer: Yes. A balance read from a lagging read model can be out of date, and a decision such as "is there enough money for this transfer?" must never be based on it. The check belongs on the write side, inside the transaction. The read model is fine for statements and history, ideally with a note like "pending transactions may not be shown".
Q. When should you not use CQRS?
Answer: When a service is simple CRUD with similar read and write shapes, when users must always see their changes immediately and the UI cannot hide the lag, or when the team is not ready to run events, projectors and monitoring. The extra complexity should solve a real read problem, not be added because it is popular.