Skip to content
KheAi
Go back

Case Study 1 - Tag Module (Complete 9-Step Build)

Edit page

In Parts 6–9 we covered CQRS, GraphQL, authentication, and extended auth. Now we apply all of it from scratch.

What This Part Covers

This is your first complete module build from scratch. You will follow every step of the 9-step pattern with nothing skipped — from empty directory to running GraphQL queries in the Playground, with unit tests passing.

Why Tags? A Tag is the simplest possible entity: no foreign keys, no auth complexity on reads, straightforward CRUD. It is the perfect pattern exercise before you tackle entities with relationships and ownership rules.

What you build:


Design Phase (Always First)

Before writing a single file, answer three questions. This is the system design habit that separates senior developers from juniors.

Q1: What does the DB table look like?

tag
├── id          SERIAL PRIMARY KEY       (from AbstractEntity)
├── name        VARCHAR NOT NULL
├── slug        VARCHAR NOT NULL UNIQUE  (url-safe identifier)
├── color       VARCHAR NOT NULL DEFAULT '#6366f1'
├── created_at  TIMESTAMPTZ              (from AbstractEntity)
└── updated_at  TIMESTAMPTZ              (from AbstractEntity)

Q2: What GraphQL operations will we expose?

# Public reads — no auth required
query tag(id: Int!): Tag
query getTags(filter: TagFilter, paging: CursorPaging, sorting: [TagSort!]): TagConnection!

# Auth-required writes
mutation createTag(input: CreateTagInput!): Tag!
mutation updateTag(id: Int!, input: UpdateTagInput!): Tag!
mutation deleteTag(id: Int!): Boolean!

Q3: Which existing module is this most like?

The notification module from the codebase: simple entity, same service pattern, same CQRS structure. Use it as your mental reference.


Step 1 — Create the Feature Branch

git checkout main
git pull
git checkout -b feat/tag-module

Step 2 — Create the File Structure

mkdir -p apps/api/src/modules/tag/cqrs
mkdir -p apps/api/src/modules/tag/dto
mkdir -p apps/api/src/modules/tag/test

Files you will create:

apps/api/src/modules/tag/
├── cqrs/
│   ├── index.ts                  ← exports handler arrays + re-exports inputs
│   ├── tag.cqrs.handler.ts       ← all handlers (thin delegation)
│   └── tag.cqrs.input.ts         ← typed Command and Query classes
├── dto/
│   ├── tag.dto.ts                ← @ObjectType — GraphQL response shape
│   ├── tag.input.ts              ← @InputType — mutation inputs
│   └── tag.query.ts              ← @ArgsType — list query args + connection
├── test/
│   ├── tag.service.spec.ts       ← unit test for TagService
│   └── tag.cqrs.spec.ts          ← unit test for handlers
├── tag.constant.ts               ← enums + registerEnumType
├── tag.entity.ts                 ← TypeORM entity
├── tag.module.ts                 ← NestJS module
├── tag.resolver.ts               ← GraphQL resolver
└── tag.service.ts                ← business logic

Step 3 — Entity (tag.entity.ts)

The entity is the source of truth for the database schema.

// apps/api/src/modules/tag/tag.entity.ts
import { Column, Entity } from 'typeorm';
import { AbstractEntity } from 'nestjs-dev-utilities';

@Entity({ name: 'tag' })
export class TagEntity extends AbstractEntity {
  @Column()
  name: string;

  @Column({ unique: true })
  slug: string;

  @Column({ default: '#6366f1' })
  color: string;
}

What AbstractEntity gives you:

Official record template: An entity is the official record template that defines every field — name, type, whether it’s required (nullable or not), any uniqueness constraints. Every row in the database is a completed record filed against that template. When you need a new field, you don’t edit old templates — you issue a new revision (migration) and all future records follow the new version.

Company letterhead: AbstractEntity is the company letterhead — every entity (letter) is printed on paper that already has id, createdAt, updatedAt, and deletedAt pre-filled. Your entity only adds its unique content. Nobody types the letterhead from scratch on each file.

From Meteor? new Mongo.Collection('tasks') is schema-less — any shape goes in. @Entity() enforces a schema at the database level AND at the TypeScript level. A field that doesn’t match the entity declaration won’t compile.

Memory hook: Entity = official record template. AbstractEntity = letterhead (id + timestamps pre-printed). Never synchronize: true in prod.

Your entity only declares the additional columns. The slug column has unique: true — PostgreSQL will enforce that no two tags share the same slug (e.g., 'work' can only exist once).


Step 4 — Constants (tag.constant.ts)

Tags do not need a complex status enum, but we add one to demonstrate the pattern:

// apps/api/src/modules/tag/tag.constant.ts
import { registerEnumType } from '@nestjs/graphql';

