CQRS in Real Systems: Separating Commands and Queries Without Overengineering

How I use CQRS concepts when transactional commands and read-heavy search/dashboard workloads require different models, without automatically introducing unnecessary databases or event infrastructure.

Romharshan Singh
Romharshan SinghSenior Solution Architect • AI & Cloud Mentor
1 September 20264 min read0 viewsUpdated 1 Sept 2026
CQRS in Real Systems: Separating Commands and Queries Without Overengineering

CQRS in Real Systems: Separating Commands and Queries Without Overengineering

My interpretation of CQRS

CQRS is often presented as:

plaintext
write database
Kafka
read database

That is one implementation, not the definition.

The useful idea is:

the model used to change state does not have to be the same model used to answer queries.

I have used this thinking where transactional booking/procurement operations had strong validation and consistency requirements, while search, dashboard or listing views needed different shapes and scaling characteristics.

Start with logical separation

Command:

plaintext
CreateAuction
PlaceBid
CloseAuction
AwardSupplier

Query:

plaintext
SearchAuctions
SupplierDashboard
BidHistory
AwardSummary

Even in one application I keep the code paths explicit.

NestJS:

typescript
@CommandHandler(CreateAuctionCommand)
export class CreateAuctionHandler
  implements ICommandHandler<CreateAuctionCommand> {

  constructor(
    private readonly repository: AuctionRepository,
  ) {}

  async execute(command: CreateAuctionCommand) {
    const auction = Auction.create({
      rfxId: command.rfxId,
      opensAt: command.opensAt,
      closesAt: command.closesAt,
    });

    await this.repository.save(auction);

    return { auctionId: auction.id };
  }
}

Query:

typescript
@QueryHandler(GetAuctionDashboardQuery)
export class GetAuctionDashboardHandler
  implements IQueryHandler<GetAuctionDashboardQuery> {

  constructor(
    private readonly readRepository: AuctionReadRepository,
  ) {}

  execute(query: GetAuctionDashboardQuery) {
    return this.readRepository.dashboard(
      query.auctionId,
    );
  }
}

That separation alone can improve maintainability.

Physical read model

When runtime requirements justify it:

plaintext
Command API
    |
Domain / Write DB
    |
Outbox/Event
    |
Projection
    |
Read DB / Search index
    |
Query API

Projection:

typescript
@EventsHandler(AuctionCreatedEvent)
export class AuctionProjection {
  constructor(
    private readonly readRepo: AuctionReadRepository,
  ) {}

  async handle(event: AuctionCreatedEvent) {
    await this.readRepo.upsert({
      auctionId: event.auctionId,
      rfxId: event.rfxId,
      status: 'OPEN',
      closesAt: event.closesAt,
      bidCount: 0,
    });
  }
}

The read model can denormalize data specifically for the UI without weakening write-side invariants.

Eventual consistency

This introduces a business question.

After command success:

plaintext
POST /auction -> 201

the query model may update a few hundred milliseconds later.

That means the UI should not assume:

plaintext
command succeeded
therefore every read replica/projection is immediately updated

For read-your-write UX I can:

  • return enough data from the command response;
  • poll until projection version catches up;
  • read from the write model for the just-created resource;
  • use synchronous projection where required.

The choice depends on the business SLA.

When CQRS helps

I normally see value when:

  • write rules are complex;
  • reads dominate traffic;
  • read shape is very different from aggregate shape;
  • search needs Elasticsearch/OpenSearch;
  • multiple projections serve different products;
  • read scaling differs significantly;
  • event history already exists.

When CQRS hurts

A simple CRUD table does not need:

plaintext
command bus
event bus
projection service
read database
replay tooling

just to create a status endpoint.

CQRS can double the number of models and operational paths. I introduce physical separation only when the value is measurable.

Rebuilding projections

A strong read model is rebuildable from durable events or authoritative state.

That changes recovery:

plaintext
drop bad projection
 -> replay events
 -> rebuild

If the only copy of important business data exists in the projection, it is no longer just a projection.

Projection idempotency

Events may repeat:

typescript
await readRepo.upsert({
  eventVersion: event.version,
  ...
});

I keep a version/event identity so a duplicate cannot increment counters twice.

Production checklist

  • Separate command and query responsibilities first.
  • Define write-side invariants.
  • Define acceptable projection lag.
  • Make projections idempotent.
  • Make read models rebuildable.
  • Track projection lag.
  • Version events.
  • Avoid business validation in read projections.
  • Provide read-your-write UX intentionally.
  • Add a second database only when justified.

FAQ

Does CQRS require Kafka?

No.

Does CQRS require two databases?

No.

Is event sourcing required?

No. CQRS and event sourcing are independent patterns.

Can CQRS improve performance?

Yes when reads can be denormalized/scaled independently, but it also adds operational complexity.

- [Microservices Design Patterns I Have Used in Real Enterprise Projects](/articles/microservices-design-patterns-real-enterprise-projects) - [Transactional Outbox](/articles/transactional-outbox-mysql-postgresql-kafka)
  • API Composition

  • Database per Service

    Architect's final take

    I use CQRS as a separation tool, not as a technology shopping list. I start simple and let actual read/write pressure justify physical separation.

Was this article useful?

Your feedback helps prioritize deeper technical content.

Romharshan Singh
ABOUT THE AUTHOR

Romharshan Singh

Senior Solution Architect and Full Stack Technology Leader with 20+ years of enterprise engineering experience across AI, cloud, distributed systems, Java, Node.js, React, Angular, Kafka and Kubernetes.