Once each service owns its data, some queries get awkward. “Show my orders with their payment status and delivery estimate” needs data from orders, payments and shipping. You could call all three on every page load (API composition), or you could keep a dedicated copy, shaped exactly for that page, that updates whenever any of them publishes an event. That copy is a read model, and the component that maintains it is a projector or projection.
Read models are the query half of CQRS (Command Query Responsibility Segregation): commands go to the services that own the data and enforce the rules, and queries go to views optimised for reading. The price is that the view is always slightly behind. Interviews on this topic are mostly about that price: how far behind, what breaks, and how you hide it from users.
A projection you can watch fall behind
The model below has a write side (the order service appending events to a log, as an outbox and a Kafka topic would) and a read side (a “my orders” view, built by a projector that polls the log every 120 ms).
// Write side: the order service appends events to a log (think: outbox -> Kafka topic).
function placeOrder(customerId, orderId, totalCents) {
log.push({ position: log.length + 1, type: 'OrderPlaced', customerId, orderId, totalCents });
return log.length; // position of the event just written
}
// Read side: a projection builds "my orders" for each customer, a little behind the log.
const view = { position: 0, byCustomer: new Map() };
function project(e) {
if (e.position <= view.position) return; // already applied: replays are harmless
const list = view.byCustomer.get(e.customerId) ?? [];
if (e.type === 'OrderPlaced') list.push({ orderId: e.orderId, totalCents: e.totalCents, status: 'PLACED' });
if (e.type === 'OrderCancelled') list.find((o) => o.orderId === e.orderId).status = 'CANCELLED';
view.byCustomer.set(e.customerId, list);
view.position = e.position;
}
async function myOrdersAtLeast(c, minPosition, timeoutMs = 1000) {
const start = performance.now();
while (view.position < minPosition) {
if (performance.now() - start > timeoutMs) return 'still catching up, try again';
await sleep(10);
}
return `${myOrders(c)} (waited ${Math.round(performance.now() - start)} ms)`;
}The script places an order, reads immediately, reads again after 150 ms, cancels, reads immediately, then reads with a position token. Finally it throws the view away and rebuilds it from the log, twice. Output from Node 22:
right after placing o-991: (none)
150 ms later: o-991:PLACED
right after cancelling: o-991:PLACED
read with position token 2 : o-991:CANCELLED (waited 87 ms)
after rebuilding from the log: o-991:CANCELLED(The wait time depends on where the projector’s polling cycle happens to be, so it changes between runs.)
What the lag looks like to a user
Line 1: the customer places an order, the confirmation page links to “My orders”, and the list is empty. Line 3: the customer cancels, the page refreshes, and the order still says “placed”. Both are correct behaviour for an eventually consistent view. Both generate support tickets (“I was charged but my order is missing”, “I cancelled but it didn’t work, so I cancelled again”).
In a real system the lag comes from the outbox relay’s polling interval, broker latency, consumer batching, and the projector’s own write time. Typically tens to hundreds of milliseconds. During an incident or a consumer rebalance it can be minutes. Design for the bad case, not the typical one.
Read-your-writes: four ways to hide the lag
Most users don’t care whether other people’s changes take a second to appear. They care a lot about their own. Techniques, roughly from cheapest to most involved:
- Update the UI from the command’s response. The cancel call returns the new state; the client shows “cancelled” without re-reading the view. Cheap and effective, but a page reload brings the stale view back.
- Position tokens. The write side returns the log position of the event it produced (here,
2). The client sends it with the next query, and the read side waits until it has applied at least that position, asmyOrdersAtLeastdoes. The output shows it working: the read with token 2 waited 87 ms and returnedCANCELLED. With Kafka, the token is a partition and offset; with a database-backed projection, the projection’s checkpoint. - Read from the owner for recent changes. For a few seconds after a write, the client (or a BFF) fetches that one entity from the owning service and merges it into the list.
- Make the lag part of the product. “Your cancellation is being processed” is honest and usually acceptable, especially when the operation really is asynchronous.
The wait must have a timeout. If the projector is down, an unbounded wait turns a stale view into a hung page.
A related property is monotonic reads: a user should never see newer data and then older data. That happens when consecutive requests hit different replicas of the view at different positions. Position tokens fix this too, or sticky routing to one replica.
Projectors must survive replays and rebuilds
The projector will see events more than once: after a crash before it saved its checkpoint, after a consumer rebalance, after an intentional replay. The if (e.position <= view.position) return guard makes it idempotent, which is why the second replay in the output changed nothing. Store the checkpoint in the same transaction as the view update so they can’t disagree.
Rebuilding is the superpower of read models. The last line of the output came from discarding the view entirely and replaying the log. That’s how you fix a projection bug, add a new column, or build a new view for a new screen. To do it without downtime, build the new version alongside the old one (a new table, a new consumer group starting from the beginning), wait until it catches up, then switch queries over. That’s only possible if the events are still available, which means a topic with long enough retention, log compaction, or an event store.
What not to use a read model for
The read model is for showing data, not for making decisions that must be correct right now. A classic mistake is checking stock levels in a product-listing read model before accepting an order. The view says 3 units are left, but it’s 400 ms behind, and those units sold 200 ms ago. The decision has to be made by the service that owns the stock, inside its own transaction.
The same applies to uniqueness checks (“is this username taken?”), balance checks and permission checks. Use the read model to give the user a helpful hint; let the owner enforce the rule.
Interview practice: the dashboard that shows yesterday’s numbers
“A seller dashboard is a read model built from order, payment and refund events. After a deploy, sellers report that refunds from the last two hours are missing, but new orders appear fine. Refund events are on the topic. What would you investigate, and how would you repair the data?”
Answer
Orders appear and refunds don’t, so the projector is consuming but not applying refunds. I’d check three things. First, the projector’s logs and error metrics for refund events: a deploy that changed the refund event shape (a renamed field) can make the handler throw, and if errors are caught and logged, the checkpoint keeps advancing past events that were never applied. Second, whether refund events are on a different topic or partition whose consumer stopped, which the consumer lag metric per topic would show. Third, whether the new projector version has a version or position guard that wrongly treats refunds as already applied.
To repair, fix the handler, then replay. If the projector is idempotent and the topic retains at least two hours of events, resetting the consumer group’s offset to before the deploy and replaying is enough, since orders already applied are skipped. If idempotency isn’t guaranteed, I’d rebuild the view into a new table from the start of retention and switch over once it’s caught up. Afterwards I’d make handler failures stop the projector or route the event to a dead-letter topic, rather than silently advancing the checkpoint.
Further reading
- Microservices interview questions
- Microservices MCQs
- Event sourcing
- Event ordering and partition keys
- Pattern reference: CQRS on microservices.io