export enum TagStatus {
  ACTIVE = 'ACTIVE',
  ARCHIVED = 'ARCHIVED',
}

// Tell GraphQL about this enum — without this, the schema won't include it
registerEnumType(TagStatus, { name: 'TagStatus' });

Pattern: Always call registerEnumType immediately after defining an enum that will appear in a @Field. Missing this call produces a cryptic runtime error when Apollo starts.


Step 5 — DTOs

Read DTO (tag.dto.ts) — what GraphQL sends back

// apps/api/src/modules/tag/dto/tag.dto.ts
import { Field, ObjectType } from '@nestjs/graphql';
import { FilterableField } from '@ptc-org/nestjs-query-graphql';
import { AbstractDto } from 'nestjs-dev-utilities';

@ObjectType('Tag')
export class TagDto extends AbstractDto {
  // AbstractDto provides: id (Int!), createdAt, updatedAt

  @FilterableField()   // clients CAN filter: { name: { like: "%work%" } }
  name: string;

  @FilterableField()   // clients CAN filter: { slug: { eq: "work" } }
  slug: string;

  @Field()             // clients CANNOT filter by color — only returned in response
  color: string;
}

Standard response envelope: AbstractDto is the standard response envelope — every output DTO pairs with AbstractEntity and exposes id, createdAt, and updatedAt as @Field() automatically. The client always knows where to find the id and timestamps because they’re on every envelope.

From Meteor? There’s no equivalent in Meteor — response shapes were ad-hoc. AbstractDto guarantees every GraphQL type exposes the same base fields without repeating them across every DTO file.

Memory hook: AbstractDto = response envelope. Pairs with AbstractEntity. All @ObjectType DTOs extend it.

Why @Field() for color instead of @FilterableField()? Filtering by color has no business value and would allow clients to enumerate tags by color. Only add @FilterableField to fields with real filter use cases.

Input DTOs (tag.input.ts) — what clients send

// apps/api/src/modules/tag/dto/tag.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsHexColor, IsNotEmpty, IsString, Matches } from 'class-validator';

@InputType()
export class CreateTagInput {
  @Field()
  @IsString()
  @IsNotEmpty({ message: 'Tag name cannot be empty' })
  name: string;

  @Field()
  @IsString()
  @IsNotEmpty()
  @Matches(/^[a-z0-9-]+$/, {
    message: 'Slug must contain only lowercase letters, numbers, and hyphens',
  })
  slug: string;

  @Field({ nullable: true, defaultValue: '#6366f1' })
  @IsHexColor({ message: 'Color must be a valid hex color (e.g. #6366f1)' })
  color?: string;
}

@InputType()
export class UpdateTagInput {
  @Field({ nullable: true })
  @IsString()
  @IsNotEmpty()
  name?: string;

  @Field({ nullable: true })
  @IsString()
  @Matches(/^[a-z0-9-]+$/)
  slug?: string;

  @Field({ nullable: true })
  @IsHexColor()
  color?: string;
}

Why not PartialType(CreateTagInput) for UpdateTagInput? PartialType makes all fields optional but keeps them. In the pattern used here, explicit UpdateTagInput fields let you add update-specific validation (e.g., slug change requires admin role) without affecting the create path. Both approaches are valid — choose consistency within your project.

Query Args DTO (tag.query.ts) — list query with pagination

// apps/api/src/modules/tag/dto/tag.query.ts
import { ArgsType } from '@nestjs/graphql';
import { SortDirection } from '@ptc-org/nestjs-query-core';
import { PagingStrategies, QueryArgsType } from '@ptc-org/nestjs-query-graphql';
import { TagDto } from './tag.dto';

@ArgsType()
export class TagsQuery extends QueryArgsType(TagDto, {
  defaultSort: [{ field: 'createdAt', direction: SortDirection.DESC }],
  pagingStrategy: PagingStrategies.CURSOR,
  enableTotalCount: true,
}) {}

export const TagQueryConnection = TagsQuery.ConnectionType;

QueryArgsType(TagDto) automatically generates the TagFilter, TagSort, and pagination args. The ConnectionType generates the TagConnection response type with edges, pageInfo, and totalCount.


Step 6 — CQRS Inputs (cqrs/tag.cqrs.input.ts)

Two separate kitchens: CQRS splits your module into two kitchens that never share a stove. The command kitchen handles writes (CreateOneTag, UpdateOneTag, DeleteOneTag) and enforces business rules. The query kitchen handles reads (FindOneTag, FindManyTag, CountTag) and only fetches data. A waiter from the query kitchen cannot place new orders.

From Meteor? Meteor.methods({ createTask: function() { ... } }) is the method body — it is the handler AND the service AND often the repo call, all in one block. CQRS separates these into distinct typed classes, each independently testable.

