Skip to content

Comandos y consultas ​

Cuatro ficheros por caso de uso, siempre los mismos. Quien entiende uno entiende todos.

create-product/
  create-product-dto.ts              el contrato de transporte
  create-product.command.ts          el comando
  create-product.command-handler.ts  donde se conduce el dominio
  create-product.service.ts          lo que llama el controlador
  index.ts

El DTO ​

ts
export class CreateProductDto {
  @IsString() name: string;
  @IsString() description: string;
  @IsNumber() price: number;
}

Los decoradores no son decoración

Si tu main.ts instala ValidationPipe({ whitelist: true }) —y debería—, whitelist conserva únicamente las propiedades que llevan un decorador de class-validator.

Un DTO sin ninguno se reduce a {} y el handler recibe un objeto vacío. El síntoma es un 500 desconcertante en un endpoint cuyo cuerpo se ve perfectamente bien en el curl.

Los decoradores declaran estructura: que el campo esté y sea del tipo correcto. No repiten las invariantes del dominio a propósito — que Name tenga entre 3 y 100 caracteres es asunto del agregado, y se cumple llegue el dato por HTTP, por una cola o por un script de migración.

El servicio ​

Existe para que el controlador dependa de una cosa por caso de uso, y no del bus:

ts
@Injectable()
export class CreateProductService {
  constructor(private readonly commandBus: CommandBus) {}

  async execute(dto: CreateProductDto): Promise<string> {
    return this.commandBus.execute(new CreateProductCommand(dto));
  }
}

El handler ​

ts
@CommandHandler(CreateProductCommand)
export class CreateProductCommandHandler
  implements ICommandHandler<CreateProductCommand, string>
{
  constructor(
    private readonly publisher: EventPublisher,
    private readonly repository: ProductRepository,
  ) {}

  async execute(command: CreateProductCommand): Promise<string> {
    const product = Product.create(
      Name.create(command.name),
      Description.create(command.description),
      Price.create(command.price),
    );

    if (!product.isValid) {
      throw new BrokenRulesException('Product', product.brokenRules.getBrokenRules());
    }

    await this.repository.save(product);
    this.publisher.mergeObjectContext(product).commit();

    return product.id.getValue();
  }
}

Las tres líneas que hay que mirar dos veces:

Si falta
if (!product.isValid)Guardas un objeto que incumple sus propias invariantes, sin error
BrokenRulesException, no new Error(...)Las reglas se aplanan a texto y el filtro no puede responder 422: el cliente recibe un 500 pelado
.commit()El comando triunfa y todos los manejadores de eventos se saltan en silencio

Consultas ​

Las lecturas van por su propio camino, con DTOs propios, para que la forma de lectura pueda diferir de la del dominio sin arrastrar a nadie:

ts
@QueryHandler(GetProductsQuery)
export class GetProductsQueryHandler implements IQueryHandler<GetProductsQuery> {
  async execute(query: GetProductsQuery): Promise<ProductResponseDto[]> {
    const products = await this.repository.findAll();
    return products.map(ProductResponseDto.fromDomain);
  }
}

Una consulta no debe cambiar estado ni emitir eventos. Si te hace falta, es un comando.

Siguiente ​

Publicado con licencia MIT. Hecho en Perú 🇵🇪 · Impulsado por BeyondNetCode