CQRS with a typed Command Bus
Commands and queries are separate concerns. The bus makes dispatch type-safe without boilerplate.
Context
The original implementation wired handlers directly into an identity module object (`identityModule.loginUser.execute(dto)`). As the number of commands grew, two problems showed up: the module became a dependency magnet where every handler's dependencies had to be instantiated in one place, and call sites had to know which module owned which handler.
The decision
Introduce a CommandBus and QueryBus in the shared infrastructure layer. Each module registers its handlers in a composition root (`api/index.ts`) via a Module class with explicit dependency wiring. Dispatch is the only public API. Callers don't need to know which handler runs.
Rationale
- 1
Each module has a composition root that wires all dependencies and registers handlers against the bus. The full dependency graph is visible in one place.
- 2
Return types are inferred via a phantom field (`declare readonly _response: TResponse`) on the Command base class. No explicit generic needed at the call site. TypeScript infers it from the command instance.
- 3
The `{ name: string; prototype: T }` type for the CommandClass parameter avoids both `Function` and `any` while still inferring T from the class prototype. Every class satisfies this shape automatically.
- 4
Adding a new command means one new folder for the command and handler, plus one registration call in the module root. No barrel update, no handler map.
In the codebase
Before. Direct handler call
// Call site had to know the module and handler name
const result = await identityModule.loginUser.execute(dto);After. Bus dispatch with inferred return type
// Return type is LoginUserResponse, inferred, no explicit generic
const result = await commandBus.dispatch(new LoginUserCommand(dto.email, dto.password));
const { jwt } = result.getValueOrThrow();Phantom type on the Command base class
abstract class Command<TResponse = unknown> {
// compile-time only, zero runtime cost
declare readonly _response: TResponse;
}
class LoginUserCommand extends Command<LoginUserResponse> {
constructor(readonly email: string, readonly password: string) {
super();
}
}Handler registration in the module composition root
// api/index.ts
import { commandBus, eventBus, prisma } from '@/core/shared/infrastructure';
import { UserRepository, PasswordHasher } from '../infrastructure';
import { LoginUserCommand, LoginUserHandler } from '../application';
import { IdentityService } from './identity.service';
class IdentityModule {
private constructor() {}
static init(): IdentityService {
const repos = {
userRepository: new UserRepository(prisma),
};
commandBus.register(
LoginUserCommand,
new LoginUserHandler(
repos.userRepository,
eventBus,
PasswordHasher,
),
);
// ... other command/query registrations
return new IdentityService(commandBus, queryBus, JwtService);
}
}
const identityService = IdentityModule.init();
export { identityService };Tradeoffs
Call sites are thin. `commandBus.dispatch(new LoginUserCommand(dto))` is the full API surface.
If a handler is never registered, the error surfaces at runtime, not compile time.
Phantom types give full response type inference without a code generator or explicit generics.
The phantom type pattern (`declare readonly _response`) is non-obvious to engineers unfamiliar with it.
Each command folder is self-contained. Command and handler live together, registration happens in the module root.
The module composition root grows with each new command. At enough handlers this file gets long, but it stays straightforward.