Implementing the CQRS pattern in infrastructure

When reads and writes compete for the same resources, the service degrades under load. We design and implement a turnkey CQRS loop: we separate flows, eliminate mutual impact, and hand over ready-made schemas and policies to the team. Write load stops determining response speed.

Our competencies:

Frequently Asked Questions

Latest works

  • Development of a web application for FEEDME
    Development of a web application for FEEDME
    1365
  • Development of an online store for the company FURNORO
    Development of an online store for the company FURNORO
    1330
  • B2B Advance company logo design
    B2B Advance company logo design
    776
  • Development of a web application for Enviok
    Development of a web application for Enviok
    1077
  • AIDER company logo development
    AIDER company logo development
    1022
  • CRM development for Chasseurs
    CRM development for Chasseurs
    1113

CQRS in infrastructure: where the separation of reads and writes breaks down

CQRS is not a library or a separate service, but a separation of two paths with different failure requirements. Commands write through a transactional model: strict consistency, short locking, predictable latency.

Reads are served by projections: they can be rebuilt, they tolerate lag, and they are denormalized for a specific screen.

Most incidents live at the junction. Projection desynchronization manifests as read-after-write: the user sent a command, the interface requested the model, and got the previous state.

This is not cured by a pause before the request, but by the aggregate version in the command response — the client reads until the projection catches up to that version.

Duplicate commands are the second typical failure point: the transport delivers an event at least once, a retry after a timeout arrives as a new command, and performs the operation twice.

Idempotency becomes part of the command contract: a deduplication key on input, a unique index on the identifier, and checking the expected version on write.

Write degradation under analytical load is almost always a consequence of shared infrastructure: heavy reports occupy the connection pool and disk, and write tail latency grows nonlinearly.

Therefore, we move the read path to separate replicas and stores — for example, projections on PostgreSQL with their own load profile — and on writes we set timeouts, limit concurrent requests, and use an explicit rejection instead of accumulating a queue.

What the CQRS setup provides: writes, reads, and the class of eliminated failures

Separating writes and reads provides two independent paths, and each is measured by its own metric.

Commands write to a normalized store with synchronous replication (in our setup — PostgreSQL): write response time stays within fractions of a second and does not depend on how many readers are building reports simultaneously. Reads are served by denormalized projections that are populated asynchronously from the event log.

Read fault tolerance rests on the fact that projections are derived from the log and are not the source of truth. A read node can be lost entirely: order intake continues, and the projection is restored by replaying events from the last saved position.

Recovery time is then determined by the daily volume of the log, not by the size of the primary database and the speed of its deployment.

Write load stops competing with analytics. Heavy queries and exports go to separate replicas, so peak report traffic no longer blocks transactions.

The class of failures also changes: instead of "the database went down under a reporting query," we get degradation of one projection, which catches up with the stream in minutes and does not interfere with order processing.

We calculate the cost of downtime in lost operations, not in hours of full unavailability. While the write path is unavailable, new orders are not accepted — but reading history, statuses, and the customer account continue to work because they run on their own replicas.

Write response time, recovery time, and read failure rate are the three metrics by which we accept the work after implementation.

Options for separating reads and writes: comparison of approaches

Separating reads and writes is not a switch but a scale: from separate roles within one database to an event log with asynchronous projections. Each step to the right offloads transactional writes but adds latency between what is written and what is read.

The choice is dictated by the data freshness requirement: somewhere "within a second" is enough, somewhere the count is in fractions of a second.

Option When it fits Limitations
Separate roles in one database Analytics and admin panel interfere with transactional writes Shared resource: a heavy report still slows writes
Read replica Load is mostly reads, lag of seconds is acceptable Replication lag: reading immediately after a write requires routing to the primary
Materialized views Heavy aggregates, data staleness is acceptable Recalculation hits locks; the set of views is tightly coupled to queries
Event log and asynchronous projections Multiple read models, history and rebuilds are needed Eventual consistency: duplicates, reprocessing, projection lag

Eventual consistency is not a defect but a parameter: its delay is set explicitly and checked by the projection lag metric. We start with separate roles where that is enough and move to an event log only where history and rebuilding of read models are needed.

Implementation order: from data schema audit to handover of regulations

CQRS implementation starts not with code but with a description of which queries the system serves and where writes interfere with reads. Without this step, separating models turns into two stores with diverging data and manual reconciliation.

Implementation route

  1. Data schema audit. We map the entities: what is only read, what is constantly written, where operations compete for row locks. The output is a list of scenarios with a load profile for each.
  2. Command flow schema. We fix aggregate boundaries and which command produces which event. Here we also decide which changes may be applied asynchronously and which must be strictly consistent.
  3. Read model design. For each screen and report — its own view with the needed indexes, without a universal "one-size-fits-all" table. We separately specify the acceptable projection lag for each scenario.
  4. Implementation plan. We proceed through one bounded context at a time, keeping the previous write path as a fallback. Until the new path is validated, rollback is possible by switching without data loss.
  5. Synchronization setup. We define the queue, event delivery order, handler idempotency, and behavior on redelivery. For failed messages — a separate triage branch so that one event does not block the stream.
  6. Load testing. We run writes and reads separately: we look at projection lag under peak load, behavior when a consumer is unavailable, and view warm-up time after rebuilding.
  7. Handover of regulations. We provide instructions for replaying events, rebuilding a projection from scratch, and resolving discrepancies, as well as a set of metrics for the state of the path for the on-call shift.

Each step ends with an artifact: a schema, configuration, or regulation. This keeps implementation manageable, and operations do not depend on the authors of the solution.

Deliverable package: flow schemas, access policies, regulations

