Skip to content
KheAi
Go back

CQRS - The Enterprise Request Pipeline

Edit page

What This Part Covers


Meteor Equivalent

// Meteor Methods: the write path
Meteor.methods({
  async createTask(text) {
    if (!this.userId) throw new Meteor.Error("not-authorized");
    if (!text) throw new Meteor.Error("text-required");
    return await TasksCollection.insertAsync({
      text,
      userId: this.userId,
      createdAt: new Date(),
    });
  },
  async deleteTask(taskId) {
    const task = await TasksCollection.findOneAsync(taskId);
    if (task.userId !== this.userId) throw new Meteor.Error("not-authorized");
    return await TasksCollection.removeAsync(taskId);
  },
});

// Meteor Publications: the read path
Meteor.publish("tasks", function () {
  if (!this.userId) return this.ready();
  return TasksCollection.find({ userId: this.userId });
});
// Enterprise NestJS: CQRS separates writes from reads
// Write path:
@CommandHandler(CreateOneTodoCommand)
class CreateOneTodoCommandHandler {
  async execute(command: CreateOneTodoCommand) {
    return this.todoService.createOne(command.args);
  }
}

// Read path:
@QueryHandler(FindManyTodoQuery)
class FindManyTodoQueryHandler {
  async execute(query: FindManyTodoQuery) {
    return this.todoService.findMany(query.args);
  }
}

1. The Problem With Meteor Methods at Scale

In Meteor, methods are where everything happens. They handle authentication, validation, business logic, and database access — all in one function body. This works for small apps. It breaks badly as the app grows.

Consider a createTask method after a year of feature additions:

Meteor.methods({
  async createTask(text, projectId, assigneeId, dueDate, priority, tags) {
    // Auth check
    if (!this.userId) throw new Meteor.Error("not-authorized");

    // Validation
    check(text, String);
    if (text.length > 500) throw new Meteor.Error("text-too-long");

    // Business rule: does the project exist?
    const project = await ProjectsCollection.findOneAsync(projectId);
    if (!project) throw new Meteor.Error("project-not-found");

    // Business rule: is the user a member of the project?
    if (!project.memberIds.includes(this.userId))
      throw new Meteor.Error("not-member");

    // Business rule: assignee must be a project member too
    if (assigneeId && !project.memberIds.includes(assigneeId)) {
      throw new Meteor.Error("assignee-not-member");
    }

    // Side effect: create a notification
    await NotificationsCollection.insertAsync({
      userId: assigneeId,
      message: `New task: ${text}`,
    });

    // Side effect: send an email
    Email.send({ to: assignee.email, subject: "New task assigned" });

    // Finally: insert
    return await TasksCollection.insertAsync({
      text,
      projectId,
      assigneeId,
      dueDate,
      priority,
      tags,
      userId: this.userId,
      createdAt: new Date(),
    });
  },
});

Problems:


2. What CQRS Solves

CQRS — Command Query Responsibility Segregation — separates writes (Commands) from reads (Queries). Instead of one createTask method that does everything, you have:

The flow:

Resolver (HTTP/GraphQL layer)
    │ dispatches

CommandBus.execute(new CreateOneTodoCommand({ input }))
    │ routes to

CreateOneTodoCommandHandler.execute(command)
    │ delegates to

TodoService.createOne(command.args)
    │ queries

TypeORM Repository → PostgreSQL

Every layer has one job. Every layer is independently testable.


3. The Full Request Lifecycle (Annotated)

Let’s trace a createTodo mutation from the client to the database and back:

GraphQL Client sends:
  POST /graphql
  { mutation: createTodo(input: { text: "Buy milk" }) { id text } }
Step 1: Apollo Server receives the request
  → Routes to TodoResolver.createTodo()
Step 2: Guards run (before the resolver method)
  → @UseGuards(AuthJwtGuard) validates the Bearer JWT
  → If invalid/missing: returns 401 UNAUTHORIZED
  → If valid: attaches user to req.user
