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:
- Tags that label content (e.g., “work”, “personal”, “urgent”)
- Public read (anyone can query tags)
- Auth-required write (only logged-in users can create/update/delete)
- Paginated list with filtering and sorting
- Full unit tests
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:
id: number—SERIAL PRIMARY KEYcreatedAt: Date—created_at TIMESTAMPTZ DEFAULT now()(hardcoded column name)updatedAt: Date—updated_at TIMESTAMPTZ DEFAULT now()(auto-updated on save)
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:
AbstractEntityis the company letterhead — every entity (letter) is printed on paper that already hasid,createdAt,updatedAt, anddeletedAtpre-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
registerEnumTypeimmediately 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:
AbstractDtois the standard response envelope — every output DTO pairs withAbstractEntityand exposesid,createdAt, andupdatedAtas@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.
AbstractDtoguarantees 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?PartialTypemakes all fields optional but keeps them. In the pattern used here, explicitUpdateTagInputfields 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 increateOneand the cross-user update guard inupdateOneare 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 replaceif (!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.
importsis what this wing borrows from others (TypeORM gives it the database connection and theTagEntityrepository).providersis the internal staff it owns (resolver, service, all handlers).exportsis what it lends to other wings — justTagService, 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,
TagServiceis only available to modules that explicitly importTagModule. 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:
slughas aUNIQUEconstraintcolorhas the right default valuecreated_atandupdated_atare present (fromAbstractEntity)
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:
- Type:
feat - Scope:
tag - Short description:
add tag module with CRUD GraphQL API - Breaking change:
N - Issues: leave blank or link ticket
Resulting commit: feat(tag): add tag module with CRUD GraphQL API
Husky runs before the commit:
- ESLint checks all staged
.tsfiles - Prettier formats staged files
- commitlint validates the commit message format
If lint fails: yarn lint:fix → re-git add → yarn 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
| Concept | Analogy | Meteor equivalent | The one rule |
|---|---|---|---|
| Entity | Official record template | new Mongo.Collection — but schema-less | Schema + TypeScript type in one class. Never synchronize: true in prod. |
| AbstractEntity | Company letterhead | No equivalent | Provides id + timestamps. All entities extend it. Never repeat those columns. |
| AbstractDto | Standard response envelope | No equivalent | Pairs with AbstractEntity. All @ObjectType DTOs extend it. |
| CQRS Inputs | Typed envelopes for two kitchens | Single Meteor.methods body | Commands mutate. Queries read. Never share a stove. |
| CommandBus / QueryBus | Postal sorting facility | Meteor.methods call dispatch | Drop the message object. Bus routes to handler. Resolver never imports handler. |
| CQRS Handler | Last-mile delivery driver | Part of the Meteor method body | Always a one-liner calling this.service.method(args). No logic. |
| Service | Specialist doctor | Business logic inside Meteor.methods | All if statements with business meaning live here. Never imports from @nestjs/graphql. |
| Resolver | Front desk receptionist + personal shopper | Meteor.methods entry point | Routes and returns. Dispatches to bus. @UseGuards replaces manual this.userId checks. |
| Module | Hospital wing | meteor add — but implicit | imports borrows · providers owns · exports lends. One feature = one module. |
| Migration | Git commit for the database | No equivalent in MongoDB | up() 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.