How I use DDD and CQRS in backend projects
DDD and CQRS are useful not as folder structures, but as a way to keep business logic visible through commands, rules, states and events.
DDD and CQRS are easy to turn into architecture theatre: many folders, long class names and several layers around a few CRUD operations. I try to use these ideas differently. For me, they are not about making the structure look impressive. They are about keeping business logic under control.
If a project is small and mostly does create/update/delete, a normal service layer is often enough. But once statuses, roles, events, documents, auctions, disputes, signing flows or background processing appear, simple structure starts leaking. Rules end up inside controllers, guards, DTOs, ORM entities and random helpers.
DDD and CQRS help prevent that from becoming the default.
I Do Not Start With Folders
Before choosing a structure, I try to understand the domain:
- which entities actually matter to the business;
- which states they move through;
- who can change those states;
- which transitions are forbidden;
- which errors should be visible to the user;
- which actions must happen immediately;
- which work can move to a queue;
- which events other parts of the system care about.
If these questions are unanswered, CQRS will not save the project. It will only distribute uncertainty across directories.
CQRS as a Language of Intent
I like CQRS because a command names the action. UpdateOrderDto does not say much. AcceptOrderResponseCommand, StartDisputeCommand, FinishAuctionRoundCommand or ModerateCommentCommand say much more.
The handler becomes the place where the scenario is coordinated:
await this.permissions.assertCanAcceptResponse(user, order);
order.acceptResponse(responseId);
await this.orders.save(order);
await this.events.publish(new OrderResponseAcceptedEvent(order.id));
This does not have to be perfect textbook DDD. What matters is that the code shows what happens in the product. Not “update a row”, but “accept a response”, “finish a round”, “send a document for signing”.
Queries live separately. The read side can be optimized for an admin table, GraphQL resolver, public page or filters. I do not try to force the write model to be convenient for every read scenario.
DDD Without Religion
I do not believe every project needs aggregates, value objects and domain events on day one. Sometimes users.service.ts plus good validation is an honest and sufficient solution.
But there are signs that a domain model is needed:
- an entity has a lifecycle;
- an action depends on user role and entity state;
- a command triggers side effects;
- the system serves several clients: web, mobile, bot, admin;
- errors must be explained in product language;
- a “simple update” starts breaking neighboring workflows.
Good domain code can be read without knowing Fastify, GraphQL, TypeORM or a specific UI. A rule like “a contractor cannot complete an order before a team is assigned” should be a product rule, not an if inside a resolver.
Where I Draw Boundaries
Most of the time this structure is enough for me:
- controller/resolver receives the request and validates DTOs;
- command/query handler coordinates the scenario;
- domain service or entity owns the rule;
- repository hides storage details;
- event/job moves side effects out of the main request.
I do not chase absolute purity. If a rule is simple, it can live in a handler. If it repeats or becomes an important domain concept, I move it closer to an entity or domain service.
Architecture should help the team move, not charge a tax for every new endpoint.
Events and Queues
In product backends, many actions do not end with saving a database row. Accepting a response can send notifications. Uploading a document can start conversion. Finishing an auction can update participants and publish a real-time event.
I try not to do all of that inside one synchronous request. A command should complete the main scenario and persist state. The rest can be moved into an event or queue:
await this.commandBus.execute(new FinishAuctionRoundCommand(auctionId));
await this.queue.add('notify-auction-participants', { auctionId });
This makes background work easier to retry, log and observe without forcing the user to wait for things they do not need immediately.
Practical Takeaway
DDD and CQRS are not there to make a project look serious. They are useful when business logic becomes more valuable than infrastructure code.
My approach is simple: start with a clear module, add architectural weight only where it reduces chaos, and keep checking the main criterion - did the business rule become easier to find, read and change?