Memory hook: CQRS inputs = typed envelopes. Commands mutate state. Queries read state. Two kitchens, never share a stove.

Typed message classes — the envelopes you put data into before dispatching to the bus.

// apps/api/src/modules/tag/cqrs/tag.cqrs.input.ts
import { Query } from '@ptc-org/nestjs-query-core';
import {
  AbstractCqrsCommandInput,
  AbstractCqrsQueryInput,
  RecordMutateOptions,
  RecordQueryWithJoinOptions,
} from 'nestjs-typed-cqrs';

import { CreateTagInput, UpdateTagInput } from '../dto/tag.input';
import { TagEntity } from '../tag.entity';

// ── Queries ─────────────────────────────────────────────────

export class FindOneTagQuery extends AbstractCqrsQueryInput<
  TagEntity,
  undefined,
  RecordQueryWithJoinOptions,
  TagEntity        // returns one entity (or null)
> {}

export class FindManyTagQuery extends AbstractCqrsQueryInput<
  TagEntity,
  undefined,
  RecordQueryWithJoinOptions,
  TagEntity[]      // returns an array
> {}

export class CountTagQuery extends AbstractCqrsQueryInput<
  TagEntity,
  Query<TagEntity>['filter'],
  undefined,
  number           // returns a count
> {}

// ── Commands ─────────────────────────────────────────────────

export class CreateOneTagCommand extends AbstractCqrsCommandInput<
  TagEntity,
  CreateTagInput
> {}

export class UpdateOneTagCommand extends AbstractCqrsCommandInput<
  TagEntity,
  UpdateTagInput,
  true,              // isUpdateOne = true (has query + input)
  RecordMutateOptions,
  { before: TagEntity; updated: TagEntity }
> {}

export class DeleteOneTagCommand extends AbstractCqrsCommandInput<
  TagEntity,
  number             // input is just the id
> {}

Step 7 — CQRS Index (cqrs/index.ts)

// apps/api/src/modules/tag/cqrs/index.ts
import {
  CountTagQueryHandler,
  CreateOneTagCommandHandler,
  DeleteOneTagCommandHandler,
  FindManyTagQueryHandler,
  FindOneTagQueryHandler,
  UpdateOneTagCommandHandler,
} from './tag.cqrs.handler';

export const TagQueryHandlers = [
  FindOneTagQueryHandler,
  FindManyTagQueryHandler,
  CountTagQueryHandler,
];

export const TagCommandHandlers = [
  CreateOneTagCommandHandler,
  UpdateOneTagCommandHandler,
  DeleteOneTagCommandHandler,
];

export const TagEventHandlers = [];

// Re-export inputs — other files import from './cqrs' (one path)
export * from './tag.cqrs.input';

Step 8 — CQRS Handlers (cqrs/tag.cqrs.handler.ts)

Always one line. No logic.

