Building Read Models and Projections
Derive optimized query-side projections from the event stream and keep them eventually consistent.
Why Read Models Exist
In an event-sourced system, the write side stores facts as an append-only stream of events. That stream is great for capturing history but terrible for queries like "show me all open orders sorted by total".
The read side solves this. A read model (also called a projection) is a denormalized, query-optimized view derived purely from events. This is the Q in CQRS: commands mutate the event stream, queries hit read models.
- One event stream can feed many read models, each shaped for a specific query.
- Read models are disposable — you can delete and rebuild them from events at any time.
- They are eventually consistent with the write side, not transactionally consistent.
A Projection Is a Left Fold
At its core, a projection is just a reduce over the event stream: you start with an initial state and apply each event in order to produce the next state.
Conceptually: readModel = events.reduce(apply, initialState). The apply function is a pure switch over event types. Anything you can fold, you can project.
// Pure projection: fold the event stream into a read model
const events = [
{ type: 'OrderPlaced', orderId: 'o1', total: 50 },
{ type: 'OrderPlaced', orderId: 'o2', total: 20 },
{ type: 'OrderPaid', orderId: 'o1' },
{ type: 'OrderCancelled', orderId: 'o2' },
];
function apply(state, event) {
const next = { ...state };
switch (event.type) {
case 'OrderPlaced':
next[event.orderId] = { status: 'placed', total: event.total };
break;
case 'OrderPaid':
if (next[event.orderId]) next[event.orderId].status = 'paid';
break;
case 'OrderCancelled':
delete next[event.orderId];
break;
}
return next;
}
const readModel = events.reduce(apply, {});
console.log(readModel);
// { o1: { status: 'paid', total: 50 } }