In recent years, architectural patterns like Command Bus and Query Bus have gained popularity, especially in TypeScript backends using NestJS and its @nestjs/cqrs package. While these patterns offer separation of concerns, they are often overused in places where a simple, clean application service or facade would be better.
🚫 The Problem: Overengineering in a Modular Monolith
Let’s look at a common anti-pattern in NestJS:
// update-profile.command.ts
export class UpdateProfileCommand {
constructor(public readonly userId: string, public readonly nickname: string) {}
}
// update-profile.handler.ts
@CommandHandler(UpdateProfileCommand)
export class UpdateProfileHandler implements ICommandHandler {
constructor(private readonly userRepository: UserRepository) {}
async execute(command: UpdateProfileCommand) {
const user = await this.userRepository.findById(command.userId);
user.updateNickname(command.nickname);
await this.userRepository.save(user);
}
}
// get-profile.query.ts
export class GetProfileQuery {
constructor(public readonly userId: string) {}
}
// get-profile.handler.ts
@QueryHandler(GetProfileQuery)
export class GetProfileHandler implements IQueryHandler {
constructor(private readonly userRepository: UserRepository) {}
async execute(query: GetProfileQuery) {
const user = await this.userRepository.findById(query.userId);
return { userId: user.id, nickname: user.nickname };
}
}
// user-profile.controller.ts
await this.commandBus.execute(new UpdateProfileCommand(userId, nickname));
await this.queryBus.execute(new GetProfileQuery(userId));That’s four classes and four files just to write 2 methods 🤯. Where is the value in that? Where is the gain?
🤔 Why This Hurts in Modular Monoliths
- Slower development: Every trivial feature requires scaffolding commands, handlers, and registering them.
- Cognitive overhead: Tracing a simple flow involves jumping across multiple files.
- False sense of architecture: You’re not gaining real modularity—just more indirection.
- No real CQRS benefit: If your read/write models are still the same, you haven’t implemented CQRS. You’ve just renamed method calls.
✅ Simpler, Cleaner with Facade Pattern
In a modular monolith, a well-designed facade class is often the perfect abstraction. It centralizes your module’s logic without overcomplicating it:
@Injectable()
export class UserProfileFacade {
constructor(private readonly userRepository: UserRepository) {}
async updateProfile(command: { userId: string; nickname: string }) {
const user = await this.userRepository.findById(command.userId);
user.updateNickname(command.nickname);
await this.userRepository.save(user);
}
async getProfile(query: { userId: string }) {
const user = await this.userRepository.findById(query.userId);
return { userId: user.id, nickname: user.nickname };
}
}You can still keep your boundaries sharp, and later, if real decoupling is needed, this is far easier to refactor than unravelling a forest of bus-handlers.
🤔 Common Developer Misconceptions
There are a few misconceptions which I would like to address. I belive that those might be the ones driving developer’s decisions to use command/query bus.
🚫Command and query bus will help me in event-driven architecture
A command bus is not required for event-driven architecture. Events represent facts that something has happened. You can publish events directly from your services without introducing the complexity of a command bus. In fact, decoupling write workflows using events is what makes it event-driven – not the presence of command bus abstraction.
🚫I need command and query bus to implement CQRS
Many developers mistakenly equate “using Command/Query Bus” with “doing CQRS.” That’s not accurate.
Using a command/query bus ≠ implementing CQRS.
CQRS (Command Query Responsibility Segregation) is a design principle that suggests separating reads and writes into different models—potentially with their own data stores and lifecycle. It does not require a command bus or a query bus. In fact, you can implement CQRS with nothing more than simple classes.
🚫Using a command bus will help me handle cross-cutting concerns
Some command bus implementations support middlewares or pipeline behaviors, allowing you to apply cross-cutting concerns (logging, validation, authorization) uniformly to every command. In those cases, this claim holds some truth. However, the popular @nestjs/cqrs package does not support middleware on command handlers. This means you cannot rely on the command bus itself for centralized cross-cutting logic.
Regardless, it is equally easy to apply them using decorators like this:
@Logging()
@Validation()
@Injectable()
class UserProfileFacade {
async updateProfile() {}
}You can implement eg. the @Logging decorator to wrap class methods to add logging of all method calls. This also enables you to pick and choose on which class or method additional logic (eg. observability) is actually needed, if you apply it globally then some day you will end up fighting to disable it for just one use case. If you can apply @CommandHandler decorator on top of your handler than applying @Logging, @Security, @Validation decorators is equal in complexity.
You can even group all the necessary decorators in one @Service, @Component or @Facade decorators which applies all the necessary things you need on top of your classes. Simple and clean.
@Facade()
class UserProfileFacade {
async updateProfile() {}
}🚫Command Bus Helps Prevent Overgrown Application Services
Some believe that forcing developers to split every action into a separate handler prevents services to go “fat” and makes junior code easier to refactor.
The Reality:
Breaking logic into dozens of tiny handler classes doesn’t prevent poor design – it just moves the mess into a different structure. Instead of a lengthy classes, you now have:
- A god module with 40+ tiny files
- Indirection that hides flow and dependencies
- Boilerplate overload (command, handler, interface, DTO, etc.)
- More cognitive load to trace what the system is doing
Encouraging good design is a team practice – not something you can “enforce” through architectural scaffolding.
A Better Way:
- Use verbs + context in method names to reflect business actions, not just CRUD – eg.
loyaltyProgramFacade.rewardCustomerForReferral - Structure your module boundaries around business capabilities, not technical nouns like
User,Order, orProfile. - Keep logic close to where it makes sense, and extract only when the behavior becomes reusable or complex.
- Keep them small and focused.
- Organize logic explicitly, not through framework conventions.
- Use code review and pairing to help juniors learn to model behavior clearly.
If you are still conerned about god services then simply use a tools like eslint with rule max-lines to force developers below predefined amount of lines of code. If the service grows too much developer will be forced to extract some logic to another class.
🚫Command Bus Helps Me Prepare For Microservices
This is false. The real decoupling needed to move to microservices lies in interface contracts and data boundaries, not in whether you use a bus or not.
You can absolutely replace a method call like this:
await this.orderFacade.createOrder({ userId, productId });with:
await this.orderClient.createOrder({ userId, productId });The only thing you need is a clear contract (DTOs, interfaces) and a layer that abstracts the call (the facade). A command bus does not inherently help with this transition.
🔄 Command Bus Doesn’t Save You From These Real Microservice Problems:
- Distributed transactions
- Partial failures / retries / idempotency
- Network latency
- Schema versioning
- Service discovery
- Security boundaries
A command bus inside a monolith doesn’t prepare you for those – it just gives you local indirection.
🧠 The Real Way to Prepare for Distribution
✅ Keep clear module boundaries
✅ Use DTOs at the module boundary (facade methods)
✅ Use facades to encapsulate use cases
✅ Make your boundaries explicit and technology-neutral
Then, when you move a module out, just swap facade with client implementation and you are done:
// Before
const userProfile: UserProfileFacade;
await userProfile.updateProfile(...);
// After
const userProfile: UserProfileClient;
await userProfile.updateProfile(...);You don’t need to rewrite a forest of command handlers or rewire your whole command bus system.
🧠 When the Bus Makes Sense
Command/Query Buses do have valid use cases:
- Asynchronous workflows
- Dynamic Command Routing / Plugin-Based Systems
- Distributed systems
In a modular monolith, where everything runs in the same process, the benefits of using a command or query bus are often marginal. Most concerns that developers try to solve with a bus – such as logging, validation, transactions, or authorization – can be addressed more cleanly using explicit decorators inside application services or facades. This keeps your code easier to follow, debug, and evolve – without hiding behavior behind layers of indirection.
🏁 Final Thought
If you’re building an application and reaching for the command bus pattern package, pause and ask:
“Do I really need this abstraction for a simple CRUD or business use case? Is it worth the effort and increased complexity?”
Use facades or application services as your default. Reach for command/query buses only when necessary – not by default. Architectural patterns are supposed to solve problems – make sure you have the problem before adopting the pattern.