Step 3: Pipes run (before the resolver method)
  → ValidationPipe runs class-validator on CreateTodoInput
  → @IsNotEmpty() on text — fails if empty → 400 BAD REQUEST
  → @IsString() on text — fails if not string → 400 BAD REQUEST
Step 4: Resolver method executes
  → @CurrentUser() extracts currentUser from req.user (set by guard)
  → @Args('input') extracts validated CreateTodoInput
  → Merges: { ...input, userId: currentUser.user.id }
  → Dispatches: commandBus.execute(new CreateOneTodoCommand({ input }))
Step 5: CommandBus routes to the registered handler
  → @CommandHandler(CreateOneTodoCommand) decorator registered this handler
  → NestJS finds CreateOneTodoCommandHandler
  → Calls handler.execute(command)
Step 6: Handler delegates to service (one line)
  → return this.todoService.createOne(command.args)
Step 7: Service executes business logic
  → Checks: does a todo with this text already exist for this user?
  → Creates: repo.create({ text, isChecked: false, userId, status: ACTIVE })
  → Saves: await repo.save(todo)
  → Returns: { success: true, data: savedTodo }
Step 8: Result bubbles back up
  → Handler returns { success: true, data: TodoEntity }
  → CommandBus returns it to the resolver
  → Resolver returns data as TodoDto
  → Apollo serializes { id, text, isChecked, createdAt } to JSON
Client receives:
  { "data": { "createTodo": { "id": 1, "text": "Buy milk", "isChecked": false } } }

The key insight: each step only knows about its own responsibility. The resolver doesn’t know how todos are created. The handler doesn’t know about validation. The service doesn’t know about JWT or GraphQL. This separation is what makes the system testable, maintainable, and refactorable.


4. nestjs-typed-cqrs — Type-Safe Commands and Queries

Vanilla @nestjs/cqrs has a serious weakness: queryBus.execute() and commandBus.execute() return any. TypeScript cannot check that your handler returns the right shape.

// Vanilla CQRS — returns any
const result = await this.queryBus.execute(new FindOneTodoQuery({ id: 1 }));
result.data; // TypeScript: any — no type checking, no autocomplete
result.daat; // TypeScript: still OK (typo goes undetected)

nestjs-typed-cqrs adds type-safety through generic parameters:

// With nestjs-typed-cqrs — fully typed
const result = await this.queryBus.execute(
  new FindOneTodoQuery({ query: { filter: { id: { eq: 1 } } } })
);
result.data; // TypeScript: TodoEntity — full autocomplete
result.daat; // TypeScript ERROR: Property 'daat' does not exist

How Typed CQRS Works

// The query class encodes its own return type
export class FindOneTodoQuery extends AbstractCqrsQueryInput<
  TodoEntity, // entity type
  undefined, // filter type (undefined = use default)
  RecordQueryWithJoinOptions, // options type
  TodoEntity // ← return type
> {}

// The handler's return type is INFERRED from the query class
export class FindOneTodoQueryHandler implements IInferredQueryHandler<FindOneTodoQuery> {
  async execute(
    query: FindOneTodoQuery
  ): Promise<QueryResult<FindOneTodoQuery>> {
    // QueryResult<FindOneTodoQuery> resolves to { success: boolean, data: TodoEntity }
    return this.todoService.findOne(query.args);
  }
}

At the call site:

const { data } = await this.queryBus.execute(new FindOneTodoQuery({ ... }));
//      ^^^^ TypeScript knows this is TodoEntity | null

5. CQRS Input Classes (Commands & Queries)

Create apps/api/src/modules/todo/cqrs/todo.cqrs.input.ts:

import { Query } from "@ptc-org/nestjs-query-core";
import {
  AbstractCqrsCommandInput,
  AbstractCqrsQueryInput,
  RecordMutateOptions,
  RecordQueryWithJoinOptions,
} from "nestjs-typed-cqrs";

import { CreateTodoInput, UpdateTodoInput } from "../dto/todo.input";
import { TodoEntity } from "../todo.entity";

/**
 * QUERIES (read operations)
 */