// apps/api/src/modules/tag/cqrs/tag.cqrs.handler.ts
import { CommandHandler, IInferredCommandHandler, IInferredQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { CommandResult, QueryResult } from 'nestjs-typed-cqrs';
import { TagService } from '../tag.service';
import {
  CountTagQuery,
  CreateOneTagCommand,
  DeleteOneTagCommand,
  FindManyTagQuery,
  FindOneTagQuery,
  UpdateOneTagCommand,
} from './tag.cqrs.input';

@QueryHandler(FindOneTagQuery)
export class FindOneTagQueryHandler implements IInferredQueryHandler<FindOneTagQuery> {
  constructor(readonly service: TagService) {}
  async execute(query: FindOneTagQuery): Promise<QueryResult<FindOneTagQuery>> {
    return this.service.findOne(query.args);
  }
}

@QueryHandler(FindManyTagQuery)
export class FindManyTagQueryHandler implements IInferredQueryHandler<FindManyTagQuery> {
  constructor(readonly service: TagService) {}
  async execute(query: FindManyTagQuery): Promise<QueryResult<FindManyTagQuery>> {
    return this.service.findMany(query.args);
  }
}

@QueryHandler(CountTagQuery)
export class CountTagQueryHandler implements IInferredQueryHandler<CountTagQuery> {
  constructor(readonly service: TagService) {}
  async execute(query: CountTagQuery): Promise<QueryResult<CountTagQuery>> {
    return this.service.count(query.args);
  }
}

@CommandHandler(CreateOneTagCommand)
export class CreateOneTagCommandHandler implements IInferredCommandHandler<CreateOneTagCommand> {
  constructor(readonly service: TagService) {}
  async execute(command: CreateOneTagCommand): Promise<CommandResult<CreateOneTagCommand>> {
    return this.service.createOne(command.args);
  }
}

@CommandHandler(UpdateOneTagCommand)
export class UpdateOneTagCommandHandler implements IInferredCommandHandler<UpdateOneTagCommand> {
  constructor(readonly service: TagService) {}
  async execute(command: UpdateOneTagCommand): Promise<CommandResult<UpdateOneTagCommand>> {
    return this.service.updateOne(command.args);
  }
}

@CommandHandler(DeleteOneTagCommand)
export class DeleteOneTagCommandHandler implements IInferredCommandHandler<DeleteOneTagCommand> {
  constructor(readonly service: TagService) {}
  async execute(command: DeleteOneTagCommand): Promise<CommandResult<DeleteOneTagCommand>> {
    return this.service.deleteOne(command.args);
  }
}

Notice: every handler body is identical in structure. this.service.methodName(query.args) — that’s it. The handler is a message router, nothing more.

The postal sorting facility: The CommandBus is a national postal sorting facility. You drop a letter (command object) in the slot. The facility reads the address (class name), routes it to the right delivery driver (handler class registered via @CommandHandler), and delivers it. The handler’s one-liner body is the driver completing the last mile — calling the service method and returning the result. Anything more than one line means the handler is trying to sort AND deliver AND repackage. That’s not its job.

From Meteor? Meteor.methods({ createTask }) handles routing, logic, and DB calls all in one body. CQRS handlers are just the routing step — always a single line. Logic belongs exclusively in the service.

Memory hook: CommandBus/QueryBus = postal sorting facility. Drop the message, bus routes to handler. Handler calls service. One line. No exceptions.


Step 9 — Service (tag.service.ts)

This is where all business logic lives.

The specialist doctor: The resolver is the front desk receptionist — she takes your name and reason for visit, decides which specialist (service method) to route you to, and returns the result when the appointment ends. She never examines you. The service is the specialist doctor — she examines the request (business rules), prescribes treatment (creates/updates/deletes data), and never touches the front desk appointment book. If your service method imports anything from @nestjs/graphql, it’s doing the receptionist’s job.

From Meteor? Meteor methods blurred routing and logic into one block. In NestJS, “Where is the business logic?” always has one answer: *.service.ts. The slug-uniqueness check in createOne and the cross-user update guard in updateOne are examples — they live here, not in the resolver or handler.

Memory hook: Service = specialist doctor. All if statements with business meaning live here. Never imports from @nestjs/graphql.

// apps/api/src/modules/tag/tag.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 {
  CountTagQuery,
  CreateOneTagCommand,
  DeleteOneTagCommand,
  FindManyTagQuery,
  FindOneTagQuery,
  UpdateOneTagCommand,
} from './cqrs/tag.cqrs.input';
import { TagEntity } from './tag.entity';

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

  findOne: CqrsQueryFunc<FindOneTagQuery, FindOneTagQuery['args']> = async ({ query, options }) => {
    const nullable = options?.nullable ?? true;
    try {
      const result = await this.filterQueryBuilder.select(query).getOne();
      if (!nullable && !result) throw new Error('Tag not found');
      return { success: true, data: result };
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };

  findMany: CqrsQueryFunc<FindManyTagQuery, FindManyTagQuery['args']> = async ({ query }) => {
    try {
      const results = await this.filterQueryBuilder.select(query).getMany();
      return { success: true, data: results };
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };

  count: CqrsQueryFunc<CountTagQuery, CountTagQuery['args']> = async ({ query }) => {
    try {
      return this.repo.count({ where: query as any });
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };

  createOne: CqrsCommandFunc<CreateOneTagCommand, CreateOneTagCommand['args']> = async ({ input }) => {
    try {
      // Business rule: slug must be unique
      const existing = await this.repo.findOne({ where: { slug: input.slug } });
      if (existing) throw new Error(`A tag with slug "${input.slug}" already exists`);

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

  updateOne: CqrsCommandFunc<UpdateOneTagCommand, UpdateOneTagCommand['args']> = async ({ query, input }) => {
    try {
      const before = await this.filterQueryBuilder.select(query).getOne();
      if (!before) throw new NotFoundException('Tag not found');

      // If slug is being changed, check uniqueness
      if (input.slug && input.slug !== before.slug) {
        const slugTaken = await this.repo.findOne({ where: { slug: input.slug } });
        if (slugTaken) throw new Error(`Slug "${input.slug}" is already taken`);
      }

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

  deleteOne: CqrsCommandFunc<DeleteOneTagCommand, DeleteOneTagCommand['args']> = async ({ input: id }) => {
    try {
      const tag = await this.repo.findOne({ where: { id } });
      if (!tag) throw new NotFoundException('Tag not found');
      await this.repo.remove(tag);
      return { success: true, data: id };
    } catch (e) {
      throw new BadRequestException(e.message);
    }
  };
}

Step 10 — Resolver (tag.resolver.ts)

Front desk receptionist + personal shopper: The resolver is the front desk receptionist — it takes the request, checks credentials (guards), and routes to the right bus. It is also a personal shopper for GraphQL — the client asks for exactly the fields it wants (id, name, slug, color) and gets precisely that shape back, nothing more. Zero business logic lives here.

From Meteor? Meteor.methods({ createTag }) is the closest equivalent, but Meteor methods contained business logic and DB calls mixed in. This resolver contains none of that — it dispatches to the CommandBus and returns. Guards replace if (!this.userId) throw new Meteor.Error('not-authorized').

Memory hook: Resolver = receptionist + personal shopper. Routes and returns. @UseGuards replaces Meteor’s manual this.userId checks.

// apps/api/src/modules/tag/tag.resolver.ts
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';

import { TagDto } from './dto/tag.dto';
import { CreateTagInput, UpdateTagInput } from './dto/tag.input';
import { TagQueryConnection, TagsQuery } from './dto/tag.query';
import { AuthJwtGuard } from '../auth/guards/auth-jwt.guard';
import {
  CountTagQuery,
  CreateOneTagCommand,
  DeleteOneTagCommand,
  FindManyTagQuery,
  FindOneTagQuery,
  UpdateOneTagCommand,
} from './cqrs';

@Resolver(() => TagDto)
export class TagResolver {
  constructor(
    private readonly queryBus: QueryBus,
    private readonly commandBus: CommandBus,
  ) {}

  // Public — no auth needed to read tags
  @Query(() => TagDto, { nullable: true })
  async tag(@Args('id', { type: () => Int }) id: number): Promise<TagDto | null> {
    const { data } = await this.queryBus.execute(
      new FindOneTagQuery({ query: { filter: { id: { eq: id } } } }),
    );
    return data as TagDto;
  }

  // Public list — paginated with automatic filtering and sorting
  @Query(() => TagQueryConnection)
  async getTags(@Args() query: TagsQuery) {
    return TagQueryConnection.createFromPromise(
      async (q) => {
        const { data } = await this.queryBus.execute(new FindManyTagQuery({ query: q }));
        return data as TagDto[];
      },
      query,
      async (filter) => {
        const count = await this.queryBus.execute(new CountTagQuery({ query: filter }));
        return count as number;
      },
    );
  }

  // Auth required — only logged-in users can create tags
  @UseGuards(AuthJwtGuard)
  @Mutation(() => TagDto)
  async createTag(@Args('input') input: CreateTagInput): Promise<TagDto> {
    const { data } = await this.commandBus.execute(new CreateOneTagCommand({ input }));
    return data as TagDto;
  }

  // Auth required — only logged-in users can update tags
  @UseGuards(AuthJwtGuard)
  @Mutation(() => TagDto)
  async updateTag(
    @Args('id', { type: () => Int }) id: number,
    @Args('input') input: UpdateTagInput,
  ): Promise<TagDto> {
    const { data } = await this.commandBus.execute(
      new UpdateOneTagCommand({ query: { filter: { id: { eq: id } } }, input }),
    );
    return data.updated as TagDto;
  }

  // Auth required — only logged-in users can delete tags
  @UseGuards(AuthJwtGuard)
  @Mutation(() => Boolean)
  async deleteTag(@Args('id', { type: () => Int }) id: number): Promise<boolean> {
    await this.commandBus.execute(new DeleteOneTagCommand({ input: id }));
    return true;
  }
}

Step 11 — Module (tag.module.ts)

Hospital wing: The module is a hospital wing. imports is what this wing borrows from others (TypeORM gives it the database connection and the TagEntity repository). providers is the internal staff it owns (resolver, service, all handlers). exports is what it lends to other wings — just TagService, so other modules can call into tag logic without importing the whole wing.

From Meteor? In Meteor, any file anywhere could import any other file. In NestJS, TagService is only available to modules that explicitly import TagModule. This prevents accidental cross-module data access.

Memory hook: @Module = hospital wing. imports borrows, providers owns staff, exports lends. One feature = one module.

// apps/api/src/modules/tag/tag.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';

import { TagEntity } from './tag.entity';
import { TagResolver } from './tag.resolver';
import { TagService } from './tag.service';
import { TagCommandHandlers, TagEventHandlers, TagQueryHandlers } from './cqrs';

@Module({
  imports: [
    // CqrsModule is NOT imported here — it is registered globally via CqrsModule.forRoot() in AppModule
    TypeOrmModule.forFeature([TagEntity]),
    NestjsQueryTypeOrmModule.forFeature([TagEntity]),
  ],
  providers: [
    TagResolver,
    TagService,
    ...TagQueryHandlers,
    ...TagCommandHandlers,
    ...TagEventHandlers,
  ],
  exports: [TagService],
})
export class TagModule {}

Step 12 — Register in AppModule

// apps/api/src/app/app.module.ts — add to imports and entities
import { TagModule } from '../modules/tag/tag.module';
import { TagEntity } from '../modules/tag/tag.entity';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      useFactory: (config: ConfigService) => ({
        entities: [
          TagEntity,   // ← add here
          // ... UserEntity, etc.
        ],
      }),
    }),
    TagModule,         // ← add here
    // ...
  ],
})
export class AppModule {}

Step 13 — Migration

# Generate migration from entity diff
yarn api:migration:generate apps/api/src/migrations/CreateTagTable

Git commit for the database: A migration is a git commit for the database schema. up() applies the change, down() reverts it. Every schema change is tracked in order and reversible. You never edit a past migration — you add a new one.

From Meteor? MongoDB has no migrations — schema changes just happen (or silently don’t). When you have 50,000 rows and need to add a required column, no-migration becomes a production incident. Every schema change in NestJS is visible, reversible, and reviewable.

Memory hook: Migration = git commit for DB. up() applies, down() reverts. Never edit old migrations. Review the SQL before running.

Review the generated file at apps/api/src/migrations/<timestamp>-create-tag-table.ts:

// Expected generated SQL (verify this):
async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`
    CREATE TABLE "tag" (
      "id"         SERIAL NOT NULL,
      "name"       character varying NOT NULL,
      "slug"       character varying NOT NULL,
      "color"      character varying NOT NULL DEFAULT '#6366f1',
      "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
      "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
      CONSTRAINT "UQ_tag_slug" UNIQUE ("slug"),
      CONSTRAINT "PK_tag" PRIMARY KEY ("id")
    )
  `);
}

