CQRS
The flow of simple CRUD (Create, Read, Update and Delete) applications can be described as follows:
- The controllers layer handles HTTP requests and delegates tasks to the services layer.
- The services layer is where most of the business logic lives.
- Services use repositories / DAOs to change / persist entities.
- Entities act as containers for the values, with setters and getters.
While this pattern is usually sufficient for small and medium-sized applications, it may not be the best choice for larger, more complex applications. In such cases, the CQRS (Command and Query Responsibility Segregation) model may be more appropriate and scalable (depending on the application's requirements). Benefits of this model include:
- Separation of concerns. The model separates the read and write operations into separate models.
- Scalability. The read and write operations can be scaled independently.
- Flexibility. The model allows for the use of different data stores for read and write operations.
- Performance. The model allows for the use of different data stores optimized for read and write operations.
To facilitate that model, Nest provides a lightweight CQRS module. This chapter describes how to use it.
Installation
First install the required package:
$ npm install --save @nestjs/cqrs
Commands
Commands are used to change the application state. They should be task-based, rather than data centric. When a command is dispatched, it is handled by a corresponding Command Handler. The handler is responsible for updating the application state.
@@filename(heroes-game.service)
@Injectable()
export class HeroesGameService {
constructor(private commandBus: CommandBus) {}
async killDragon(heroId: string, killDragonDto: KillDragonDto) {
return this.commandBus.execute(
new KillDragonCommand(heroId, killDragonDto.dragonId)
);
}
}
@@switch
@Injectable()
@Dependencies(CommandBus)
export class HeroesGameService {
constructor(commandBus) {
this.commandBus = commandBus;
}
async killDragon(heroId, killDragonDto) {
return this.commandBus.execute(
new KillDragonCommand(heroId, killDragonDto.dragonId)
);
}
}
In the code snippet above, we instantiate the KillDragonCommand
class and pass it to the CommandBus
's execute()
method. This is the demonstrated command class:
@@filename(kill-dragon.command)
export class KillDragonCommand {
constructor(
public readonly heroId: string,
public readonly dragonId: string,
) {}
}
@@switch
export class KillDragonCommand {
constructor(heroId, dragonId) {
this.heroId = heroId;
this.dragonId = dragonId;
}
}
The CommandBus
represents a stream of commands. It is responsible for dispatching commands to the appropriate handlers. The execute()
method returns a promise, which resolves to the value returned by the handler.
Let's create a handler for the KillDragonCommand
command.
@@filename(kill-dragon.handler)
@CommandHandler(KillDragonCommand)
export class KillDragonHandler implements ICommandHandler<KillDragonCommand> {
constructor(private repository: HeroRepository) {}
async execute(command: KillDragonCommand) {
const { heroId, dragonId } = command;
const hero = this.repository.findOneById(+heroId);
hero.killEnemy(dragonId);
await this.repository.persist(hero);
}
}
@@switch
@CommandHandler(KillDragonCommand)
@Dependencies(HeroRepository)
export class KillDragonHandler {
constructor(repository) {
this.repository = repository;
}
async execute(command) {
const { heroId, dragonId } = command;
const hero = this.repository.findOneById(+heroId);
hero.killEnemy(dragonId);
await this.repository.persist(hero);
}
}
This handler retrieves the Hero
entity from the repository, calls the killEnemy()
method, and then persists the changes. The KillDragonHandler
class implements the ICommandHandler
interface, which requires the implementation of the execute()
method. The execute()
method receives the command object as an argument.
Queries
Queries are used to retrieve data from the application state. They should be data centric, rather than task-based. When a query is dispatched, it is handled by a corresponding Query Handler. The handler is responsible for retrieving the data.
The QueryBus
follows the same pattern as the CommandBus
. Query handlers should implement the IQueryHandler
interface and be annotated with the @QueryHandler()
decorator.
Events
Events are used to notify other parts of the application about changes in the application state. They are dispatched by models or directly using the EventBus
. When an event is dispatched, it is handled by corresponding Event Handlers. Handlers can then, for example, update the read model.
For demonstration purposes, let's create an event class:
@@filename(hero-killed-dragon.event)
export class HeroKilledDragonEvent {
constructor(
public readonly heroId: string,
public readonly dragonId: string,
) {}
}
@@switch
export class HeroKilledDragonEvent {
constructor(heroId, dragonId) {
this.heroId = heroId;
this.dragonId = dragonId;
}
}
Now while events can be dispatched directly using the EventBus.publish()
method, we can also dispatch them from the model. Let's update the Hero
model to dispatch the HeroKilledDragonEvent
event when the killEnemy()
method is called.
@@filename(hero.model)
export class Hero extends AggregateRoot {
constructor(private id: string) {
super();
}
killEnemy(enemyId: string) {
// Business logic
this.apply(new HeroKilledDragonEvent(this.id, enemyId));
}
}
@@switch
export class Hero extends AggregateRoot {
constructor(id) {
super();
this.id = id;
}
killEnemy(enemyId) {
// Business logic
this.apply(new HeroKilledDragonEvent(this.id, enemyId));
}
}
The apply()
method is used to dispatch events. It accepts an event object as an argument. However, since our model is not aware of the EventBus
, we need to associate it with the model. We can do that by using the EventPublisher
class.
@@filename(kill-dragon.handler)
@CommandHandler(KillDragonCommand)
export class KillDragonHandler implements ICommandHandler<KillDragonCommand> {
constructor(
private repository: HeroRepository,
private publisher: EventPublisher,
) {}
async execute(command: KillDragonCommand) {
const { heroId, dragonId } = command;
const hero = this.publisher.mergeObjectContext(
await this.repository.findOneById(+heroId),
);
hero.killEnemy(dragonId);
hero.commit();
}
}
@@switch
@CommandHandler(KillDragonCommand)
@Dependencies(HeroRepository, EventPublisher)
export class KillDragonHandler {
constructor(repository, publisher) {
this.repository = repository;
this.publisher = publisher;
}
async execute(command) {
const { heroId, dragonId } = command;
const hero = this.publisher.mergeObjectContext(
await this.repository.findOneById(+heroId),
);
hero.killEnemy(dragonId);
hero.commit();
}
}