// Find a single todo by filter (e.g., by id)
export class FindOneTodoQuery extends AbstractCqrsQueryInput<
  TodoEntity,
  undefined,
  RecordQueryWithJoinOptions,
  TodoEntity // ← returns one entity (or null)
> {}

// Find many todos by filter (e.g., all todos for a user)
export class FindManyTodoQuery extends AbstractCqrsQueryInput<
  TodoEntity,
  undefined,
  RecordQueryWithJoinOptions,
  TodoEntity[] // ← returns an array
> {}

// Count todos matching a filter
export class CountTodoQuery extends AbstractCqrsQueryInput<
  TodoEntity,
  Query<TodoEntity>["filter"],
  undefined,
  number // ← returns a count
> {}

/**
 * COMMANDS (write operations)
 */

// Create one todo
export class CreateOneTodoCommand extends AbstractCqrsCommandInput<
  TodoEntity,
  CreateTodoInput & { userId: number } // ← input type (userId added server-side)
> {}

// Update one todo
export class UpdateOneTodoCommand extends AbstractCqrsCommandInput<
  TodoEntity,
  UpdateTodoInput,
  true, // ← isUpdateOne = true
  RecordMutateOptions,
  { before: TodoEntity; updated: TodoEntity } // ← returns before and after
> {}

// Delete one todo
export class DeleteOneTodoCommand extends AbstractCqrsCommandInput<
  TodoEntity,
  number // ← input is just the id
> {}

Reading the generic type parameters:

For AbstractCqrsQueryInput<Entity, FilterType, OptionsType, ReturnType>:

For AbstractCqrsCommandInput<Entity, InputType, isUpdateOne?, OptionsType?, ReturnType?>:


6. CQRS Index (cqrs/index.ts)

// apps/api/src/modules/todo/cqrs/index.ts
import {
  CountTodoQueryHandler,
  CreateOneTodoCommandHandler,
  DeleteOneTodoCommandHandler,
  FindManyTodoQueryHandler,
  FindOneTodoQueryHandler,
  UpdateOneTodoCommandHandler,
} from "./todo.cqrs.handler";

// Arrays spread into module providers
export const TodoQueryHandlers = [
  FindOneTodoQueryHandler,
  FindManyTodoQueryHandler,
  CountTodoQueryHandler,
];

export const TodoCommandHandlers = [
  CreateOneTodoCommandHandler,
  UpdateOneTodoCommandHandler,
  DeleteOneTodoCommandHandler,
];

export const TodoEventHandlers = [];

// Re-export inputs so other files can import from './cqrs' (one import path)
export * from "./todo.cqrs.input";

Why the index pattern? The module file spreads these arrays into providers:

// In apps/api/src/modules/todo/todo.module.ts:
providers: [
  TodoResolver,
  TodoService,
  ...TodoQueryHandlers, // spreads all query handlers
  ...TodoCommandHandlers, // spreads all command handlers
];

When you add a new handler, you add it to the array in index.ts. The module and the bus registration update automatically.


7. CQRS Handlers (cqrs/todo.cqrs.handler.ts)

Handlers are always one-liners. This is not laziness — it is a design rule.

// apps/api/src/modules/todo/cqrs/todo.cqrs.handler.ts
import {
  CommandHandler,
  IInferredCommandHandler,
  IInferredQueryHandler,
  QueryHandler,
} from "@nestjs/cqrs";
import { CommandResult, QueryResult } from "@nestjs-architects/typed-cqrs";
import { TodoService } from "../todo.service";
import {
  CountTodoQuery,
  CreateOneTodoCommand,
  DeleteOneTodoCommand,
  FindManyTodoQuery,
  FindOneTodoQuery,
  UpdateOneTodoCommand,
} from "./todo.cqrs.input";

// ── Query Handlers ──────────────────────────────────────────

@QueryHandler(FindOneTodoQuery)
export class FindOneTodoQueryHandler implements IInferredQueryHandler<FindOneTodoQuery> {
  constructor(readonly service: TodoService) {}
  async execute(
    query: FindOneTodoQuery
  ): Promise<QueryResult<FindOneTodoQuery>> {
    return this.service.findOneTodo(query.args); // one line — delegate to service
  }
}

