← All posts
·13 min read

Claude Code with NestJS: Modules, DI, Guards, and Interceptors

Claude CodeNestJSBackendWorkflow
Claude Code with NestJS: Modules, DI, Guards, and Interceptors

Why NestJS needs explicit CLAUDE.md configuration

Claude Code writes TypeScript well. It writes NestJS code inconsistently without project context. The specific failure modes are predictable: providers instantiated with new instead of injected through the constructor, @Module imports that reference services directly instead of the feature module, guards applied at the handler level when they belong on the controller, and interceptors written as middleware rather than as NestInterceptor implementations.

Each of these breaks the NestJS DI container in ways that are not immediately obvious. The application starts. The tests pass if you wrote them against the manual instantiation. But the DI container does not manage the instance lifecycle, scope, and teardown that NestJS provides. You end up with connection leaks, providers that bypass the module system, and code that becomes difficult to test in isolation.

The fix is a CLAUDE.md that encodes what you would tell a new backend engineer joining the project. NestJS has a specific way of doing things. Document it once and Claude follows it for every file it touches.

If you have not set up Claude Code yet, the Claude Code setup guide covers installation and workspace configuration before the NestJS-specific steps below apply.

The NestJS CLAUDE.md template

This is the core configuration block. Add it to the root CLAUDE.md of any NestJS project.

# NestJS project rules

## Stack
- Node: 20.x
- NestJS: 10.x
- TypeScript: 5.x
- Database: PostgreSQL 16 (TypeORM 0.3.x) OR PostgreSQL 16 (Prisma 5.x). See Database section below.
- Package manager: pnpm

## Project structure
src/
  app.module.ts           # Root module: imports feature modules only
  main.ts                 # Bootstrap only: NestFactory.create + app.listen
  common/
    guards/               # Auth and role guards
    interceptors/         # Response transform, logging
    pipes/                # Validation, transformation
    filters/              # Exception filters
    decorators/           # Custom param + class decorators
  config/
    configuration.ts      # @nestjs/config factory function
  {feature}/
    {feature}.module.ts   # Feature module
    {feature}.controller.ts
    {feature}.service.ts
    {feature}.repository.ts (if using Repository pattern with TypeORM)
    dto/
      create-{feature}.dto.ts
      update-{feature}.dto.ts
    entities/
      {feature}.entity.ts

## Dependency injection rules (CRITICAL)
- NEVER instantiate a provider with `new` outside a test factory
- ALWAYS inject via constructor with type token: `constructor(private readonly usersService: UsersService) {}`
- ALWAYS declare providers in the module that owns them
- ALWAYS export a provider from its feature module before importing that module elsewhere
- NEVER import a service directly across module boundaries. Import the module, not the service.

## Module rules
- app.module.ts imports feature modules only. No controllers or services registered directly.
- Each feature module is self-contained: controllers, providers, exports declared inside
- ConfigModule.forRoot({ isGlobal: true }) is called in app.module.ts, never in feature modules
- TypeOrmModule.forRoot() or PrismaModule registered in app.module.ts only
- Feature modules use TypeOrmModule.forFeature([Entity]) for entity registration

## Command reference
- Dev server: pnpm start:dev (nodemon + ts-node)
- Build: pnpm build
- Tests: pnpm test (unit) / pnpm test:e2e (end-to-end)
- Lint: pnpm lint
- Format: pnpm format

## Hard rules
- NEVER use @Global() on feature modules, only on shared infrastructure modules
- NEVER put business logic in controllers, controllers handle HTTP only
- NEVER catch errors inside services and return null, throw NestJS exceptions
- ALWAYS use class-validator DTOs for request body and query validation
- ALWAYS validate with the global ValidationPipe (set in main.ts)

The DI rules section is the most important. Claude will reach for new UsersService() in a controller if not told otherwise, because it is the natural TypeScript pattern. The NestJS pattern requires declaring UsersService in the providers array of UsersModule, exporting it, importing UsersModule in the consuming module, and injecting via constructor. The CLAUDE.md makes that explicit rather than relying on Claude to infer it from the codebase.

Module structure and dependency injection

The module boundary issue is where Claude Code gets things wrong most often. Here is the correct pattern to include in your CLAUDE.md as a reference example:

## Module pattern reference

### Feature module (users.module.ts)
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'
import { User } from './entities/user.entity'

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],  // export if other modules need it
})
export class UsersModule {}