async down(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`DROP TABLE "tag"`);
}

Check that:

Run the migration:

yarn api:migration:run

Verify in Adminer (http://localhost:8080): the tag table should now exist with the correct columns.


Step 14 — Smoke Test in GraphQL Playground

Start the server:

yarn api:dev

Open http://localhost:3333/graphql. First, get an auth token (for write operations):

mutation {
  signIn(input: { username: "testuser", password: "Secret123!" }) {
    accessToken
  }
}

Set the Authorization header in Playground HTTP Headers tab:

{ "Authorization": "Bearer <paste_access_token>" }

Create a tag:

mutation {
  createTag(input: { name: "Work", slug: "work", color: "#3b82f6" }) {
    id
    name
    slug
    color
    createdAt
  }
}

Create more tags:

mutation { createTag(input: { name: "Personal", slug: "personal" }) { id name } }
mutation { createTag(input: { name: "Urgent", slug: "urgent", color: "#ef4444" }) { id name } }

Query the paginated list with filter:

query {
  getTags(
    filter: { name: { like: "%%" } }
    sorting: [{ field: createdAt, direction: DESC }]
    paging: { first: 10 }
  ) {
    totalCount
    edges {
      node { id name slug color createdAt }
      cursor
    }
    pageInfo { hasNextPage endCursor }
  }
}

Update a tag:

mutation {
  updateTag(id: 1, input: { name: "Work Tasks", color: "#2563eb" }) {
    id
    name
    color
    updatedAt
  }
}

Delete a tag:

mutation {
  deleteTag(id: 3)
}

Try creating a tag without auth (should fail):

Remove the Authorization header, then:

mutation {
  createTag(input: { name: "Should fail", slug: "fail" }) { id }
}

Expected: { "errors": [{ "message": "Unauthorized" }] }


Step 15 — Unit Tests

Unit tests verify your business logic in isolation — no database, no HTTP, no NestJS app bootstrap.

Service Unit Test (test/tag.service.spec.ts)

// apps/api/src/modules/tag/test/tag.service.spec.ts
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getQueryServiceToken } from '@ptc-org/nestjs-query-core';

