NestJS Enterprise Backend APIs · 课时

命令、处理器与命令总线

将写入操作建模为命令,并通过 CommandBus 分发给专用处理器。

第 1 / 4 课13 个步骤

命令、处理器与命令总线 是 CoddyKit 上的免费 NestJS Enterprise Backend APIs 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 NestJS Enterprise Backend APIs 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 NestJS Enterprise Backend APIs 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

Why Commands Exist

In a CQRS system we split the model into two halves: the write side that changes state and the read side that returns data. A Command represents an intent to change state, for example CreateOrder or CancelSubscription.

  • A command is a plain DTO carrying the data needed to perform the write.
  • It is named in the imperative (do this) and describes a single business action.
  • It returns at most an identifier or acknowledgment, never a full read model.

NestJS ships @nestjs/cqrs, which gives us a CommandBus to dispatch commands to exactly one handler.

Modeling a Command

A command is just a class. It holds the input data as readonly properties so it stays immutable once created. There is no logic inside it.

Below, CreateOrderCommand captures everything a handler needs to create an order. Notice it carries no NestJS decorators and no behavior.

export class CreateOrderCommand {
  constructor(
    public readonly customerId: string,
    public readonly items: { sku: string; quantity: number }[],
    public readonly currency: string,
  ) {}
}

The Command Handler Contract

Each command is processed by exactly one handler. In NestJS you implement ICommandHandler<TCommand> and decorate the class with @CommandHandler(TCommand).

  • @CommandHandler registers the link between a command type and its handler.
  • The interface forces an execute(command) method.
  • The handler is a normal provider, so it can inject repositories and services.
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateOrderCommand } from './create-order.command';

@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler
  implements ICommandHandler<CreateOrderCommand>
{
  async execute(command: CreateOrderCommand): Promise<{ orderId: string }> {
    const orderId = crypto.randomUUID();
    // persist order, charge, etc.
    return { orderId };
  }
}

Dispatching Through the CommandBus

Controllers and other entry points do not call handlers directly. They build a command and hand it to the CommandBus via execute(). The bus finds the registered handler and runs it.

This indirection means the controller stays thin and knows nothing about persistence, validation rules, or side effects.

import { Body, Controller, Post } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { CreateOrderCommand } from './create-order.command';

@Controller('orders')
export class OrdersController {
  constructor(private readonly commandBus: CommandBus) {}

  @Post()
  async create(@Body() dto: CreateOrderDto) {
    return this.commandBus.execute(
      new CreateOrderCommand(dto.customerId, dto.items, dto.currency),
    );
  }
}

Wiring It Up with CqrsModule

For the bus to discover handlers, you must import CqrsModule in the feature module and register every handler as a provider. NestJS scans providers for the @CommandHandler metadata at startup and binds them to the bus.

  • Forget to list the handler in providers and the bus throws an unhandled command error at dispatch time.
  • CqrsModule supplies CommandBus, QueryBus, and EventBus.
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { OrdersController } from './orders.controller';
import { CreateOrderHandler } from './create-order.handler';

@Module({
  imports: [CqrsModule],
  controllers: [OrdersController],
  providers: [CreateOrderHandler],
})
export class OrdersModule {}

One Command, One Handler

The CommandBus enforces a strict one-to-one mapping: a single command type resolves to a single handler. This is the core distinction from events.

  • Commands express an instruction that may be rejected and have exactly one handler.
  • Events announce that something already happened and may have zero or many handlers.

If you register two handlers for the same command, the last one registered wins and the bus silently overrides the earlier binding, which is almost always a bug.

Returning Results from a Command

Strict CQRS purists return nothing from a command. In practice, returning a small acknowledgment such as the new aggregate id is pragmatic and common in NestJS APIs.

CommandBus.execute<TCommand, TResult>() is generic, so you can type the return value precisely. Keep the payload tiny: an id, a status, never a full read model. If the client needs the whole resource, it issues a separate query afterward.

const result = await this.commandBus.execute<
  CreateOrderCommand,
  { orderId: string }
>(new CreateOrderCommand(customerId, items, currency));

return { id: result.orderId };

Validation Belongs at the Edge

Keep handlers focused on business behavior. Structural validation (required fields, types, formats) belongs on the incoming DTO using class-validator and the global ValidationPipe, before a command is ever constructed.

Business invariants that need data from the database, such as 'customer must not exceed credit limit', belong inside the handler where you have access to repositories.