The documentation package is part of the result, not an appendix to it. Without it, the CQRS setup works, but the customer's team cannot change it: it is unclear where the truth is written, who reads the projections, and what to do in case of a discrepancy.

That is why, along with the code, we hand over artifacts, each of which covers its own class of operational tasks.

  • Event flow map — which commands produce them, who the consumers are, what delivery order is guaranteed, and how reprocessing is suppressed.
  • Data schema — separation of the write side and read side, projection model, keys and indexes for specific screen queries.
  • Projection documentation — event source, rebuild rule, normal lag from the stream, procedure for full recalculation without stopping reads.
  • Access policies — roles for sending commands and for reading, separation by aggregates, a separate permission for rebuilding a projection.
  • Maintenance regulation — how to change the event schema, how to version events, and what to do with consumers that have not yet migrated to the new version.
  • Incident triage procedure — signs of read/write divergence, diagnostic steps based on logs, rollback to the previous projection version.
  • Deployment schema — node composition, broker parameters, retry queues, and limits after which an engineer is engaged.

The package lives in the customer's repository and is updated along with the code. We check it at acceptance simply: if it cannot be used to rebuild a projection or onboard a new engineer, the task is not closed.

Case study: separating reads and writes in a high-load service

“The service started with a single node: both writes and reads went to the same table. At peak, write transactions hit locks — write response time grew from a second to ten, and the read failure rate reached 8%.

Failures were the worst part: losing a node meant manual recovery and up to half an hour of unavailability.

We separated the models. Writes remained in the transactional PostgreSQL store, reads moved to separate denormalized projections that are updated from the change log.

Handlers were made idempotent, and the log became the source from which a projection can be fully rebuilt after a failure. Read replicas switch automatically, without manual intervention.

What changed in the metrics. Write response time at peak decreased by 6–8 times and stopped depending on the volume of reads. The read failure rate fell below tenths of a percent: a query no longer waits for locks, and a single replica going down does not take down the output.

Recovery after a node failure is automatic, within a couple of minutes, without losing confirmed writes.

We also separated observability: projection lag, queue depth, and divergence between models are visible on the monitoring dashboard, so degradation is caught before the user notices it.”

How does CQRS differ from a read replica?

A read replica is the same write model replicated across nodes: the same tables, the same constraints, the same contract.

Only who answers requests changes, and divergence is limited to replica lag — usually fractions of a second, and for most reporting queries it is imperceptible. Separating models begins where read and write requirements diverge so much that one schema no longer serves them.

In CQRS, the write model is responsible for state and invariants: transactions, checks, locks. The read model is a separate projection for specific queries: denormalized tables, precomputed aggregates, search structures. It is built from the change stream, not from a copy of tables, and lives by its own rules.

Hence the key difference. For a replica, lag is copying delay with the same meaning of data.

In CQRS, models diverge by definition: a projection is not the source of truth, it can be rebuilt from scratch, and a calculation error is fixed by recalculation rather than restoration from a copy. The price for this is asynchrony.

A reader immediately after a write may not see their changes. Where this is unacceptable, we version the projection and wait for confirmation of the required version, or keep a synchronous path for a narrow set of operations.

At the same time, we monitor different metrics: replica lag and projection update delay are not the same thing. If reads and writes are symmetric in load, separating models is not needed — replicas and request distribution are enough.

Technical concerns: consistency, duplicate commands, migration

Objections to CQRS are resolved at the design stage, not after launch: each has a specific engineering solution that is verified by a test or load. Below are answers to the ones that most often come up before work starts.

How is command idempotency guaranteed?

Each command arrives with a unique key, and the handler keeps a log of already applied keys. Redelivery on retry does not create a second record: the operation is either already applied or accepted as new.

What about data consistency between writes and reads?

The projection catches up with the log with a delay of fractions of a second, and this window is measurable — it is visible in the metrics.

For critical scenarios, reads wait for the required projection version or go through the log itself, and the interface shows "accepted, processing" instead of stale data.

Can projections be rebuilt without downtime?

A projection is derived from the log, so the new version is built into a separate schema in parallel with the active one, catches up with the stream, and is switched by the read route. Rollback is returning the route to the previous schema.

How does migration proceed without stopping writes?

We extend the schema, write to the old and new format simultaneously, migrate history in background batches, verify checksums by keys, and only then remove the old path. Each step is reversible and does not block writes.

What happens if the event transport is unavailable?

The event is written in the same transaction as the state change, and a separate worker publishes it with delivery confirmation. Unconfirmed ones are resent, and the receiver discards duplicates by event identifier.

Let's discuss the task: what is needed for an estimate and work plan

For the work estimate to rely on facts rather than assumptions, we need a brief review of the current setup: where writes happen, where reads happen, and how they are connected.

The more specific the source data, the more accurate the implementation plan and the fewer assumptions in timelines and scope of changes.

Whatever is at hand will work: schemas, service configurations, metric exports, descriptions of known bottlenecks. If there is no documentation, we will reconstruct the picture from your answers and the logs you are willing to show.

What noticeably speeds up preparation:

  • system diagram: services, write and read points, how requests currently flow;
  • load profile: share of reads and writes, peaks, tolerance for projection lag;
  • consistency requirements: where delayed reads are acceptable and where freshness is needed;
  • stores and queues: what is already used, access and environment restrictions;
  • rollout and rollback: how changes are released, whether there is a set of regression checks;
  • acceptance criteria: by which metrics we will know the solution works;
  • constraints: regulations, maintenance windows, who makes decisions on your side.

In response, we send a stage-by-stage estimate, an implementation plan with the order of work and rollback points, as well as a scope of work with responsibility boundaries. Describe the task in a convenient form — we will get back with an estimate and plan.