Nestjs Expert
Guides NestJS 12 APIs with Prisma and Postgres — modules, DI, guards, interceptors, pipes, DTOs,…
by shipshitdev · MIT
The skill
Guides NestJS 12 APIs with Prisma and Postgres — modules, DI, guards, interceptors, pipes, DTOs, auth, errors. Use when building NestJS APIs or debugging Nest-specific issues.
NestJS Expert
Stack: NestJS 12 + Prisma 7 + Postgres + TypeScript strict mode.
NestJS 12 and Better Auth ship ESM-only packages. A CommonJS build (module: commonjs with moduleResolution: bundler) still works because Node 22.12+ loads ESM through require(esm), so run the API on Node >= 22.12; older Node fails at startup with ERR_REQUIRE_ESM. Version 11 is the legacy line (legacy dist-tag): do not mix 11 and 12 packages.
Module architecture
Every feature is a self-contained module. No cross-module direct imports — use exported providers.
src/
├── app.module.ts # Root — imports feature modules only
├── common/ # Shared guards, pipes, filters, interceptors
│ ├── filters/
│ ├── guards/
│ ├── interceptors/
│ └── pipes/
├── config/ # ConfigModule setup
├── prisma/ # PrismaModule + PrismaService (global, one client)
└── {feature}/
├── {feature}.module.ts
├── {feature}.controller.ts
├── {feature}.service.ts
├── dto/
│ ├── create-{feature}.dto.ts
│ └── update-{feature}.dto.ts
└── {feature}.types.ts
Dependency injection rules
- Inject interfaces, not concrete classes where possible
- Use
@Injectable({ scope: Scope.DEFAULT })(singleton) unless you need request-scoped - Circular deps = architectural problem — fix with
forwardRefonly as last resort - Test with
Test.createTestingModule— always mock external services
Controllers
@Controller('resources')
@UseGuards(JwtAuthGuard)
@UseInterceptors(ResponseTransformInterceptor)
export class ResourceController {
constructor(private readonly resourceService: ResourceService) {}
@Get()
async findAll(@Query() query: PaginationQueryDto) {
return this.resourceService.findAll(query);
}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateResourceDto, @CurrentUser() user: AuthUser) {
return this.resourceService.create(dto, user.id);
}
}
Rules:
- Controllers are thin — no business logic, no DB calls
- Always type
@Body(),@Query(),@Param()with DTOs - Use
@CurrentUser()custom decorator, never@Req()
DTOs + validation
import { IsString, IsEnum, IsOptional, MinLength, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateResourceDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name: string;
@IsEnum(ResourceStatus)
status: ResourceStatus;
@IsOptional()
@IsString()
@Transform(({ value }) => value?.trim())
description?: string;
}
Global validation pipe in main.ts:
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip unknown props
forbidNonWhitelisted: true,
transform: true, // auto-transform primitives
transformOptions: { enableImplicitConversion: true },
}));
Prisma / Postgres
Schema lives in prisma/schema.prisma; one global PrismaService wraps the client. Feature services depend on PrismaService — controllers never touch it.
Prisma 7: the prisma-client generator needs an explicit output (import PrismaClient from that path, not @prisma/client), the client needs a driver adapter (@prisma/adapter-pg), and the connection URL lives in prisma.config.ts. A CommonJS Nest build sets moduleFormat = "cjs" in the generator block. output is resolved relative to the schema file, so prisma/schema.prisma with ../src/generated/prisma writes the client to src/generated/prisma, which the service below imports from src/prisma/. Keep that folder git-ignored and regenerate it in CI and Docker builds.
Set datasource: { url: process.env.DATABASE_URL } in prisma.config.ts rather than env("DATABASE_URL"): env() throws when the variable is missing, which breaks prisma generate in clean CI and Docker builds that have no .env. migrate commands still fail with a clear error when the URL is absent.
// src/prisma/prisma.service.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
model Resource {
id String @id @default(uuid()) @db.Uuid
name String
status ResourceStatus @default(ACTIVE)
userId String @db.Uuid
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([userId, createdAt(sort: Desc)])
}
enum ResourceStatus {
ACTIVE
ARCHIVED
}
// service
@Injectable()
export class ResourceService {
constructor(private readonly prisma: PrismaService) {}
async findAll(userId: string, query: PaginationQueryDto) {
const { page = 1, limit = 20 } = query;
return this.prisma.resource.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
select: { id: true, name: true, status: true, createdAt: true },
});
}
}
Rules:
- Ids are
String(uuid or cuid) and relations are explicit@relationfields — never hand-rolled id strings with no foreign key - Type ids as
stringin the service layer; the Prisma-generated types are the source of truth (no hand-written model interfaces) - Use
selectto return only needed fields; never return the raw row when it holds secrets - Multi-step writes use
prisma.$transaction; never chain dependent writes without it - Prefer the typed client; for
$queryRawuse the tagged template (Prisma.sql) so values stay parameterized — never$queryRawUnsafewith user input - Soft delete:
deletedAt DateTime?, never hard delete user data; filterdeletedAt: nullin reads - Schema changes go through
prisma migrate dev/prisma migrate deploy, never edited by hand in the database
Auth pattern
// JWT strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get<string>('JWT_SECRET'),
ignoreExpiration: false,
});
}
async validate(payload: JwtPayload): Promise<AuthUser> {
// return value is injected as req.user
return { id: payload.sub, email: payload.email };
}
}
// custom decorator
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);
Guards
@Injectable()
export class ResourceOwnerGuard implements CanActivate {
constructor(private readonly resourceService: ResourceService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const { user, params } = context.switchToHttp().getRequest();
const resource = await this.resourceService.findById(params.id);
return resource?.userId === user.id;
}
}
Exception filter
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
if (exception instanceof HttpException) {
return response.status(exception.getStatus()).json({
statusCode: exception.getStatus(),
message: exception.message,
});
}
this.logger.error('Unhandled exception', exception instanceof Error ? exception.stack : exception);
return response.status(500).json({ statusCode: 500, message: 'Internal server error' });
}
}
Config
// config/app.config.ts
export default registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
jwtSecret: process.env.JWT_SECRET,
databaseUrl: process.env.DATABASE_URL,
}));
// access in service
constructor(private config: ConfigService) {}
const port = this.config.get<number>('app.port');
Never use process.env directly outside config files.
Performance rules
selectonly needed columns on read queries- Add
@@indexfor every field used in awherefilter ororderBy - Compound indexes for multi-field queries:
@@index([userId, createdAt(sort: Desc)]) - Avoid N+1: use
include/selectrelations or batch withfindMany({ where: { id: { in: ids } } }) - Paginate with
skip/take, or cursor pagination for large tables - Cache with
@nestjs/cache-manager(Redis) for expensive reads
Common mistakes
| Wrong | Right |
|---|---|
| Business logic in controller | Move to service |
any type anywhere |
Define interface/DTO |
console.log |
new Logger(ClassName.name) |
req.user directly |
@CurrentUser() decorator |
| Hard-coding env vars | ConfigService |
findMany() returning every column |
select only what the caller needs |
$queryRawUnsafe with user input |
Typed client or tagged $queryRaw |
| Dependent writes without a transaction | prisma.$transaction |
Related skills
nestjs-queue-architect— BullMQ async job patternspostgres-ops— migrations, indexes, query planserror-handling-expert— global error strategy
Use this skill
Connect your agent to the Skill Store MCP server and ask for it by ref: shipshitdev/nestjs-expert-16917174961.
Details
- Publisher
- shipshitdev
- Category
- Software Development › APIs & backend
- License
- MIT
- Updated
- 2026-10-17
References
More from shipshitdev
- Readme Sync Regenerate catalog counts, layout claims, and README summaries from canonical sources.
- Skill Auditor Audit the skills library for duplicates, stale content, spec violations, and structural issues.
- Skill Validator Validate SKILL.md files against the Agent Skills spec and Claude Code extensions.
- Agent Dispatch Router behind /agent.