import { IsArray, IsString, Length } from 'class-validator';

export class CreateOrderDto {
  @IsString()
  customerId: string;

  @IsArray()
  items: { sku: string; quantity: number }[];

  @IsString()
  @Length(3, 3)
  currency: string;
}

Composing Side Effects in a Handler

A handler typically orchestrates several steps: load state, mutate it, persist, then publish domain events. Injected providers make this clean and testable.

Here the handler persists the order and then publishes an event so other parts of the system can react asynchronously, keeping the write path decoupled from downstream concerns like email or analytics.

@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler
  implements ICommandHandler<CreateOrderCommand>
{
  constructor(
    private readonly orders: OrderRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: CreateOrderCommand) {
    const order = Order.create(command.customerId, command.items);
    await this.orders.save(order);
    this.eventBus.publish(new OrderCreatedEvent(order.id));
    return { orderId: order.id };
  }
}

Testing the Pure Logic

Because a command is a plain immutable object, the dispatch-and-handle pattern is easy to reason about. The snippet below is framework-free: it models a tiny command bus, registers a handler, and dispatches a command, demonstrating the exact one-command-one-handler contract you saw earlier.

type Handler<C, R> = (command: C) => R;

class MiniCommandBus {
  private handlers = new Map<string, Handler<any, any>>();

  register<C, R>(name: string, handler: Handler<C, R>): void {
    this.handlers.set(name, handler);
  }

  execute<R>(name: string, command: unknown): R {
    const handler = this.handlers.get(name);
    if (!handler) throw new Error(`No handler for ${name}`);
    return handler(command);
  }
}

class CreateOrderCommand {
  constructor(public readonly customerId: string) {}
}

const bus = new MiniCommandBus();
bus.register('CreateOrder', (c: CreateOrderCommand) => ({
  orderId: 'ord_' + c.customerId,
}));

const result = bus.execute<{ orderId: string }>(
  'CreateOrder',
  new CreateOrderCommand('42'),
);
console.log(result.orderId);

Error Handling and Idempotency

Commands can fail, and callers need a clear contract. Throw domain exceptions from the handler and let a NestJS exception filter map them to HTTP status codes.

  • Throwing inside execute() rejects the promise returned by commandBus.execute().
  • For at-least-once delivery (retries, message queues), make commands idempotent: include a client-supplied key so re-processing the same command is safe.

Never swallow errors silently in a handler; an undelivered write looks like success to the client.

Quick Check

Test your understanding of the command dispatch model.

Recap

You now know how to model write operations as commands in NestJS CQRS:

  • A command is an immutable DTO expressing an intent to change state, named imperatively.
  • A handler implements ICommandHandler and is bound via @CommandHandler; it holds the business logic and injected dependencies.
  • The CommandBus dispatches each command to its single handler; controllers stay thin and never call handlers directly.
  • Import CqrsModule and register handlers in providers so the bus can discover them.
  • Structural validation lives on the DTO at the edge; business invariants live in the handler. Return only a tiny acknowledgment, and make commands idempotent when delivery may retry.
免费开始

用 AI 导师学习 TypeScript — 免费

在浏览器中编写并运行真实代码,获得全天候 AI 导师的即时帮助,并在网页或应用中继续学习。

课程
20
课程
76

常见问题解答

「命令、处理器与命令总线」课时是免费的吗?

是的 — 「命令、处理器与命令总线」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 NestJS Enterprise Backend APIs 课程的其余内容,请升级到 CoddyKit PRO。 NestJS Enterprise Backend APIs 课程共包含 4 节课。

「命令、处理器与命令总线」这节课中我会学到什么?

将写入操作建模为命令,并通过 CommandBus 分发给专用处理器。 你通过在浏览器中直接运行的动手代码来练习 NestJS Enterprise Backend APIs,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 NestJS Enterprise Backend APIs 需要有经验吗?

无需任何先前经验。CoddyKit 上的 NestJS Enterprise Backend APIs 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。

「命令、处理器与命令总线」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 NestJS Enterprise Backend APIs 课中编写并运行代码吗?

能。每节 NestJS Enterprise Backend APIs 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 命令、处理器与命令总线
  2. 查询与读取模型投影
  3. 领域事件与 AggregateRoot
  4. 使用 Saga 处理长时间运行的工作流
← 返回 NestJS Enterprise Backend APIs