@QueryHandler(FindManyTodoQuery)
export class FindManyTodoQueryHandler implements IInferredQueryHandler<FindManyTodoQuery> {
  constructor(readonly service: TodoService) {}
  async execute(
    query: FindManyTodoQuery
  ): Promise<QueryResult<FindManyTodoQuery>> {
    return this.service.findManyTodo(query.args);
  }
}

@QueryHandler(CountTodoQuery)
export class CountTodoQueryHandler implements IInferredQueryHandler<CountTodoQuery> {
  constructor(readonly service: TodoService) {}
  async execute(query: CountTodoQuery): Promise<QueryResult<CountTodoQuery>> {
    return this.service.countTodo(query.args);
  }
}

// ── Command Handlers ────────────────────────────────────────

@CommandHandler(CreateOneTodoCommand)
export class CreateOneTodoCommandHandler implements IInferredCommandHandler<CreateOneTodoCommand> {
  constructor(readonly service: TodoService) {}
  async execute(
    command: CreateOneTodoCommand
  ): Promise<CommandResult<CreateOneTodoCommand>> {
    return this.service.createOneTodo(command.args);
  }
}

@CommandHandler(UpdateOneTodoCommand)
export class UpdateOneTodoCommandHandler implements IInferredCommandHandler<UpdateOneTodoCommand> {
  constructor(readonly service: TodoService) {}
  async execute(
    command: UpdateOneTodoCommand
  ): Promise<CommandResult<UpdateOneTodoCommand>> {
    return this.service.updateOneTodo(command.args);
  }
}

@CommandHandler(DeleteOneTodoCommand)
export class DeleteOneTodoCommandHandler implements IInferredCommandHandler<DeleteOneTodoCommand> {
  constructor(readonly service: TodoService) {}
  async execute(
    command: DeleteOneTodoCommand
  ): Promise<CommandResult<DeleteOneTodoCommand>> {
    return this.service.deleteOneTodo(command.args);
  }
}

Every handler follows the exact same pattern:

  1. @QueryHandler(TheQuery) or @CommandHandler(TheCommand) — registers with the bus
  2. implements IInferredQueryHandler<TheQuery> — TypeScript enforces correct return type
  3. Constructor injects the service
  4. execute() — exactly one line: return this.service.methodName(query.args)

The thin handler rule: If you find yourself writing logic in a handler (if statements, repository calls, calculations), you are doing it wrong. Move it to the service.


8. The Service

The service is where work actually happens. All business rules, all database access, all side effects.

TodoService extends TypeOrmQueryService<TodoEntity> from @ptc-org/nestjs-query-typeorm (the public API). This does three things at once:

The constructor only needs @InjectRepository to inject the repository from NestJS DI and pass it to super(). No NestjsQueryTypeOrmModule, no @InjectQueryService, no manual FilterQueryBuilder instantiation.

// apps/api/src/modules/todo/todo.service.ts
import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { TypeOrmQueryService } from "@ptc-org/nestjs-query-typeorm";
import { CqrsCommandFunc, CqrsQueryFunc } from "nestjs-typed-cqrs";
import { Repository } from "typeorm";

import {
  CountTodoQuery,
  CreateOneTodoCommand,
  DeleteOneTodoCommand,
  FindManyTodoQuery,
  FindOneTodoQuery,
  UpdateOneTodoCommand,
} from "./cqrs/todo.cqrs.input";
import { TodoEntity } from "./todo.entity";

@Injectable()
export class TodoService extends TypeOrmQueryService<TodoEntity> {
  constructor(
    @InjectRepository(TodoEntity)
    repo: Repository<TodoEntity>, // no `private readonly` — parent sets this.repo
  ) {
    super(repo); // sets this.repo and this.filterQueryBuilder via TypeOrmQueryService
  }

