CQRS

CQRS — Command Query Responsibility Segregation — means reads and writes travel separate paths. Commands change state and return nothing meaningful; queries read state and return DTOs. ForgeStack implements this on top of NestJS's CQRS module, with base classes that give every handler the same cross-cutting behaviour.

Commands

A command is a plain, immutable request to change something. It extends Base_Command, which carries the optional requesterUserId used for authorization:

// …/application/commands/register-user/register-user.command.ts
export class RegisterUser_Command extends Base_Command implements ICommand {
  public readonly email: string;
  public readonly password: string;

  constructor(props: { email: string; password: string }) {
    super();
    this.email = props.email;
    this.password = props.password;
  }
}

The command handler base

Handlers don't implement execute directly. They extend the Base_CommandHandler(Command) mixin and implement three methods — authorize, validate, handle. The base wraps them with a tracing span, structured logging, metrics, and a uniform try/finally:

// libs/common/src/cqrs/base.command-handler.ts
export function Base_CommandHandler<TCommand extends ICommand>(
  command: new (...args: any[]) => TCommand,
) {
  @CommandHandler(command)
  abstract class Base_CommandHandlerClass {
    @WithSpan('command.execute', { attributesFrom: ['constructor.name'] })
    async execute(command: TCommand) {
      const startTime = process.hrtime.bigint();
      let status: 'success' | 'error' = 'success';
      try {
        await this.authorize(command);
        await this.validate(command);
        return await this.handle(command);
      } catch (error) {
        status = 'error';
        throw error;
      } finally {
        const duration = Number(process.hrtime.bigint() - startTime) / 1e9;
        getCqrsMetrics()?.observeCommand(command.constructor.name, duration, status);
      }
    }

    abstract handle(command: TCommand): Promise<void>;
    abstract authorize(command: TCommand): Promise<boolean>;
    abstract validate(command: TCommand): Promise<void>;

    // helpers available to every handler:
    async sendDomainEvents<T extends SharedAggregate>(entity: T): Promise<void> { /* … */ }
    async sendIntegrationEvent<T extends Base_IntegrationEvent>(event: T, ctx): Promise<void> { /* … */ }
  }
  return Base_CommandHandlerClass;
}

A concrete handler

handle is where the use case is orchestrated: create the aggregate (the rule runs there), persist it in a transaction, then publish its domain events after the transaction commits.

// …/commands/register-user/register-user.command-handler.ts
export class RegisterUser_CommandHandler extends Base_CommandHandler(RegisterUser_Command) {
  constructor(
    @Inject(USER_REPOSITORY) private readonly userRepository: User_Repository,
    @Inject(USER_UNIQUENESS_CHECKER) private readonly uniquenessChecker: IUserUniquenessChecker,
    @Inject(EVENT_BUS) eventBus: IEventBus,
    @Inject(OUTBOX_REPOSITORY) outboxRepository: Outbox_Repository,
  ) {
    super(eventBus, outboxRepository);
  }

  async handle(command: RegisterUser_Command) {
    const user = await User.create(
      {
        email: new Email(command.email),
        password: await Password.createFromPlainText(command.password),
      },
      this.uniquenessChecker,
    );

    await Transaction.run(async (context) => {
      await this.userRepository.save(user, context);
    });

    await this.sendDomainEvents<User>(user);
  }

  async authorize(_: RegisterUser_Command) { return true; }
  async validate(_: RegisterUser_Command) {}
}

authorize → validate → handle

Splitting the three concerns keeps handlers focused. authorize answers "is the requester allowed?", validate checks the command's shape/preconditions, and handle performs the work. The base class guarantees they always run in that order.

Queries

A query reads. It extends Base_Query and is handled by a Base_QueryHandler(Query)<Response>() — note the extra type parameter for the return type, since queries (unlike commands) return data:

// libs/common/src/cqrs/base.query-handler.ts
export function Base_QueryHandler<TQuery extends IQuery>(query) {
  return function <TResult extends object>() {
    @QueryHandler(query)
    abstract class Base_QueryHandlerClass {
      @WithSpan('query.execute', { attributesFrom: ['constructor.name'] })
      async execute(query: TQuery): Promise<TResult> {
        // authorize → validate → handle, with metrics + tracing (as commands)
        await this.authorize(query);
        await this.validate(query);
        return await this.handle(query);
      }
      abstract handle(query: TQuery): Promise<TResult>;
      abstract authorize(query: TQuery): Promise<boolean>;
      abstract validate(query: TQuery): Promise<void>;
    }
    return Base_QueryHandlerClass;
  };
}

The login query reads a user, verifies the password, runs a domain check, and returns tokens — no aggregate is mutated, nothing is published:

// …/queries/get-new-tokens-from-user-credentials/…query-handler.ts
export class GetNewTokensFromUserCredentials_QueryHandler extends Base_QueryHandler(
  GetNewTokensFromUserCredentials_Query,
)<GetNewTokensFromUserCredentials_QueryResponse>() {
  async handle(query) {
    const user = await this.userRepository.findByEmail(new Email(query.email));
    if (!user) throw new UnauthorizedException();

    if (!(await user.password.verify(query.password))) throw new UnauthorizedException();
    user.canAuthenticate(); // domain rule (status checks)

    const payload: TokenPayload = {
      userId: user.id.toValue(),
      email: user.email.toValue(),
      role: user.role.toValue(),
    };
    return {
      userId: user.id.toValue(),
      accessToken: this.jwtTokenService.generateAccessToken(payload),
      refreshToken: this.jwtTokenService.generateRefreshToken(payload),
    };
  }
}

What you get for free

Because every command and query goes through these bases, you automatically get, per execution:

  • a tracing span (command.execute / query.execute) named after the class;
  • metrics — duration histogram + success/error counter (visible on the CQRS Grafana dashboard);
  • structured logs with correlation IDs;
  • a consistent authorize → validate → handle lifecycle.

The command-handler-base / query-handler-base lint rules ensure handlers actually extend these bases, and handler-naming enforces the _CommandHandler / _QueryHandler suffixes.

Next: Event-Driven Architecture.