Architecture
CQRS
Split your data model in two: a command side that writes and validates, and a query side that reads — each shaped for its own job.
One model, two jobs that fight each other
A single model must serve heavy writes and complex reads. Read-optimized indexes slow down writes; a write-normalized schema forces reads into expensive joins. You can't scale, tune, or shape them separately.
class OrderModel {
// heavy write validation…
save(order) { /* normalize, validate */ }
// …fights a read-optimized query
getDashboard() {
return db.join(orders, items, users) // slow ✗
.groupBy(...);
}
}
Separate command side from query side
commands validate and change state on the write model. Writes emit an
event that projects the change into a separate, denormalized read model.
queries only ever read from it — they never mutate anything.
class PlaceOrder { // command
execute(cmd) {
write.save(cmd);
emit('OrderPlaced', cmd);
}
}
class Dashboard { // query
on('OrderPlaced', e =>
readModel.project(e)); // update view
get() { return readModel.summary; }
}
Place a command — query the projection
Place order writes to the write store, then an event projects it into the read model. Query dashboard always reads the read model — instantly, but it may lag behind by a beat.
📝 Write store (normalized)
📊 Read model (denormalized)
no orders yet
Two models, each shaped for its job
- Scale independently: add read replicas or write shards without touching the other side.
- Tailor each model: normalized for safe writes, denormalized for fast reads — no compromise.
- Clearer code: commands mutate, queries read — no method secretly does both.
- Caution: more moving parts and eventual consistency between the two sides. Only reach for CQRS in high-scale or complex domains — plain CRUD is fine otherwise (YAGNI).