### Controller (users.controller.ts)
import { Controller, Get, Post, Body, Param, ParseIntPipe } from '@nestjs/common'
import { UsersService } from './users.service'
import { CreateUserDto } from './dto/create-user.dto'

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll()
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id)
  }

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto)
  }
}

### Service (users.service.ts)
import { Injectable, NotFoundException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { User } from './entities/user.entity'
import { CreateUserDto } from './dto/create-user.dto'

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}

  async findAll(): Promise<User[]> {
    return this.usersRepository.find()
  }

  async findOne(id: number): Promise<User> {
    const user = await this.usersRepository.findOneBy({ id })
    if (!user) throw new NotFoundException(`User #${id} not found`)
    return user
  }

  async create(dto: CreateUserDto): Promise<User> {
    const user = this.usersRepository.create(dto)
    return this.usersRepository.save(user)
  }
}

Two patterns in this reference are worth calling out.

ParseIntPipe in the controller @Param decorator converts the route string to a number and throws a BadRequestException if it is not a valid integer. Without it, Claude tends to write parseInt(id) inline in the service and return NaN-related errors. The pipe handles this at the HTTP boundary, which is where it belongs.

NotFoundException in the service rather than returning null is the NestJS convention. Claude's default is to return null or undefined when a record is not found, leaving the controller to check. In NestJS, services throw framework exceptions and the global exception filter handles HTTP status codes. This keeps error handling consistent across all endpoints.

For background on how Claude Code handles TypeScript projects more broadly, the Claude Code TypeScript guide covers the type-safety patterns that NestJS decorators build on.

Guards and interceptors

Guards and interceptors are where Claude Code diverges most sharply from NestJS conventions without explicit instruction. Claude knows what JWT authentication is. It does not always know to implement it as a CanActivate class registered with @UseGuards().

Add this to your CLAUDE.md:

## Guards

### Pattern: always implement CanActivate
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { Request } from 'express'

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>()
    const token = this.extractTokenFromHeader(request)
    if (!token) throw new UnauthorizedException()
    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: process.env.JWT_SECRET,
      })
      request['user'] = payload
    } catch {
      throw new UnauthorizedException()
    }
    return true
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? []
    return type === 'Bearer' ? token : undefined
  }
}

### Guard registration
- Apply globally (most common): app.useGlobalGuards(new JwtAuthGuard())
- Apply to controller: @UseGuards(JwtAuthGuard) above @Controller decorator
- Apply to specific route: @UseGuards(JwtAuthGuard) above @Get/@Post decorator
- NEVER apply guards inside service methods

### Role-based guard pattern
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { ROLES_KEY } from '../decorators/roles.decorator'
import { Role } from '../enums/role.enum'

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ])
    if (!requiredRoles) return true
    const { user } = context.switchToHttp().getRequest()
    return requiredRoles.some((role) => user.roles?.includes(role))
  }
}

The Reflector pattern for role-based access is important to document explicitly. Claude will reach for middleware-based role checking or a manual if (user.role !== 'admin') inside the service. The NestJS approach uses custom metadata (@Roles(Role.Admin)) applied to the controller method, with Reflector reading that metadata inside the guard. Without this pattern in CLAUDE.md, Claude will not generate it.

Interceptors follow the same principle:

## Interceptors

### Pattern: always implement NestInterceptor
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'

export interface Response<T> {
  data: T
  statusCode: number
  timestamp: string
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    const statusCode = context.switchToHttp().getResponse().statusCode
    return next.handle().pipe(
      map((data) => ({
        data,
        statusCode,
        timestamp: new Date().toISOString(),
      })),
    )
  }
}

### Logging interceptor
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'
import { Observable } from 'rxjs'
import { tap } from 'rxjs/operators'

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name)

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest()
    const { method, url } = request
    const now = Date.now()
    return next
      .handle()
      .pipe(tap(() => this.logger.log(`${method} ${url} - ${Date.now() - now}ms`)))
  }
}

### Interceptor registration
- Global: app.useGlobalInterceptors(new TransformInterceptor())
- Controller-level: @UseInterceptors(TransformInterceptor) on @Controller class
- Route-level: @UseInterceptors(TransformInterceptor) on handler method

Configuration with @nestjs/config

Configuration management is another area where Claude reverts to raw process.env access if not directed otherwise. @nestjs/config provides a typed configuration with validation, which is worth the setup.