  // Find one record
  findOneTodo: CqrsQueryFunc<FindOneTodoQuery, FindOneTodoQuery["args"]> =
    async ({ query, options }) => {
      const nullable = options?.nullable ?? true;

      try {
        const builder = this.filterQueryBuilder.select(query);
        const result = await builder.getOne();

        if (!nullable && !result) {
          throw new Error("Todo not found");
        }

        return { success: true, data: result ?? undefined };
      } catch (e) {
        throw new BadRequestException(e.message);
      }
    };

  // Find many records (for paginated list queries)
  findManyTodo: CqrsQueryFunc<FindManyTodoQuery, FindManyTodoQuery["args"]> =
    async ({ query }) => {
      try {
        const builder = this.filterQueryBuilder.select(query);
        const results = await builder.getMany();
        return { success: true, data: results };
      } catch (e) {
        throw new BadRequestException(e.message);
      }
    };

  // Count records (needed for cursor pagination's totalCount)
  countTodo: CqrsQueryFunc<CountTodoQuery, CountTodoQuery["args"]> = async ({
    query,
  }) => {
    try {
      const count = await this.filterQueryBuilder
        .select({ filter: query })
        .getCount();
      return { success: true, data: count };
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };

  // Create one record
  createOneTodo: CqrsCommandFunc<
    CreateOneTodoCommand,
    CreateOneTodoCommand["args"]
  > = async ({ input }) => {
    try {
      // Business rule: no duplicate text per user
      const existing = await this.repo.findOne({
        where: { text: input.text, userId: input.userId },
      });
      if (existing) {
        throw new Error("You already have a todo with that text");
      }

      const todo = this.repo.create(input);
      const data = await this.repo.save(todo);
      return { success: true, data };
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };

  // Update one record
  updateOneTodo: CqrsCommandFunc<
    UpdateOneTodoCommand,
    UpdateOneTodoCommand["args"]
  > = async ({ query, input }) => {
    try {
      const builder = this.filterQueryBuilder.select(query);
      const before = await builder.getOne();
      if (!before) throw new NotFoundException("Todo not found");

      const updated = await this.repo.save({ ...before, ...input });
      return { success: true, data: { before, updated } };
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };

  // Delete one record
  deleteOneTodo: CqrsCommandFunc<
    DeleteOneTodoCommand,
    DeleteOneTodoCommand["args"]
  > = async ({ input: id }) => {
    try {
      const todo = await this.repo.findOne({ where: { id } });
      if (!todo) throw new NotFoundException("Todo not found");
      await this.repo.remove(todo);
      return { success: true, data: todo };
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };
}

9. The 9-Step Module Pattern

Every feature module follows this exact checklist. Memorise it.

Step 1: Entity         → extends AbstractEntity, define columns, relations, indexes
Step 2: Constants      → enums + registerEnumType
Step 3: DTOs           → @ObjectType (read), @InputType (write), @ArgsType (list query)
Step 4: CQRS Inputs    → AbstractCqrsQueryInput / AbstractCqrsCommandInput classes
Step 5: CQRS Handlers  → one-liner delegation to service, thin always
Step 6: CQRS Index     → export handler arrays + re-export inputs
Step 7: Service        → business logic, repository operations
Step 8: Resolver       → GraphQL endpoints, @UseGuards, @CurrentUser
Step 9: Module         → TypeOrmModule.forFeature([Entity]) + providers spread
        Register       → add Module to AppModule, add Entity to AppModule's entities[]
        Migrate        → generate → review SQL → run → verify in Adminer
        Test           → unit tests for service + handlers (Part 10)

You will run through this checklist completely in Part 08 (Tag module) and Part 09 (Todo module).


10. The Module File

// apps/api/src/modules/todo/todo.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";

import { TodoEntity } from "./todo.entity";
import { TodoResolver } from "./todo.resolver";
import { TodoService } from "./todo.service";
import {
  TodoCommandHandlers,
  TodoEventHandlers,
  TodoQueryHandlers,
} from "./cqrs";

@Module({
  imports: [
    TypeOrmModule.forFeature([TodoEntity]), // makes Repository<TodoEntity> injectable in this module
    // CqrsModule is NOT imported here — CqrsModule.forRoot() in AppModule registers the buses globally
  ],
  providers: [
    TodoResolver,
    TodoService,
    ...TodoQueryHandlers, // spreads all query handlers
    ...TodoCommandHandlers, // spreads all command handlers
    ...TodoEventHandlers, // spreads all event handlers (empty for now)
  ],
  exports: [TodoService], // export if other modules need to call TodoService
})
export class TodoModule {}

Register in AppModule. This is the complete, working app.module.ts after adding the Todo module:

// apps/api/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { CqrsModule } from '@nestjs/cqrs';
import { AppResolver } from './app.resolver';
import { HealthModule } from '../modules/health/health.module';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
import { TodoModule } from '../modules/todo/todo.module';
import { TodoEntity } from '../modules/todo/todo.entity';
import { UserEntity } from '../modules/user/user.entity';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),

    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get('PROJECT_DB_HOST'),
        port: config.get<number>('PROJECT_DB_PORT'),
        username: config.get('PROJECT_DB_USERNAME'),
        password: config.get('PROJECT_DB_PASSWORD'),
        database: config.get('PROJECT_DB_DATABASE'),
        entities: [TodoEntity, UserEntity],
        synchronize: false,
        logging: config.get('PROJECT_DB_DEBUG') === 'true',
        namingStrategy: new SnakeNamingStrategy(),
      }),
    }),

    GraphQLModule.forRootAsync<ApolloDriverConfig>({
      driver: ApolloDriver,
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        autoSchemaFile: true,
        playground: config.get('PROJECT_GRAPHQL_PLAYGROUND') === 'true',
        context: ({ req }) => ({ req }),
      }),
    }),

    CqrsModule.forRoot(),  // registers CommandBus, QueryBus, EventBus globally
    HealthModule,
    TodoModule,
  ],
  providers: [AppResolver],
})
export class AppModule {}