import { TagEntity } from '../tag.entity';
import { TagService } from '../tag.service';

// Mock the TypeORM repository
const mockRepo = {
  findOne: jest.fn(),
  create: jest.fn(),
  save: jest.fn(),
  remove: jest.fn(),
  count: jest.fn(),
  createQueryBuilder: jest.fn().mockReturnValue({
    where: jest.fn().mockReturnThis(),
    getOne: jest.fn(),
    getMany: jest.fn(),
  }),
};

// Mock the nestjs-query QueryService
const mockQueryService = {};

describe('TagService', () => {
  let service: TagService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        TagService,
        { provide: getRepositoryToken(TagEntity), useValue: mockRepo },
        { provide: getQueryServiceToken(TagEntity), useValue: mockQueryService },
      ],
    }).compile();

    service = module.get<TagService>(TagService);

    // Reset all mocks before each test
    jest.clearAllMocks();
  });

  describe('createOne', () => {
    it('should create and return a new tag', async () => {
      const input = { name: 'Work', slug: 'work', color: '#3b82f6' };
      const savedTag: Partial<TagEntity> = { id: 1, ...input, createdAt: new Date(), updatedAt: new Date() };

      mockRepo.findOne.mockResolvedValue(null);  // no existing tag with this slug
      mockRepo.create.mockReturnValue(input);
      mockRepo.save.mockResolvedValue(savedTag);

      const result = await service.createOne({ input });

      expect(result.success).toBe(true);
      expect(result.data).toEqual(savedTag);
      expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { slug: 'work' } });
      expect(mockRepo.save).toHaveBeenCalledWith(input);
    });

    it('should throw BadRequestException when slug already exists', async () => {
      const input = { name: 'Work', slug: 'work' };
      mockRepo.findOne.mockResolvedValue({ id: 1, slug: 'work' });  // slug taken

      await expect(service.createOne({ input })).rejects.toThrow(BadRequestException);
      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it('should throw BadRequestException when name is empty', async () => {
      const input = { name: '', slug: 'work' };
      mockRepo.findOne.mockResolvedValue(null);
      // The service would delegate to the repo and throw
      mockRepo.save.mockRejectedValue(new Error('violates not-null constraint'));

      await expect(service.createOne({ input })).rejects.toThrow(BadRequestException);
    });
  });

  describe('deleteOne', () => {
    it('should delete an existing tag', async () => {
      const tag: Partial<TagEntity> = { id: 1, name: 'Work', slug: 'work' };
      mockRepo.findOne.mockResolvedValue(tag);
      mockRepo.remove.mockResolvedValue(tag);

      const result = await service.deleteOne({ input: 1 });

      expect(result.success).toBe(true);
      expect(mockRepo.remove).toHaveBeenCalledWith(tag);
    });

    it('should throw BadRequestException when tag not found', async () => {
      mockRepo.findOne.mockResolvedValue(null);

      await expect(service.deleteOne({ input: 999 })).rejects.toThrow(BadRequestException);
      expect(mockRepo.remove).not.toHaveBeenCalled();
    });
  });
});