## Configuration

### Setup in app.module.ts
import { ConfigModule } from '@nestjs/config'
import configuration from './config/configuration'

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
      envFilePath: ['.env.local', '.env'],
    }),
    ...
  ],
})
export class AppModule {}

### Configuration factory (config/configuration.ts)
export default () => ({
  port: parseInt(process.env.PORT, 10) || 3000,
  database: {
    host: process.env.DATABASE_HOST,
    port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
    name: process.env.DATABASE_NAME,
    username: process.env.DATABASE_USERNAME,
    password: process.env.DATABASE_PASSWORD,
  },
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '7d',
  },
})

### Inject in services
import { ConfigService } from '@nestjs/config'

@Injectable()
export class AuthService {
  constructor(private configService: ConfigService) {}

  private readonly jwtSecret = this.configService.get<string>('jwt.secret')
}

### Hard rules for configuration
- NEVER access process.env directly in services or controllers
- ALWAYS use ConfigService.get() with a typed path
- ALWAYS use the configuration factory pattern, not ConfigModule.forRoot({ envFilePath })  alone

The global module rule is important: ConfigModule.forRoot({ isGlobal: true }) means every feature module can inject ConfigService without importing ConfigModule explicitly. Without isGlobal: true, Claude will add ConfigModule to every feature module's imports array, which causes duplicate registration warnings.

TypeORM vs Prisma: which to document

NestJS officially supports both TypeORM and Prisma. The choice affects your CLAUDE.md configuration, so clarify it explicitly.

TypeORM is the native NestJS ORM, with first-class module integration via TypeOrmModule.forRoot() and TypeOrmModule.forFeature(). It uses class-based entities with decorators, which aligns with the NestJS decorator-heavy style. The repository pattern is built in. The downside is that TypeORM migrations are less predictable and the query builder API is verbose.

Prisma has better TypeScript ergonomics, safer migrations, and a cleaner query API. The integration with NestJS requires a custom PrismaService that extends PrismaClient and implements OnModuleInit. It does not use the TypeOrmModule integration, so the @InjectRepository decorator does not apply.

Add this decision to your CLAUDE.md:

## Database ORM: TypeORM 0.3.x

### Entity pattern
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ unique: true })
  email: string

  @Column()
  name: string

  @CreateDateColumn()
  createdAt: Date

  @UpdateDateColumn()
  updatedAt: Date
}

### Repository injection
- Use @InjectRepository(User) in services, NEVER instantiate Repository manually
- Use this.usersRepository.findOneByOrFail({ id }) when not-found is always an error
- Use this.usersRepository.find({ where: {}, order: {}, take: 20, skip: 0 }) for lists

### Migration commands
- Generate: pnpm typeorm migration:generate src/migrations/MigrationName -d src/data-source.ts
- Run: pnpm typeorm migration:run -d src/data-source.ts
- Revert: pnpm typeorm migration:revert -d src/data-source.ts
- NEVER use synchronize: true in production, migrations only

If your project uses Prisma instead, the Claude Code Prisma guide covers the full Prisma configuration including the PrismaService implementation, migration safety rules, and how to integrate Prisma with NestJS modules.

Testing with @nestjs/testing

NestJS provides a testing module that wires up the DI container for unit and e2e tests. Claude Code generates correct Test.createTestingModule() setups when given a reference pattern.

## Testing conventions

### Unit test pattern (users.service.spec.ts)
import { Test, TestingModule } from '@nestjs/testing'
import { getRepositoryToken } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { UsersService } from './users.service'
import { User } from './entities/user.entity'

describe('UsersService', () => {
  let service: UsersService
  let usersRepository: jest.Mocked<Repository<User>>

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: {
            find: jest.fn(),
            findOneBy: jest.fn(),
            create: jest.fn(),
            save: jest.fn(),
          },
        },
      ],
    }).compile()

    service = module.get<UsersService>(UsersService)
    usersRepository = module.get(getRepositoryToken(User))
  })

  it('findOne throws NotFoundException when user not found', async () => {
    usersRepository.findOneBy.mockResolvedValue(null)
    await expect(service.findOne(999)).rejects.toThrow(NotFoundException)
  })
})

### E2e test pattern (test/users.e2e-spec.ts)
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from './../src/app.module'

