When should I apply CQRS, and how do I sync the read and write models?
Question
In our app the write operations are very few (~5,000 records/day) but reads and complex reporting are very heavy (~2,000 queries/sec). The relational DB locks up because of the reporting queries. We want to fully separate the write model (PostgreSQL) and the read model (Elasticsearch or an optimized read-replica SQL) — CQRS. How do I keep the two models in sync, event-driven, with no lag and reliably?
Answer
Short answer: 2k reads/sec against 5k writes/day — that asymmetry is a real CQRS case. But start with the cheapest version; don’t jump straight to full CQRS.
Short answer
The problem is clear: heavy reporting queries lock the write path too. Most of the time you don’t need a full architectural split to fix that. If you really are moving the read model onto Elasticsearch, the search side brings costs of its own; I worked through those in the edge n-gram answer.
Why
- Full CQRS only earns its keep when query shapes diverge. A separate read model (Elasticsearch or denormalized SQL) is only worth it when query shapes diverge so much that one schema can’t serve both well. Needs like full-text search or heavy aggregation — where even a replica isn’t enough — are what justify splitting out the read model.
- Dual-writes inevitably drift. If the app writes to both stores, the two sides diverge over time and you can no longer say which one is right.
- The read model always lags. It trails the write by ms–seconds; eventual consistency isn’t a defect, it’s the price of this architecture.
What to do
- Add a read replica first. Put one or more read replicas on PostgreSQL and route all reporting queries there. Writes stay on the primary, reads go to the replica; the locking usually ends with just this. Don’t take on CQRS complexity before trying it.
- Sync the models event-driven. Have the write side emit an event on every change; a projector consumes those events to update the read model.
- Use the outbox pattern for reliability. Write the event to an outbox table in the same transaction as the business data, and have a separate process publish it — so you never get “the DB committed but the event was lost.”
- Design the UI for eventual consistency. For example, “saved” instantly, visible in the list a moment later. Always drive the read model from the write side’s events, and never write to both stores from the app.
Bottom line: replicas first, move reporting there. Adopt full CQRS only when query needs genuinely diverge, with outbox-driven projections — and treat the added complexity as a cost. The answer to “is PostgreSQL enough for me?” is still yes under most loads; push it to its limit before reaching for CQRS.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.