Handler Unit Test (test/tag.cqrs.spec.ts)

// apps/api/src/modules/tag/test/tag.cqrs.spec.ts
import { CreateOneTagCommandHandler, FindOneTagQueryHandler } from '../cqrs/tag.cqrs.handler';
import { CreateOneTagCommand, FindOneTagQuery } from '../cqrs/tag.cqrs.input';
import { TagService } from '../tag.service';

// Mock the entire TagService
const mockService: Partial<TagService> = {
  findOne: jest.fn(),
  createOne: jest.fn(),
};

describe('Tag CQRS Handlers', () => {
  let findOneHandler: FindOneTagQueryHandler;
  let createOneHandler: CreateOneTagCommandHandler;

  beforeEach(() => {
    jest.clearAllMocks();
    findOneHandler = new FindOneTagQueryHandler(mockService as TagService);
    createOneHandler = new CreateOneTagCommandHandler(mockService as TagService);
  });

  describe('FindOneTagQueryHandler', () => {
    it('should delegate to service.findOne with query.args', async () => {
      const expectedResult = { success: true, data: { id: 1, name: 'Work' } };
      (mockService.findOne as jest.Mock).mockResolvedValue(expectedResult);

      const query = new FindOneTagQuery({ query: { filter: { id: { eq: 1 } } } });
      const result = await findOneHandler.execute(query);

      expect(mockService.findOne).toHaveBeenCalledWith(query.args);
      expect(result).toEqual(expectedResult);
    });
  });

  describe('CreateOneTagCommandHandler', () => {
    it('should delegate to service.createOne with command.args', async () => {
      const input = { name: 'Work', slug: 'work' };
      const expectedResult = { success: true, data: { id: 1, ...input } };
      (mockService.createOne as jest.Mock).mockResolvedValue(expectedResult);

      const command = new CreateOneTagCommand({ input });
      const result = await createOneHandler.execute(command);

      expect(mockService.createOne).toHaveBeenCalledWith(command.args);
      expect(result).toEqual(expectedResult);
    });

    // This test proves the thin handler rule: handler should NOT contain logic
    it('handler body should be exactly one line (delegate to service)', () => {
      // The handler's execute method source code
      const handlerSource = createOneHandler.execute.toString();
      // It should contain exactly one "return this.service" call
      const serviceCallCount = (handlerSource.match(/this\.service\./g) || []).length;
      expect(serviceCallCount).toBe(1);
    });
  });
});

Run Tests

yarn api:test

Expected output:

PASS apps/api/src/modules/tag/test/tag.service.spec.ts
  TagService
    createOne
      ✓ should create and return a new tag (5ms)
      ✓ should throw BadRequestException when slug already exists (2ms)
      ✓ should throw BadRequestException when name is empty (1ms)
    deleteOne
      ✓ should delete an existing tag (1ms)
      ✓ should throw BadRequestException when tag not found (1ms)

PASS apps/api/src/modules/tag/test/tag.cqrs.spec.ts
  Tag CQRS Handlers
    FindOneTagQueryHandler
      ✓ should delegate to service.findOne with query.args (2ms)
    CreateOneTagCommandHandler
      ✓ should delegate to service.createOne with command.args (1ms)
      ✓ handler body should be exactly one line (1ms)

Step 16 — Commit

# Stage all new files
git add apps/api/src/modules/tag/
git add apps/api/src/migrations/*-create-tag-table.ts
git add apps/api/src/app/app.module.ts

# Conventional commit via Commitizen
yarn cz

In the Commitizen interactive menu:

Resulting commit: feat(tag): add tag module with CRUD GraphQL API

Husky runs before the commit:

If lint fails: yarn lint:fix → re-git addyarn cz


Complete File Checklist

[✅] tag.entity.ts            — extends AbstractEntity, slug UNIQUE
[✅] tag.constant.ts          — TagStatus enum + registerEnumType
[✅] dto/tag.dto.ts           — @ObjectType, @FilterableField on name/slug
[✅] dto/tag.input.ts         — CreateTagInput, UpdateTagInput with class-validator
[✅] dto/tag.query.ts         — TagsQuery + TagQueryConnection (cursor pagination)
[✅] cqrs/tag.cqrs.input.ts   — Find/Count queries, Create/Update/Delete commands
[✅] cqrs/tag.cqrs.handler.ts — All handlers, all one-liners
[✅] cqrs/index.ts            — Handler arrays + re-export inputs
[✅] tag.service.ts           — Business logic: slug uniqueness, findOne/Many/count/create/update/delete
[✅] tag.resolver.ts          — Public reads, auth-required writes
[✅] tag.module.ts            — TypeOrmModule + NestjsQueryTypeOrmModule (no CqrsModule — buses are global)
[✅] AppModule updated        — TagEntity in entities[], TagModule in imports[]
[✅] Migration generated      — create-tag-table
[✅] Migration reviewed       — checked SQL for correctness
[✅] Migration run            — yarn api:migration:run
[✅] Adminer verified         — tag table exists with correct schema
[✅] Playground tested        — create, list, update, delete all work
[✅] Playground auth test     — unauthorized write returns 401
[✅] Unit tests passing       — service + handler specs green
[✅] Committed                — conventional commit via yarn cz

Quick Reference

ConceptAnalogyMeteor equivalentThe one rule
EntityOfficial record templatenew Mongo.Collection — but schema-lessSchema + TypeScript type in one class. Never synchronize: true in prod.
AbstractEntityCompany letterheadNo equivalentProvides id + timestamps. All entities extend it. Never repeat those columns.
AbstractDtoStandard response envelopeNo equivalentPairs with AbstractEntity. All @ObjectType DTOs extend it.
CQRS InputsTyped envelopes for two kitchensSingle Meteor.methods bodyCommands mutate. Queries read. Never share a stove.
CommandBus / QueryBusPostal sorting facilityMeteor.methods call dispatchDrop the message object. Bus routes to handler. Resolver never imports handler.
CQRS HandlerLast-mile delivery driverPart of the Meteor method bodyAlways a one-liner calling this.service.method(args). No logic.
ServiceSpecialist doctorBusiness logic inside Meteor.methodsAll if statements with business meaning live here. Never imports from @nestjs/graphql.
ResolverFront desk receptionist + personal shopperMeteor.methods entry pointRoutes and returns. Dispatches to bus. @UseGuards replaces manual this.userId checks.
ModuleHospital wingmeteor add — but implicitimports borrows · providers owns · exports lends. One feature = one module.
MigrationGit commit for the databaseNo equivalent in MongoDBup() applies, down() reverts. Never edit old migrations. Review SQL before running.

Summary

You have built a complete enterprise module from scratch. The pattern you followed:

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

Every file has one job. Every business rule is in the service. Every handler is a one-liner. The GraphQL API is self-documenting, auto-filters, and cursor-paginates.

In Part 11, you will build the Todo module — which adds foreign keys, auth ownership enforcement, and the DataLoader pattern for resolving related entities without N+1 queries.


Edit page
Share this post:

Next Post
Case Study 2 - Todo Module (FK + Auth + DataLoader)
Previous Post
Extended Auth — Email Service, Secured Tokens & Two-Factor Authentication