describe('Users (e2e)', () => {
  let app: INestApplication

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile()

    app = moduleFixture.createNestApplication()
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }))
    await app.init()
  })

  afterAll(async () => {
    await app.close()
  })

  it('GET /users returns 200', () => {
    return request(app.getHttpServer()).get('/users').expect(200)
  })
})

### Testing rules
- Unit tests use Test.createTestingModule with mocked providers, NEVER instantiate services manually
- E2e tests use the full AppModule with a test database
- ALWAYS call app.close() in afterAll, prevents open handle warnings
- ALWAYS register ValidationPipe in e2e test setup to match production behavior
- Use getRepositoryToken(Entity) for TypeORM repository mocking, not a string token

The getRepositoryToken(User) pattern is one Claude gets wrong without guidance. The TypeORM integration uses a Symbol-based token internally, and getRepositoryToken(Entity) returns the correct token for that entity's repository. If Claude uses a string like 'UserRepository' instead, the DI container cannot match it and the test fails with a "Nest can't resolve dependencies" error.

The app.close() rule prevents a common test suite issue where Jest reports open handles after tests complete. NestJS applications hold database connections and server listeners open. app.close() triggers the OnModuleDestroy lifecycle hooks that clean them up.

For testing patterns beyond NestJS unit tests, the Claude Code testing guide covers Jest configuration, coverage thresholds, and e2e pipeline setup that applies to any TypeScript backend project.

Permission hooks for NestJS development

Claude Code's permission system gives you a safety net for NestJS-specific commands. Add this to .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(pnpm start:dev)",
      "Bash(pnpm test*)",
      "Bash(pnpm build)",
      "Bash(pnpm lint)",
      "Bash(pnpm format)",
      "Bash(pnpm typeorm migration:generate*)",
      "Bash(pnpm typeorm migration:run*)",
      "Bash(pnpm typeorm migration:show*)"
    ],
    "deny": [
      "Bash(pnpm typeorm migration:revert*)",
      "Bash(pnpm typeorm schema:drop*)",
      "Bash(pnpm typeorm schema:sync*)"
    ]
  }
}

schema:drop drops every table. schema:sync applies schema changes directly without a migration file, bypassing your migration history. Both are valid in development but catastrophic if run against the wrong database. The deny list forces Claude to surface these as suggestions rather than running them automatically.

For a complete walkthrough of how permission hooks work across any Claude Code project, the Claude Code hooks guide covers the full permission system, bash hook configuration, and pre/post tool hooks.

What Claude Code does well in NestJS once configured

After the CLAUDE.md is in place, several NestJS tasks become reliable one-shot operations.

Feature module scaffolding. Ask Claude to create a posts feature module and it will generate posts.module.ts, posts.controller.ts, posts.service.ts, the entity, and the DTOs with the correct structure. It registers the module in app.module.ts and adds the TypeORM entity registration automatically.

DTO generation from a description. Describe the shape of a request body and Claude generates a class-validator DTO with the correct decorators (@IsString(), @IsEmail(), @IsOptional(), @Min(), etc.). The whitelist: true and transform: true options in the global ValidationPipe mean that unknown fields are stripped and type coercion happens automatically.

Exception filter implementation. Claude generates HttpExceptionFilter implementations that catch specific exception types and return consistent error shapes, following the ExceptionFilter interface correctly.

OpenAPI/Swagger documentation. With @nestjs/swagger installed and the DocumentBuilder setup in main.ts, Claude adds @ApiProperty() decorators to DTOs and @ApiTags(), @ApiOperation(), and @ApiResponse() to controllers when you ask it to document an endpoint.

The underlying principle is the same as any Claude Code best practices workflow: the quality of output matches the quality of the context provided. A bare NestJS project produces Claude that knows TypeScript but not the NestJS DI lifecycle. A project with the CLAUDE.md above produces Claude that generates correct module structure, uses the @InjectRepository token pattern, throws NotFoundException instead of returning null, and wires up the ValidationPipe in test setups.

Start with the CLAUDE.md template above. Add the guards and interceptors reference section when you implement authentication. Add the testing conventions block before writing your first service test. Each section you add reduces the back-and-forth needed to produce code that fits your NestJS project's conventions. Claudify includes a NestJS-specific CLAUDE.md template as part of the Claude Code workflow kit, pre-configured with module structure rules, DI guardrails, guard and interceptor patterns, and typed testing conventions for TypeORM and Prisma projects.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir