How do you scale reads and writes independently at the infrastructure level using CQRS?
CQRS isn't just a read replica — it lets your read side use a completely different data model and storage engine, kept in sync through events instead of tables.
LEARN BY DOINGPut your intuition to work3 steps
How do you scale reads and writes independently at the infrastructure level using CQRS?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The core idea: two models, not one
CQRS — Command Query Responsibility Segregation — splits the model that handles writes (commands: create, update, delete) from the model that handles reads (queries), rather than using one schema for both. This is a stronger claim than "add a read replica": a read replica is a physical copy of the exact same schema, still shaped by the write side's constraints. CQRS says the read side can have an entirely different data model, different indexes, and even a different storage engine, kept in sync with the write side through events rather than through physical replication of the same tables.
#Why sharing a schema is a scaling ceiling
The write side wants normalization — data broken into small, non-redundant tables to avoid update anomalies — strong consistency, and few indexes, since every additional index slows down every write. The read side wants exactly the opposite: denormalized, pre-joined data shaped around specific query patterns, as many indexes (or specialized index structures) as the queries need, and often a storage engine chosen for one access pattern rather than general-purpose flexibility — a search index for full-text queries, a graph database for traversal-heavy lookups, a simple key-value store for point lookups, a columnar store for analytics.
A single shared schema forces a compromise between these goals: denormalizing for read speed hurts write correctness and increases update complexity, while normalizing for write correctness forces reads into expensive joins. CQRS resolves the tension by refusing to compromise — each side gets the model it actually wants.
#Architecture: write store, event propagation, read stores
A command service accepts writes and persists them to a write-optimized store — typically a normalized relational database enforcing transactional invariants. On commit, the write is captured as a domain event (published explicitly by the command service) or picked up via change data capture (a tool like Debezium tailing the database's write-ahead log) and placed on an event bus. Independent projection workers consume that stream and update one or more read-optimized stores, each shaped for a specific query pattern. A query service reads exclusively from those read stores — it never touches the write database at all.
#The actual payoff: independent scaling and independent modeling
Because the read stores are physically separate systems, they scale independently of write capacity — add replicas, shard, or resize a read store without touching the write path's throughput or its transaction isolation guarantees at all. Heavy read traffic (a viral spike, a reporting job scanning millions of rows) can never contend for the write database's locks, connections, or I/O, because it's structurally impossible for it to reach that database.
Just as importantly, each read store can use the data model and storage engine best suited to its specific access pattern instead of every query pattern competing for indexes and schema decisions on one shared table. A product catalog might project into Elasticsearch for search, Redis for a hot product-detail cache, and a denormalized reporting table for analytics — three read models from one write model, each independently scalable and independently optimized.
#The cost: eventual consistency and operational weight
A write is visible on the read side only after its event has propagated and been projected — there's a real, measurable lag, typically milliseconds but occasionally longer under backpressure. This creates the classic "read-your-writes" problem: a user submits a form, the command succeeds, and an immediate subsequent read (against the projected read store) can still show the old value. Common fixes are returning the fresh entity directly in the command's response rather than re-querying, or routing that specific user's next read to the write side (or a low-latency, closely-synced replica) for a short window after their own write.
Rebuilding a read store — after a bug in a projector, a schema change, or standing up a brand-new read model — means replaying the event log from the beginning, which only works if every projector is idempotent and safe to run multiple times over the same events. And the system now has genuinely more moving parts: an event bus to operate, multiple projection workers to monitor for lag, and schema evolution to manage across however many read stores exist, each independently.
#When it earns its complexity
CQRS pays for itself when the read/write ratio is heavily skewed, when read patterns are genuinely heterogeneous (the same underlying data needs to support full-text search, simple lookups, and analytics simultaneously, patterns no single schema serves equally well), or when the write side has enough business-invariant complexity that keeping its model clean and transactional is valuable on its own merits. It's overkill for a straightforward CRUD application where the read patterns already match the write schema reasonably well — in that case, a read replica gets most of the scaling benefit for a fraction of the operational cost.