CQRS in Real Systems: Separating Commands and Queries Without Overengineering
My interpretation of CQRS
CQRS is often presented as:
write database
Kafka
read databaseThat 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:
CreateAuction
PlaceBid
CloseAuction
AwardSupplierQuery:
SearchAuctions
SupplierDashboard
BidHistory
AwardSummaryEven in one application I keep the code paths explicit.
NestJS:
@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:
@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:
Command API
|
Domain / Write DB
|
Outbox/Event
|
Projection
|
Read DB / Search index
|
Query APIProjection:
@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:
POST /auction -> 201the query model may update a few hundred milliseconds later.
That means the UI should not assume:
command succeeded
therefore every read replica/projection is immediately updatedFor 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:
command bus
event bus
projection service
read database
replay toolingjust 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:
drop bad projection
-> replay events
-> rebuildIf the only copy of important business data exists in the projection, it is no longer just a projection.
Projection idempotency
Events may repeat:
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.
Related architecture guides
- [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)
-
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.
Your feedback helps prioritize deeper technical content.






