plugins/lisa-nestjs-cursor/skills/nestjs-rules/SKILL.md
Procedural rules and patterns for NestJS backend development. This skill should be used when creating new NestJS modules, services, resolvers, or controllers. It covers component generation with NestJS CLI, TDD patterns, module structure conventions, Lambda handler patterns, and configuration standards. Use this skill alongside nestjs-graphql for GraphQL-specific patterns.
npx skillsauth add codyswanngt/lisa nestjs-rulesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
Always use NestJS CLI to create components rather than manually creating files.
bunx nest g module <name> --no-spec
bunx nest g service <name> --no-spec
bunx nest g resolver <name> --no-spec
bunx nest g controller <name> --no-spec
--no-specThe --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.
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:
bun run migration:generate --name=<DescriptiveName> to produce the migration from the diff.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 or modify a TypeORM migration file directly. Use migration:generate from package.json:
bun run migration:generate --name=<DescriptiveName>
Some changes genuinely cannot be derived from entity diffs:
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:
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.
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.
TypeORM compares your entity definitions against the current database schema to generate migrations. Manual creation can:
Create test files before implementation:
// src/feature/feature.service.test.ts
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.ts*.integration.test.ts# Unit tests only
bun run test:unit
# Integration tests only
bun run test:integration
# All tests
bun run test
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
Register feature modules in app.module.ts:
import { Module } from "@nestjs/common";
import { FeatureModule } from "./feature/feature.module";
@Module({
imports: [
// ... other imports
FeatureModule,
],
})
export class AppModule {}
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { configure as serverlessExpress } from "@vendia/serverless-express";
import { AppModule } from "./app.module";
type ServerlessHandler = ReturnType<typeof serverlessExpress>;
/**
* Creates a lazy-initialized server getter using closure pattern
* @description Encapsulates mutable cache state for Lambda warm starts
* @returns Async function that returns the cached or newly created server
*/
const createServerGetter = (): (() => Promise<ServerlessHandler>) => {
// eslint-disable-next-line functional/no-let -- Required for Lambda warm start caching
let cachedServer: ServerlessHandler | null = null;
return async (): Promise<ServerlessHandler> => {
if (cachedServer) {
return cachedServer;
}
const nestApp = await NestFactory.create(AppModule, {
cors: {
origin: "*",
methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
preflightContinue: false,
optionsSuccessStatus: 204,
},
});
await nestApp.init();
const app = nestApp.getHttpAdapter().getInstance();
cachedServer = serverlessExpress({ app });
return cachedServer;
};
};
const getServer = createServerGetter();
/**
* Lambda handler function
* @param event - AWS Lambda event object
* @param context - AWS Lambda context object
* @returns Promise resolving to Lambda response
*/
export const handler = async (
event: unknown,
context: unknown
): Promise<unknown> => {
const server = await getServer();
return server(event, context);
};
@vendia/serverless-express for Express adapter compatibility// nest-cli.json
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
Required settings for NestJS decorators:
// tsconfig.json (additions)
{
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false
}
}
# serverless.yml
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
memorySize: 1024
events:
- httpApi:
method: any
path: /{proxy+}
Every exported function, class, and type must have JSDoc documentation:
/**
* Service for managing user accounts
* @description Provides CRUD operations for user entities
* @remarks
* - All methods are idempotent
* - Throws NotFoundException for missing resources
*/
@Injectable()
export class UserService {
/**
* Retrieves a user by their unique identifier
* @param id - The unique identifier of the user
* @returns The user if found, null otherwise
*/
async findById(id: string): Promise<User | null> {
return this.repository.findOne({ where: { id } });
}
}
Every file should have a preamble comment:
/**
* @file user.service.ts
* @description Service providing user account management
* @module users
*/
Services that support DataLoader must implement batch methods:
/**
* Batch loads entities by IDs (for DataLoader)
* @param ids - Array of entity IDs to load
* @returns Promise resolving to array of entities in same order as input
* @remarks Used by DataLoader for batching - maintains input order
*/
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);
}
Batch functions must return results in the same order as input keys. Always map input IDs to results to maintain order.
Use const instead of let or var:
// Good
const users = await this.userService.findAll();
const filtered = users.filter(u => u.active);
// Bad
let users = await this.userService.findAll();
users = users.filter(u => u.active);
Use reduce instead of push or pop:
// Good
const userMap = users.reduce(
(acc, user) => ({ ...acc, [user.id]: user }),
{} as Record<string, User>
);
// Bad
const userMap: Record<string, User> = {};
users.forEach(user => {
userMap[user.id] = user;
});
Use GraphQL errors with codes:
import { GraphQLError } from "graphql";
throw new GraphQLError("User not found", {
extensions: { code: "NOT_FOUND", id },
});
Always use NestJS ConfigService instead of accessing process.env directly. This provides:
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 {
// Type-safe configuration access with autocomplete
const host = this.configService.get("database.host", { infer: true });
const isOffline = this.configService.get("app.isOffline", { infer: true });
}
}
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 {}
For code running outside NestJS context (Lambda authorizers, WebSocket handlers):
import { getStandaloneConfig } from "../../config/configuration";
// Use getStandaloneConfig() for type-safe access outside NestJS
const config = getStandaloneConfig();
const host = config.valkey.host;
const port = config.valkey.port;
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;
// ... other database config
};
readonly valkey: {
readonly host: string;
readonly port: number;
readonly maxRetriesPerRequest: number;
};
// ... other configuration namespaces
}
Configuration interfaceconfiguration() factory functionconfigService.get("namespace.property", { infer: true })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>;
};
// In test setup
const module = await Test.createTestingModule({
providers: [
MyService,
{ provide: ConfigService, useValue: createMockConfigService() },
],
}).compile();
After creating or modifying NestJS components:
bun run test:unitbun run test:integrationbun run lintbun run buildbun run start:localdocumentation
Onboard a user to the project via its LLM Wiki. Interviews the user about themselves in relation to the project, captures that to project-scoped memory only, then gives a guided tour of what the project is and sample questions they can ask. Use when someone is new to the project or asks to be onboarded. Read-mostly — it does not open PRs or write PII into the wiki.
documentation
Migrate an existing, hand-rolled wiki implementation onto the lisa-wiki kernel — phased and compatibility-first, with a strict no-loss guarantee. Use when adopting lisa-wiki in a repo that already has its own wiki/, ingest skills, docs, or roles. Renaming things into the canonical shape is fine; losing functionality or data is not. Ends by running /doctor.
development
Health-check the LLM Wiki. Reports orphan pages, contradictions, stale claims, broken internal links, missing index/log coverage, structure-manifest violations, and secret/tenant leaks. Use periodically or before hardening a wiki. Read-only — it reports findings, it does not fix them.
testing
Ingest source material into the LLM Wiki. With an argument (URL, file path, or prompt) it ingests that one source; with no argument it runs a full ingest across every enabled non-external-write source. Routes to the right connector, then runs the ordered pipeline (source note → synthesis → index → log → verify → state → commit/PR). Use whenever new knowledge should enter the wiki.