| name | nestjs-rules |
| description | Procedural rules and patterns… |
NestJS Development Rules
Overview
This skill provides procedural rules for working with NestJS in this project. It covers component generation, testing patterns, module structure, and deployment configuration. For GraphQL-specific patterns (resolvers, types, auth decorators), use the nestjs-graphql skill.
Component Generation
Always use NestJS CLI to create components rather than manually creating files.
Module Generation
bunx nest g module <name> --no-spec
Service Generation
bunx nest g service <name> --no-spec
Resolver Generation (GraphQL)
bunx nest g resolver <name> --no-spec
Controller Generation (REST)
bunx nest g controller <name> --no-spec
Why --no-spec
The --no-spec flag is used because this project follows TDD (Test-Driven Development). Tests are written first with a custom test structure before implementation, not auto-generated by the CLI.
Database Migrations
Entity Files Are the Source of Truth
In this project, entity files (src/database/entities/*.ts) are the single source of truth for the database schema. Migrations are a derived artifact — TypeORM diffs the entity metadata against the current database and emits the migration for you. The workflow is always:
- Edit the entity file to express the desired schema.
- Run
bun run migration:generate --name=<DescriptiveName> to produce the migration from the diff.
- Review the generated migration, then commit both the entity change and the migration together.
If a schema change cannot be expressed via the entity model, the entity model is wrong — fix the entity, do not hand-write the migration.
Never Create Migration Files Manually
Never create or modify a TypeORM migration file directly. Use migration:generate from package.json:
bun run migration:generate --name=<DescriptiveName>
Out-of-Band Migrations (Seed Data, Backfills)
Some changes genuinely cannot be derived from entity diffs:
- Seed data (reference rows, lookup tables, initial admin user)
- Data backfills (populating a new column from existing rows)
- Data transformations (splitting a column, normalizing values)
- One-off cleanup (deleting orphaned rows before a constraint is added)
These are legitimate cases for a hand-written migration, but they are out-of-band — they bypass the entity-as-source-of-truth contract. When you encounter one:
- Stop and tell the user. Explain what change is needed and why it cannot be expressed via the entity model.
- Get explicit approval before writing the migration by hand.
- Document the rationale in the migration's class comment so future readers understand why this one was not generated.
Do not silently hand-write a migration for a backfill or seed-data change. The user must know that the entity-as-source-of-truth contract is being intentionally bypassed for this case.
Enforcement
The lisa-nestjs plugin ships a PreToolUse hook (block-migration-edits.sh) that blocks Write/Edit on any path matching **/migrations/*.ts or **/migrations/*.js. The block surfaces this rule's guidance to remind you to either (a) edit the entity instead, or (b) ask the user before proceeding with an out-of-band migration.
Why Auto-Generation
TypeORM compares your entity definitions against the current database schema to generate migrations. Manual creation can:
- Introduce drift between entities and migrations
- Miss important schema changes
- Create migrations that don't match entity metadata
Test-Driven Development Pattern
Write Tests First
Create test files before implementation:
import { Test, TestingModule } from "@nestjs/testing";
import { FeatureService } from "./feature.service";
describe("FeatureService", () => {
const service: FeatureService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FeatureService],
}).compile();
service = module.get<FeatureService>(FeatureService);
});
describe("methodName", () => {
it("should do expected behavior", () => {
expect(service.methodName()).toBe("expected");
});
});
});
Test File Naming
- Unit tests:
*.test.ts
- Integration tests:
*.integration.test.ts
Running Tests
bun run test:unit
bun run test:integration
bun run test
Module Structure
Feature Module Pattern
src/
├── <feature>/
│ ├── <feature>.module.ts # Module definition
│ ├── <feature>.service.ts # Business logic
│ ├── <feature>.service.test.ts # Service unit tests
│ ├── <feature>.resolver.ts # GraphQL resolver (if applicable)
│ ├── <feature>.resolver.test.ts # Resolver unit tests
│ ├── <feature>.controller.ts # REST controller (if applicable)
│ ├── <feature>.controller.test.ts
│ ├── dto/ # Data transfer objects
│ │ ├── create-<feature>.input.ts
│ │ └── update-<feature>.input.ts
│ └── entities/ # Entity definitions
│ └── <feature>.entity.ts
Module Registration
Register feature modules in app.module.ts:
import { Module } from "@nestjs/common";
import { FeatureModule } from "./feature/feature.module";
@Module({
imports: [
FeatureModule,
],
})
export class AppModule {}
Lambda Handler Pattern
Entry Point Structure
import { NestFactory } from "@nestjs/core";
import { configure as serverlessExpress } from "@vendia/serverless-express";
import { AppModule } from "./app.module";
type ServerlessHandler = ReturnType<typeof serverlessExpress>;
const createServerGetter = (): (() => Promise<ServerlessHandler>) => {
let cachedServer: ServerlessHandler | null = null;
return async (): Promise<ServerlessHandler> => {
if (cachedServer) {
return cachedServer;
}
const nestApp = await NestFactory.create(AppModule, {
cors: {
origin: "*",
methods: ,
: ,
: ,
},
});
nestApp.();
app = nestApp.().();
cachedServer = ({ app });
cachedServer;
};
};
getServer = ();
handler = (
: ,
:
): <> => {
server = ();
(event, context);
};
Key Lambda Concepts
- Warm Start Caching: Use closure pattern to cache the NestJS application between invocations
- Serverless Express: Use
@vendia/serverless-express for Express adapter compatibility
- CORS Configuration: Configure CORS in NestFactory.create(), not in serverless.yml
Configuration Files
NestJS CLI Configuration
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
TypeScript Configuration
Required settings for NestJS decorators:
{
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false
}
}
Serverless Framework Configuration
service: project-name
frameworkVersion: "^4.0.0"
custom:
esbuild:
bundle: true
minify: false
sourcemap: true
keepNames: true
platform: node
target: node20
external:
- "fsevents"
- "@nestjs/websockets"
- "@nestjs/microservices"
- "@apollo/gateway"
- "@apollo/subgraph"
- "@as-integrations/fastify"
- "class-transformer/storage"
plugins:
- serverless-esbuild
- serverless-offline
provider:
name: aws
runtime: nodejs22.x
region: us-east-1
httpApi:
cors: true
functions:
main:
handler: src/main.handler
timeout: 29
Documentation Standards
JSDoc for All Components
Every exported function, class, and type must have JSDoc documentation:
@Injectable()
export class UserService {
async findById(id: string): Promise<User | null> {
return this.repository.findOne({ where: { id } });
}
}
Module File Preambles
Every file should have a preamble comment:
DataLoader Integration
Batch Method Pattern
Services that support DataLoader must implement batch methods:
async findByIds(ids: readonly string[]): Promise<Entity[]> {
const entities = await this.repository.findBy({ id: In([...ids]) });
const entityMap = new Map(entities.map(e => [e.id, e]));
return ids.map(id => entityMap.get(id) ?? null);
}
Order Preservation
Batch functions must return results in the same order as input keys. Always map input IDs to results to maintain order.
Common Patterns
Immutable Code
Use const instead of let or var:
const users = await this.userService.findAll();
const filtered = users.filter(u => u.active);
let users = await this.userService.findAll();
users = users.filter(u => u.active);
Functional Transformations
Use reduce instead of push or pop:
const userMap = users.reduce(
(acc, user) => ({ ...acc, [user.id]: user }),
{} as Record<string, User>
);
const userMap: Record<string, User> = {};
users.forEach(user => {
userMap[user.id] = user;
});
Error Handling
Use GraphQL errors with codes:
import { GraphQLError } from "graphql";
throw new GraphQLError("User not found", {
extensions: { code: "NOT_FOUND", id },
});
Configuration Management
Never Use process.env Directly
Always use NestJS ConfigService instead of accessing process.env directly. This provides:
- Type safety with full autocomplete
- Centralized configuration management
- Easier testing (mock ConfigService instead of environment)
- Validation at startup
ConfigService in NestJS Context
For services, resolvers, and controllers running within NestJS:
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Configuration } from "../config/configuration";
@Injectable()
export class MyService {
constructor(
private readonly configService: ConfigService<Configuration, true>
) {}
someMethod(): void {
const host = this.configService.get("database.host", { infer: true });
const isOffline = this.configService.get("app.isOffline", { infer: true });
}
}
ConfigService in Module Factories
For dynamic module configuration (e.g., TypeOrmModule.forRootAsync):
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Configuration } from "../config/configuration";
@Module({
imports: [
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService<Configuration, true>) =>
createTypeOrmOptionsFromConfigService(configService),
}),
],
})
export class DatabaseModule {}
Standalone Configuration (Lambda Handlers)
For code running outside NestJS context (Lambda authorizers, WebSocket handlers):
import { getStandaloneConfig } from "../../config/configuration";
const config = getStandaloneConfig();
const host = config.valkey.host;
const port = config.valkey.port;
Configuration Schema
All configuration is defined in src/config/configuration.ts:
export interface Configuration {
readonly app: {
readonly nodeEnv: string;
readonly isOffline: boolean;
};
readonly database: {
readonly host: string;
readonly port: number;
readonly username: string;
readonly password: string;
readonly name: string;
};
readonly valkey: {
readonly host: string;
readonly port: number;
readonly maxRetriesPerRequest: number;
};
}
Adding New Configuration
- Add the new property to the
Configuration interface
- Add default values in the
configuration() factory function
- Access via
configService.get("namespace.property", { infer: true })
Testing with ConfigService
Create mock ConfigService in tests:
const createMockConfigService = (): ConfigService<Configuration, true> => {
const config = {
valkey: { host: "localhost", port: 6379, maxRetriesPerRequest: 3 },
};
return {
get: jest.fn((key: string) => {
const keys = key.split(".");
return keys.reduce((obj, k) => obj?.[k], config);
}),
} as unknown as ConfigService<Configuration, true>;
};
const module = await Test.createTestingModule({
providers: [
MyService,
{ provide: ConfigService, useValue: createMockConfigService() },
],
}).compile();
Verification Checklist
After creating or modifying NestJS components:
- Unit tests pass:
bun run test:unit
- Integration tests pass:
bun run test:integration
- Linting passes:
bun run lint
- Type check passes:
bun run build
- Local server starts:
bun run start:local