Two things to understand here:

1. entities: [TodoEntity, UserEntity] — explicit, not a glob

You might expect to use a glob pattern like __dirname + '/**/*.entity{.ts,.js}' to auto-discover entities. That works with ts-node directly, but this project uses Webpack, which compiles everything into a single main.js. At runtime there are no separate .entity.js files on disk — the glob finds nothing. TypeORM silently boots with zero entities, and then crashes the moment a query touches any table.

Rule: every entity in every future module must be explicitly imported and listed in AppModule’s entities[].

2. UserEntity is required even though there is no UserModule yet

TodoEntity declares @ManyToOne(() => UserEntity). TypeORM needs every entity in a relationship to be registered in the same DataSource — even if you’re not querying UserEntity directly. Omitting it produces EntityMetadataNotFoundError: No metadata for "UserEntity" was found at startup.

3. CqrsModule.forRoot() here, not in feature modules

CqrsModule.forRoot() registers CommandBus, QueryBus, and EventBus as global providers. This is why TodoModule does not import CqrsModule — the buses are already in scope for the entire application once AppModule calls forRoot(). Feature modules that import plain CqrsModule (without forRoot) will get a separate, isolated bus instance that has no handlers registered — a subtle bug that is hard to diagnose.


11. Naming Conventions

Every name in the CQRS layer follows a strict convention. Consistency means any developer can find any file in any module without looking.

TypePatternExample
Query classFindOne<Entity>QueryFindOneTodoQuery
Query class (many)FindMany<Entity>QueryFindManyTodoQuery
Query class (count)Count<Entity>QueryCountTodoQuery
Command class (create)CreateOne<Entity>CommandCreateOneTodoCommand
Command class (update)UpdateOne<Entity>CommandUpdateOneTodoCommand
Command class (delete)DeleteOne<Entity>CommandDeleteOneTodoCommand
Query handlerFindOne<Entity>QueryHandlerFindOneTodoQueryHandler
Command handlerCreateOne<Entity>CommandHandlerCreateOneTodoCommandHandler
Service method (find one)findOne<Entity>findOneTodo
Service method (find many)findMany<Entity>findManyTodo
Service method (count)count<Entity>countTodo
Service method (create)createOne<Entity>createOneTodo
Service method (update)updateOne<Entity>updateOneTodo
Service method (delete)deleteOne<Entity>deleteOneTodo
Event<Entity><Action>EventTodoCreatedEvent
Event handler<Entity><Action>EventHandlerTodoCreatedEventHandler

12. CQRS Events (Advanced)

Commands can emit events after execution. Events are processed asynchronously by event handlers.

// An event class
export class TodoCreatedEvent {
  constructor(public readonly todo: TodoEntity) {}
}

// In the command handler (after create):
@CommandHandler(CreateOneTodoCommand)
export class CreateOneTodoCommandHandler {
  constructor(
    readonly service: TodoService,
    private readonly eventBus: EventBus
  ) {}

  async execute(command: CreateOneTodoCommand) {
    const result = await this.service.createOne(command.args);
    // Emit an event — processed asynchronously
    this.eventBus.publish(new TodoCreatedEvent(result.data));
    return result;
  }
}

// An event handler (does side effects: email, notification, etc.)
@EventsHandler(TodoCreatedEvent)
export class TodoCreatedEventHandler implements IEventHandler<TodoCreatedEvent> {
  async handle(event: TodoCreatedEvent) {
    // Send email notification, update counters, etc.
    // This runs asynchronously — the original mutation already returned
    console.log(`Todo created: ${event.todo.text}`);
  }
}

Events are used for side effects that should not block the primary operation. Use them for: sending emails, creating notifications, updating analytics, triggering background jobs. You will implement this pattern with Bull queues in Part 11.


13. Troubleshooting: reflect-metadata Version Conflict

If the application throws this error on startup:

UnknownDependenciesException: Nest can't resolve dependencies of the ConfigService (?).
  Symbol(CONFIG_SERVICE) at index [0] in ConfigModule module.

The root cause is almost certainly a reflect-metadata version conflict.

NestJS decorators (@Module, @Injectable, etc.) store metadata using reflect-metadata. Two versions of this library coexist in the dependency tree — v0.1.x (older) and v0.2.x (newer, bundled inside typeorm, nestjs-dev-utilities, and @ptc-org/nestjs-query-*). Each version maintains its own internal WeakMap. When v0.2.x loads, it overwrites the global Reflect methods to point at its own empty WeakMap. Any metadata written earlier by v0.1.x is now inaccessible. NestJS’s module scanner calls Reflect.getMetadata('imports', ConfigModule) — it now hits v0.2.x’s empty map, gets undefined, never discovers ConfigHostModule, and the DI lookup for ConfigService fails.

The fix — upgrade the root reflect-metadata to v0.2.x so yarn (or npm) deduplicates all nested copies down to one version:

// package.json
"dependencies": {
  "reflect-metadata": "^0.2.2",  // was ^0.1.13 — upgrade to force deduplication
  ...
}

Then reinstall:

yarn install

Verify there is only one copy:

find node_modules -path "*/reflect-metadata/package.json" | xargs grep '"version"'
# Should show only one result — the root node_modules copy

@nestjs/common declares "peerDependencies": { "reflect-metadata": "^0.1.12 || ^0.2.0" } so both versions are supported; upgrading is safe.


Summary

You now understand the full CQRS pipeline:

ConcernLayerRule
HTTP/GraphQL routing, auth, input extractionResolverNo business logic, no DB access
Message dispatchCommandBus / QueryBusNever bypassed — always go through the bus
Message routingHandlerOne line: call service method
Business logic, DB access, side effectsServiceThe only place logic lives
SQL executionTypeORM RepositoryNever called from resolver or handler

The 9-step pattern:

Entity → Constants → DTOs → CQRS Inputs → CQRS Index →
CQRS Handlers → Service → Resolver → Module → Register → Migrate

In Part 06, you will learn how DTOs and resolvers work together with GraphQL and nestjs-query to give you automatic filtering, sorting, pagination, and the frontend that consumes it all.


Edit page
Share this post:

Next Post
GraphQL API + Next.js Frontend
Previous Post
Database - PostgreSQL, TypeORM, Entities & Migrations