NestJS 依赖注入与模块设计:容器如何接管你的对象

NestJS 用模块组织资源、用容器创建实例:构造函数里声明依赖,容器负责注入。本文讲清 Provider 的各种写法、作用域、全局模块、分层、横切能力的执行顺序与常见报错的排查路径。

  • NestJS
  • 概念
  • 入门
  • 更新于 2026-10-31

很多人在 NestJS 里第一次遇到「不能自己 new 一个依赖」时会觉得别扭:为什么不能 new PrismaService()?答案在于,NestJS 的价值恰恰在于替你管理对象的创建与生命周期——一旦你手动 new,依赖注入带来的可测试、可替换、可配置就全部失效了。

理解 NestJS 只需要抓住两条约定:一个资源一个模块,实例由容器创建。这两条一旦想明白,NestJS 的目录结构、报错信息、启动顺序就都不再是玄学。这篇把容器的运行机制、Provider 的各种写法、作用域、分层、横切能力执行顺序,以及最常见的那条报错该怎么查,全部讲透。

一、容器到底做了什么

NestJS 应用启动时,会扫描所有模块,解析出一张依赖图,然后按拓扑顺序实例化 Provider:

  1. 扫描 @Module() 装饰器,收集 controllers、providers、imports、exports;
  2. 读取每个 Provider 构造函数参数的依赖类型;
  3. 按依赖顺序创建实例:先创建被依赖的,再创建依赖它的;
  4. 把实例缓存起来,后续注入时直接复用。

这就是「控制反转」——对象的创建权从业务代码手里交给了容器。带来的直接收益:

收益 具体表现
可测试 单测里换成 mock Provider,业务代码一行不改
可替换 换实现只改 Provider 的绑定,使用方无感
不重复 默认单例作用域,一份实例处处复用,不会到处 new
生命周期统一 配置、连接、清理都有明确钩子,不用散落在各处的副作用代码

容器管的是三件事:实例从哪来、活多久、谁能拿到。它不替你写业务逻辑,也不替你决定路由怎么设计。它只保证一件事:当 CardsService 声明「我要一个 PrismaService」时,容器能立刻递上一个已经初始化好的实例。

还有一个容易被忽略的好处:依赖缺失在启动阶段就暴露,而不是等到第一个请求打进来才报错。容器建不出实例就根本起不来,于是「忘了写 imports」这类问题在你打开浏览器之前就已经被拦下了。

二、模块:结构的最小单位

一个模块用 @Module() 声明四件事:

// server/src/cards/cards.module.ts
import { Module } from '@nestjs/common';
import { CardsController } from './cards.controller';
import { CardsService } from './cards.service';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  controllers: [CardsController], // 管路由、收参数、返回响应
  providers: [CardsService],      // 管业务逻辑与数据访问
  imports: [PrismaModule],       // 依赖别的模块导出的能力
  exports: [CardsService],        // 导出之后别的模块才能注入它
})
export class CardsModule {}

四个字段的语义:

  • controllers:这个模块对外暴露哪些 HTTP 端点;
  • providers:这个模块自己提供的能力(服务、仓储、工厂、第三方 SDK 的适配器);
  • imports:依赖哪些别的模块;
  • exports:把哪些能力开放给外部。

exports 是最常被漏掉的一环:模块 A 导入了模块 B,但 B 没有 exports 自己的 Provider,A 里就注入不到它,容器会直接报「找不到依赖」。

根模块把一切串起来

// server/src/app.module.ts
import { Module } from '@nestjs/common';
import { CardsModule } from './cards/cards.module';
import { CommentsModule } from './comments/comments.module';

@Module({
  imports: [CardsModule, CommentsModule],
})
export class AppModule {}

模块内部的标准分层

cards/
├── cards.module.ts        # 组装:声明 controller / provider / import / export
├── cards.controller.ts    # HTTP 层:路由、参数、状态码
├── cards.service.ts       # 业务层:业务规则、编排
├── cards.repository.ts    # 数据层:SQL / ORM 调用
└── dto/
    ├── create-card.dto.ts # 入参形状与校验规则
    └── card.dto.ts        # 出参形状

职责边界要清楚:Controller 只做参数收发,Service 只做业务,Repository 只做数据访问。业务逻辑沉在 Service,Controller 就不必知道数据库的存在,Service 也不必知道 HTTP 的存在——这样才能换存储、换协议、单独测试。

还有一点必须想明白:模块是注册表,不是文件夹。文件放在哪个目录由你和构建工具决定,真正决定边界的是 imports 与 exports。两个目录毫不相干的文件可以属于同一个模块,同一个目录下的文件也可以分属不同模块。

三、动手:从零搭一个 cards 模块

前面讲的都是概念,接下来把一个完整的业务模块搭出来。这套代码可以直接跑起来:cards 模块负责卡片接口,database 模块负责连接池,auth 模块负责认证,common 目录放全局横切能力。跑通之后,你对「模块边界」「容器注册」「请求生命周期」三件事的理解会比看十篇文章都扎实。

3.1 目录与依赖

server/
├── src/
│   ├── main.ts                    # 入口:装配全局能力、监听端口、优雅退出
│   ├── app.module.ts              # 根模块:只做组装
│   ├── common/
│   │   ├── response.interceptor.ts # 统一响应体
│   │   └── http-exception.filter.ts # 统一错误格式
│   ├── auth/
│   │   ├── auth.module.ts         # 认证模块 + 全局守卫注册
│   │   ├── auth.service.ts        # 令牌校验
│   │   ├── jwt-auth.guard.ts      # 认证守卫
│   │   └── public.decorator.ts    # 标记公开接口
│   ├── database/
│   │   ├── database.module.ts     # 全局模块:配置 → 工厂 → 连接池
│   │   ├── database.tokens.ts     # 自定义注入令牌
│   │   └── pool-cleanup.ts        # 关停时释放连接池
│   └── cards/
│       ├── cards.module.ts        # 业务模块
│       ├── cards.controller.ts
│       ├── cards.service.ts
│       ├── cards.repository.ts
│       └── dto/
│           ├── create-card.dto.ts
│           ├── list-cards.query.dto.ts
│           └── card.dto.ts
├── package.json
└── tsconfig.json
{
  "name": "nest-di-demo",
  "private": true,
  "scripts": {
    "build": "nest build",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "test": "jest"
  },
  "dependencies": {
    "@nestjs/common": "^10.0.0",
    "@nestjs/config": "^3.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/platform-express": "^10.0.0",
    "@nestjs/testing": "^10.0.0",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.14.1",
    "pg": "^8.11.0",
    "reflect-metadata": "^0.2.2",
    "rxjs": "^7.8.1"
  },
  "devDependencies": {
    "@nestjs/cli": "^10.0.0",
    "@types/jest": "^29.5.12",
    "@types/node": "^20.14.0",
    "jest": "^29.7.0",
    "ts-jest": "^29.1.2",
    "typescript": "^5.4.0"
  }
}
{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": false,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2021",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": true,
    "forceConsistentCasingInFileNames": true,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "noFallthroughCasesInSwitch": false
  }
}

experimentalDecorators 和 emitDecoratorMetadata 这两项是硬性前提:少了前者装饰器语法直接编译报错,少了后者容器读不到构造函数参数的类型元数据,依赖注入无从谈起。

3.2 入口文件 main.ts

入口文件是整个应用的装配台:横切能力在这里注册,端口在这里监听,关停钩子在这里开启。

// server/src/main.ts —— 应用入口:装配全局横切能力 + 监听端口 + 优雅退出
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/http-exception.filter';
import { ResponseInterceptor } from './common/response.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 全局管道:入参白名单 + 类型转换,多余字段直接拒绝
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: true,
    }),
  );

  // 全局拦截器:把控制器返回值统一包成 { ok: true, data }
  app.useGlobalInterceptors(new ResponseInterceptor());

  // 全局过滤器:把任何异常统一成 { ok: false, error }
  app.useGlobalFilters(new HttpExceptionFilter());

  // 端口来自环境变量,默认 3000;真实密钥只放 .env,仓库里只提交 .env.example
  const port = Number(process.env.PORT ?? 3000);

  // 开启关停钩子:收到 SIGTERM / SIGINT 时才触发 onModuleDestroy 等回调
  app.enableShutdownHooks();

  await app.listen(port);
  Logger.log(`服务已启动,监听端口 ${port}`, 'Bootstrap');
}

bootstrap();

3.3 根模块 app.module.ts

// server/src/app.module.ts —— 根模块只做组装,不写业务
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { CardsModule } from './cards/cards.module';
import { DatabaseModule } from './database/database.module';

@Module({
  imports: [
    DatabaseModule, // 基础设施:全局模块,装一次到处可用
    AuthModule,     // 认证:注册全局守卫
    CardsModule,    // 业务模块
  ],
})
export class AppModule {}

3.4 业务模块 cards.module.ts

// server/src/cards/cards.module.ts —— 声明本模块提供什么、用什么、对外给什么
import { Module } from '@nestjs/common';
import { CardsController } from './cards.controller';
import { CardsRepository } from './cards.repository';
import { CardsService } from './cards.service';

@Module({
  controllers: [CardsController],
  providers: [CardsService, CardsRepository],
  // 注意:imports 里没有 DatabaseModule —— 它是 @Global(),全局模块不用重复导入
  exports: [CardsService],
})
export class CardsModule {}

3.5 控制器 cards.controller.ts

// server/src/cards/cards.controller.ts —— HTTP 层:只管路由、参数与状态码
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { CardsService } from './cards.service';
import { CreateCardDto } from './dto/create-card.dto';
import { ListCardsQueryDto } from './dto/list-cards.query.dto';

@Controller('cards')
export class CardsController {
  constructor(private readonly cardsService: CardsService) {}

  @Get()
  list(@Query() query: ListCardsQueryDto) {
    return this.cardsService.findAll(query);
  }

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

  @Post()
  create(@Body() dto: CreateCardDto) {
    return this.cardsService.create(dto);
  }
}

3.6 服务 cards.service.ts

// server/src/cards/cards.service.ts —— 业务层:规则与编排,不知道 HTTP 也不直接写 SQL
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CardsRepository } from './cards.repository';
import { CreateCardDto } from './dto/create-card.dto';
import { ListCardsQueryDto } from './dto/list-cards.query.dto';

@Injectable()
export class CardsService {
  constructor(private readonly repository: CardsRepository) {}

  findAll(query: ListCardsQueryDto) {
    return this.repository.findAll(query);
  }

  async findOne(id: number) {
    const card = await this.repository.findOne(id);
    // 业务层负责把「查不到」翻译成对 HTTP 友好的异常
    if (!card) throw new NotFoundException(`卡片 ${id} 不存在`);
    return card;
  }

  async create(dto: CreateCardDto) {
    const title = dto.title.trim();
    if (!title) throw new BadRequestException('标题不能为空');
    return this.repository.create({ ...dto, title });
  }
}

3.7 仓储 cards.repository.ts

// server/src/cards/cards.repository.ts —— 数据层:只负责 SQL,不含业务判断
import { Inject, Injectable } from '@nestjs/common';
import { Pool } from 'pg';
import { DATABASE_POOL } from '../database/database.tokens';
import { CardDto } from './dto/card.dto';
import { CreateCardDto } from './dto/create-card.dto';
import { ListCardsQueryDto } from './dto/list-cards.query.dto';

@Injectable()
export class CardsRepository {
  // 用自定义令牌注入,构造函数的类型元数据只用来识别这个类
  constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {}

  async findAll(query: ListCardsQueryDto): Promise<CardDto[]> {
    const limit = query.limit ?? 20;
    // 参数化查询:值走占位符,不拼字符串
    const { rows } = await this.pool.query(
      'SELECT id, title, content, created_at FROM cards ORDER BY id DESC LIMIT $1',
      [limit],
    );
    // 数据库的蛇形列名转成对外的驼峰字段
    return rows.map((row) => ({
      id: row.id,
      title: row.title,
      content: row.content,
      createdAt: row.created_at,
    }));
  }

  async findOne(id: number): Promise<CardDto | null> {
    const { rows } = await this.pool.query(
      'SELECT id, title, content, created_at FROM cards WHERE id = $1',
      [id],
    );
    if (rows.length === 0) return null;
    const row = rows[0];
    return { id: row.id, title: row.title, content: row.content, createdAt: row.created_at };
  }

  async create(dto: CreateCardDto): Promise<CardDto> {
    const { rows } = await this.pool.query(
      'INSERT INTO cards (title, content) VALUES ($1, $2) RETURNING id, title, content, created_at',
      [dto.title, dto.content ?? ''],
    );
    const row = rows[0];
    return { id: row.id, title: row.title, content: row.content, createdAt: row.created_at };
  }
}

3.8 DTO:入参校验与出参形状

// server/src/cards/dto/create-card.dto.ts —— 入参形状与校验规则
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';

export class CreateCardDto {
  @IsString()
  @MinLength(1)
  @MaxLength(120)
  title: string;

  @IsOptional()
  @IsString()
  @MaxLength(2000)
  content?: string;
}
// server/src/cards/dto/list-cards.query.dto.ts —— 查询参数也要过校验
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';

export class ListCardsQueryDto {
  @IsOptional()
  @Type(() => Number) // 配合全局管道的 transform,字符串先转数字再校验
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number;
}
// server/src/cards/dto/card.dto.ts —— 出参形状:接口返回什么由这里定义
export class CardDto {
  id: number;
  title: string;
  content: string;
  createdAt: string;
}

DTO 的写法细节和前后端共享类型的那套思路可以对照着看,站内《前后端共享 DTO 类型》讲得更细。

3.9 跑起来看看

npm install                # 安装依赖
npm run start:dev          # 开发模式启动,文件改动自动重启
# 请求卡片列表:认证守卫默认拦截,这里先看路由与错误格式是否生效
try {
  Invoke-WebRequest -Uri 'http://localhost:3000/cards' -UseBasicParsing |
    Select-Object -ExpandProperty Content
} catch {
  # 401 在 PowerShell 里以异常形式抛出,响应体要从异常里取
  $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
  $reader.ReadToEnd()
}

正常情况下你会拿到 401,而错误体已经是 { ok: false, error: { status: 401, ... } } 这个形状——说明全局过滤器生效了。带上令牌再试一次:

# 演示版 AuthService 只认 demo.开头的令牌
Invoke-WebRequest -Uri 'http://localhost:3000/cards' -Headers @{ Authorization = 'Bearer demo.1' } -UseBasicParsing |
  Select-Object -ExpandProperty Content

这一步会打到数据库:上面的查询语句假定 cards 表已经存在,文章里只给了读写语句,没有给建表脚本。表没建好时仓储会抛数据库错误,全局过滤器会把它收成 500 的统一格式——这正好演示了异常兜底的作用。想跳过认证也可以给接口加 @Public()(见第八节)。

四、Provider:容器的供给方式

providers 数组里的每一项都是一个 Provider,不止是类。

@Module({
  providers: [
    // 1) 类:最常见,需要 @Injectable() 标记
    CardsService,

    // 2) useClass:同一个类换实现
    { provide: CardsService, useClass: MockCardsService },

    // 3) useValue:直接给一个现成的值
    { provide: LOGGER, useValue: console },

    // 4) useFactory:运行时根据配置创建
    {
      provide: DB_CLIENT,
      inject: [ConfigService],
      useFactory: (config: ConfigService) => createClient(config.get('DATABASE_URL')),
    },

    // 5) useExisting:把已存在的另一个 Provider 别名过来
    { provide: CardStore, useExisting: CardsRepository },
  ],
})
export class CardsModule {}

五种供给方式的区别,一句话各说清楚:

写法 容器做什么 典型场景
类 用类型元数据反射出构造函数,new 出来并注入依赖 绝大多数业务服务
useClass 换一个类的实现来满足同一个令牌 同一接口的两种实现,按环境切换
useValue 直接给一个现成对象,不再创建 常量、测试替身、外部已建好的实例
useFactory 调你的工厂函数,由你决定怎么造 需要配置、SDK、异步初始化
useExisting 给已存在的 Provider 换个别名,不新建实例 同一能力换名字对外提供

useClass 与 useExisting 的差别经常被问:useClass 会新建一个实例,useExisting 复用原实例。需要「另一个令牌指向同一个单例」时用 useExisting;需要「同令牌换实现」时用 useClass。

自定义注入令牌

当注入的不是类(比如一个字符串、一个配置值、一个第三方 SDK 的实例)时,用令牌(token):

// 用 Symbol 更安全,不会和别的字符串撞车
export const DB_CLIENT = Symbol('DB_CLIENT');
@Injectable()
export class CardsService {
  constructor(@Inject(DB_CLIENT) private readonly db: DatabaseClient) {}
}

令牌既可以是字符串,也可以是 Symbol。用字符串时全项目要保持完全一致(大小写敏感),否则容器会安静地找不到依赖;用 Symbol 则天然不会撞车。真实的项目里建议把令牌集中放在 tokens.ts 之类的文件里导出,避免同一个令牌在两处各写一遍。

构造函数注入的原理

@Injectable()
export class CardsService {
  constructor(private readonly prisma: PrismaService) {}
}

看起来只是普通构造函数参数,但 emitDecoratorMetadata 会在编译期为参数类型发射元数据,容器据此知道要注入哪个 Provider。这就是为什么 NestJS 项目必须开 experimentalDecorators 和 emitDecoratorMetadata——少了前者装饰器语法直接报错,少了后者容器无法解析依赖类型。

第三方 SDK 怎么办

PrismaService 这类自己写的类加 @Injectable() 即可。但很多第三方 SDK 的类无法加装饰器(不是你写的),这时用工厂:

{
  provide: PrismaClient,
  inject: [ConfigService],
  useFactory: (config: ConfigService) => new PrismaClient({ datasources: { url: config.get('DATABASE_URL') } }),
},

这样容器照样能管理它的创建时机。

五、Provider 工厂与配置注入:从环境变量到数据库客户端

这一节把第四节里的 useFactory 展开成一条完整的链路:环境变量 → ConfigModule → 工厂 → 全局模块导出 → Service 注入。

5.1 不要在业务代码里直接读 process.env

// 反例:每个用到配置的地方都自己读环境变量,测试时无法替换
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

问题不在于读环境变量,而在于读完之后这个值就固化在代码里了:测试里没法换、类型是 string | undefined、拼错 key 不会报错。正确做法是让配置成为容器里的一等公民。

5.2 配置模块

// server/src/database/database.module.ts —— 全局基础设施:配置 → 工厂 → 连接池
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Pool } from 'pg';
import { DATABASE_POOL } from './database.tokens';
import { PoolCleanup } from './pool-cleanup';

@Global()
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,               // 配置在全局可见,业务模块不用重复导入
      envFilePath: ['.env.local', '.env'],
      cache: true,                  // 缓存读取结果,避免反复读文件
    }),
  ],
  providers: [
    {
      // 自定义令牌 + 工厂:容器负责创建连接池
      provide: DATABASE_POOL,
      inject: [ConfigService],       // 声明工厂需要什么,容器负责喂进来
      useFactory: (config: ConfigService) => {
        // getOrThrow:拿不到就抛错,而不是把 undefined 传下去
        const connectionString = config.getOrThrow<string>('DATABASE_URL');
        return new Pool({ connectionString, max: 10 });
      },
    },
    PoolCleanup,                    // 关停时释放连接池
  ],
  exports: [DATABASE_POOL],         // 导出令牌,业务模块才能注入
})
export class DatabaseModule {}
// server/src/database/database.tokens.ts —— 令牌集中管理
export const DATABASE_POOL = Symbol('DATABASE_POOL');

这段代码里有四个要点值得单独记住:

  1. inject 声明工厂的依赖。工厂不是凭空能拿到东西的,inject: [ConfigService] 就是在告诉容器「调用它之前,先把 ConfigService 建好传进来」。
  2. useFactory 可以是异步的。返回 Promise 时容器会等它完成再注入下游,异步初始化因此不必塞进构造函数。
  3. getOrThrow 比 get 更安全。get 拿不到时返回 undefined,错误会推迟到第一次用连接池时才爆出来;getOrThrow 在启动时就抛错。
  4. @Global() + exports 让能力到处可用。业务模块不用写 imports: [DatabaseModule],直接 @Inject(DATABASE_POOL) 就行。

5.3 Service 里注入

// server/src/cards/cards.repository.ts —— 工厂造好的连接池,在这里被注入
@Injectable()
export class CardsRepository {
  constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {}
}

到这里,整条链路闭合了:环境变量在 .env 里 → ConfigModule 读进来 → 工厂用配置造出连接池 → 全局模块导出令牌 → 仓储注入令牌。业务代码里没有一行 process.env,也没有一行 new Pool()。

5.4 工厂造出来的资源要负责关掉

连接池是工厂创建的,但释放它的责任得有人认领——通常是实现生命周期钩子的 Provider:

// server/src/database/pool-cleanup.ts —— 关停时释放连接池
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import { Pool } from 'pg';
import { DATABASE_POOL } from './database.tokens';

@Injectable()
export class PoolCleanup implements OnModuleDestroy {
  constructor(@Inject(DATABASE_POOL) private readonly pool: Pool) {}

  async onModuleDestroy() {
    await this.pool.end(); // 关闭所有空闲连接
  }
}

这条规则值得单独记住:容器负责创建,就该由容器负责销毁。自己 new 出来的东西没人会替你关。

六、作用域:一份实例还是每个一份

Provider 默认是单例(DEFAULT scope):容器创建一次,之后一直复用。大多数服务都该是单例。

@Injectable({
  scope: Scope.TRANSIENT, // 每次注入都新建一个实例
})
export class RequestContextService {}
@Injectable({
  scope: Scope.REQUEST, // 每个请求一份
})
export class UserContextService {}

三种作用域:

作用域 实例数量 适用 代价
DEFAULT(单例) 整个应用一份 无状态服务、数据库客户端 无
TRANSIENT 每次注入新建 需要独立状态的轻量工具 每次注入都创建,注意别在单例里注入它
REQUEST 每个请求一份 需要请求上下文(当前用户、租户) 依赖链会整体降级为请求作用域,实例不再复用

REQUEST 作用域会向上传播:如果一个单例 Provider 注入了请求作用域的 Provider,容器会把这个单例也变成请求作用域(否则单例里存着上一个请求的状态就出事了)。这是性能敏感场景里的常见坑。

选型的判断顺序很简单:默认用单例;确实需要每次一份的独立状态,才用 TRANSIENT;只有真的要读当前请求的上下文时,才用 REQUEST,并且要意识到它会把整条依赖链降级。

七、全局模块:把基础设施提出来

数据库客户端、配置这类每个模块都要用的东西,做成全局模块一次注册:

// server/src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

业务模块就不用再 imports: [PrismaModule] 了,直接注入即可。

什么时候用全局模块:配置、数据库连接、消息队列这类真正的基础设施,用它;普通的业务模块不要图省事全做成全局——模块之间的依赖关系越清晰,越容易排查问题。

配置模块的标准写法

// server/src/config/config.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Global()
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env.local', '.env'],
      cache: true,
    }),
  ],
  exports: [ConfigModule],
})
export class AppConfigModule {}

真实密钥只放 .env,仓库里只提交 .env.example。

全局模块是一种可见性豁免,不是一种「更好的模块」。用它的代价是:模块之间的依赖关系不再写在代码里,排查时得靠记忆。所以只给真正的基础设施开这个口子。

八、横切能力:同一个请求会依次经过哪些关卡

NestJS 把「每个请求都要做的事」做成了可插拔的关卡,它们的执行顺序是固定的:

Middleware → Guards → Interceptors(前置)→ Pipes → 控制器方法 → Service
                                                    ↓
                          Interceptors(后置)→ Filters(异常兜底)
关卡 职责 典型用途
Middleware Express 层中间件 请求日志、原始请求体处理
Guard 能不能进 JWT 校验、角色授权
Interceptor 包装执行 统一响应体、耗时统计、超时
Pipe 入参校验与转换 ValidationPipe + DTO
Filter 异常兜底 统一错误格式
Controller / Service 真正的业务

这四件套(认证 Guard、统一响应体、统一错误格式、全局管道注册)装在 main.ts 里,这就是横切能力的全部装配工作。

Guard:认证与授权

守卫依赖的认证服务自己实现,演示版只认 demo. 开头的令牌:

// server/src/auth/auth.service.ts —— 校验并解析令牌
import { Injectable, UnauthorizedException } from '@nestjs/common';

export interface User {
  id: number;
  name: string;
}

@Injectable()
export class AuthService {
  // 演示实现:真实项目应校验 JWT 的签名、有效期与吊销状态
  verify(token: string): User {
    if (!token.startsWith('demo.')) throw new UnauthorizedException('令牌无效');
    const id = Number(token.slice('demo.'.length));
    if (!Number.isInteger(id)) throw new UnauthorizedException('令牌无效');
    return { id, name: `用户 ${id}` };
  }
}
// server/src/auth/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly authService: AuthService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<{ user?: User }>();
    const token = request.headers.authorization?.replace('Bearer ', '');
    if (!token) throw new UnauthorizedException('缺少令牌');

    request.user = this.authService.verify(token);
    return true;
  }
}

Guard 返回 false 或抛异常时,请求不会进入控制器。这是鉴权的正确位置——不要在 Service 里手写 if (!user) throw。

要让守卫默认拦住所有接口、个别接口放行,靠元数据标记:

// server/src/auth/public.decorator.ts —— 用元数据标记「这个接口不需要登录」
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// server/src/auth/jwt-auth.guard.ts —— 在原来的 canActivate 开头加一段放行逻辑
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './public.decorator';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly authService: AuthService,
    private readonly reflector: Reflector,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    // 标记了 @Public() 的接口直接放行:先看方法,再看控制器
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    // 下面是原来的令牌校验逻辑
    const request = context.switchToHttp().getRequest<{ user?: User }>();
    const token = request.headers.authorization?.replace('Bearer ', '');
    if (!token) throw new UnauthorizedException('缺少令牌');

    request.user = this.authService.verify(token);
    return true;
  }
}
// server/src/auth/auth.module.ts —— 用 APP_GUARD 把守卫注册成全局守卫
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';

@Module({
  providers: [
    AuthService,
    // APP_GUARD 让容器负责创建守卫,守卫内部才能注入 AuthService、Reflector
    { provide: APP_GUARD, useClass: JwtAuthGuard },
  ],
  exports: [AuthService],
})
export class AuthModule {}

用 APP_GUARD 而不是 app.useGlobalGuards(new JwtAuthGuard()) 的原因很实际:前者由容器创建,守卫内部可以注入依赖;后者传进去的是你自己 new 出来的对象,它的构造函数只能吃字面量。

Interceptor:统一响应体

// server/src/common/response.interceptor.ts
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, { ok: true; data: T }> {
  intercept(_context: ExecutionContext, next: CallHandler<T>) {
    return next.handle().pipe(map((data) => ({ ok: true, data })));
  }
}

Filter:统一错误格式

// server/src/common/http-exception.filter.ts
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;

    response.status(status).json({
      ok: false,
      error: { status, message: status === 500 ? '服务器内部错误' : (exception as Error).message },
    });
  }
}

这三个关卡组合起来,前端拿到的响应格式就是稳定的:成功一律 { ok: true, data },失败一律 { ok: false, error: { status, message } }。

同一个请求里,它们各自做了什么

以一次 POST /cards 为例(下面是按代码推导出的预期结果,不是某次抓包的实测输出):

阶段 谁在处理 这一步做了什么 失败时的表现
Middleware Express 记录原始请求 交给下一层
Guard JwtAuthGuard 校验令牌,失败直接抛 401 401,且不会进入管道与控制器
Interceptor 前置 ResponseInterceptor 记录开始时间,准备包装返回值 交给下一层
Pipe ValidationPipe 校验并转换 CreateCardDto 400,message 是校验文案数组
Controller CardsController 取出 DTO,调用 Service —
Service CardsService 执行业务规则,调用 Repository 抛 NotFoundException 则转成 404
Interceptor 后置 ResponseInterceptor 把返回值包成 { ok: true, data } —
Filter HttpExceptionFilter 兜住所有异常,统一成 { ok: false, error } 500 时隐藏内部细节

注意 Guard 抛出的异常同样会经过 Filter,所以 401 也是 { ok: false, error: { status: 401, message: '缺少令牌' } } 这种形状。这正是把 Filter 注册成全局的意义:不管异常从哪个关卡冒出来,出口只有一个。

成功与失败的响应大致是这样(预期形状):

{
  "ok": true,
  "data": { "id": 12, "title": "周会纪要", "content": "", "createdAt": "2026-10-31T09:12:03.000Z" }
}
{
  "ok": false,
  "error": { "status": 404, "message": "卡片 999 不存在" }
}

九、生命周期钩子

Provider 可以实现接口,让容器在合适的时机调用它:

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect(); // 容器创建完实例后调用
  }

  async onModuleDestroy() {
    await this.$disconnect(); // 应用关闭时调用,用于释放连接
  }
}

常用的还有 OnApplicationBootstrap(所有模块初始化完成后)、OnApplicationShutdown。数据库连接、消息队列订阅、定时任务注册都放在这里,而不是塞进构造函数——构造函数里做异步初始化,容器是等不到的。

四个钩子的时机差别不大,但排查问题时很管用:

钩子 触发时机 适合放什么
OnModuleInit 实例创建后、模块初始化阶段 建立连接、预热缓存
OnApplicationBootstrap 所有模块 OnModuleInit 之后 依赖别的模块已就绪的启动逻辑
OnModuleDestroy 收到关停信号、模块销毁阶段 释放连接、停止消费
OnApplicationShutdown 全部销毁流程走完之后 最后的收尾与上报

钩子要写成 async 并 await:容器会等你的 Promise 结束,没 await 的话进程可能在清理完成前就退出了。而且要先在 main.ts 里调用 app.enableShutdownHooks(),否则收到 SIGTERM 时这些钩子根本不会被触发——这是「服务重启时连接没关干净」这类问题的第一嫌疑。

十、测试:依赖注入最大的回报

因为实例是容器给的,测试时可以直接把真实 Provider 换成 mock:

// server/src/cards/cards.service.spec.ts
import { Test } from '@nestjs/testing';
import { CardsService } from './cards.service';
import { CardsRepository } from './cards.repository';

describe('CardsService', () => {
  let service: CardsService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [CardsService, CardsRepository],
    })
      .overrideProvider(CardsRepository)
      .useValue({ findAll: () => [{ id: 1, title: '测试卡片' }] })
      .compile();

    service = moduleRef.get(CardsService);
  });

  it('应当返回全部卡片', () => {
    expect(service.findAll()).toEqual([{ id: 1, title: '测试卡片' }]);
  });
});

业务代码里没有任何 new,也没有任何 if (process.env.NODE_ENV === 'test'),测试替身却能干净地插进去。这就是「不自己 new」换来的东西。

overrideProvider 有三个要点:

  1. 令牌必须对得上。注入用的是类就 overrideProvider(CardsService),用的是自定义令牌就 overrideProvider(DATABASE_POOL)——令牌不一致时 mock 不会生效,你会看到「mock 好像没起作用」。
  2. 被替换的 Provider 得在当前测试模块里存在,否则会报「找不到这个 Provider 可以覆盖」。
  3. useValue 替掉的是整个实例,原类的构造函数不会再执行,它自己的依赖也不会被解析。这正是我们想要的:把仓储换成假实现之后,连接池根本不会被创建。

连模块一起测

单测测的是「一个类 + 假依赖」。如果想连控制器一起测,把整个模块装进来,只替换最底层的仓储:

// server/src/cards/cards.module.spec.ts —— 模块级测试:整条依赖链都在,只换最底层
import { Test } from '@nestjs/testing';
import { CardsController } from './cards.controller';
import { CardsModule } from './cards.module';
import { CardsRepository } from './cards.repository';

describe('CardsModule', () => {
  let controller: CardsController;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [CardsModule],
    })
      .overrideProvider(CardsRepository)
      .useValue({
        findAll: jest.fn().mockResolvedValue([{ id: 1, title: '测试卡片' }]),
        findOne: jest.fn().mockResolvedValue(null),
        create: jest.fn().mockResolvedValue({ id: 2, title: '新卡片' }),
      })
      .compile();

    controller = moduleRef.get(CardsController);
  });

  it('列表接口应返回仓储给的数据', async () => {
    await expect(controller.list({ limit: 10 })).resolves.toEqual([
      { id: 1, title: '测试卡片' },
    ]);
  });

  it('查不到卡片时应抛出 NotFoundException', async () => {
    await expect(controller.findOne(999)).rejects.toThrow('卡片 999 不存在');
  });
});

两种测试各有用处:单测快、定位准;模块级测试能验证「模块声明写对了没有」——比如 exports 漏写,模块级测试会直接报依赖找不到,而单测根本发现不了。

十一、最常见的报错怎么查

NestJS 报依赖找不到时,信息其实给得很全:

Nest can't resolve dependencies of the CardsService (?). Please make sure that the argument PrismaService at index [0] is available in the CardsModule context.

按这个顺序查三处:

  1. CardsModule 的 imports 里有没有 PrismaModule —— 没有它,容器不知道 PrismaService 从哪来;
  2. PrismaModule 的 exports 里有没有 PrismaService —— 导入了但没导出,等于没导入;
  3. Provider 有没有注册进 providers —— 写了 @Injectable() 但没列进数组,容器根本不知道这个类存在。

其它高频报错:

报错 / 现象 原因 修法
Nest can't resolve dependencies of the X (?). 上面三处之一 按 imports → exports → providers 顺序查
注入的是第三方 SDK,提示找不到 SDK 类没有类型元数据 改成 useFactory 提供
Circular dependency between X and Y 两个 Provider 互相依赖 先审视边界;确实需要时用 forwardRef(() => Y)
启动时报 design:paramtypes 相关错误 少了 emitDecoratorMetadata 补上 tsconfig 选项
单例里读到了上一个请求的数据 请求作用域的依赖被单例持有 别在单例里注入请求作用域的 Provider
单测里 mock 不生效 Provider 没进 overrideProvider 确认用的是同一个令牌(类或自定义 token)
改完配置不生效 配置没走 ConfigModule 检查 .env 加载路径与 cache 设置
报错说 argument at index [0],但类明明写了 类没加 @Injectable(),或没进 providers 补装饰器并注册进模块
用自定义令牌注入报找不到 token provide 与 @Inject 指向的不是同一个令牌 用 Symbol 并把令牌集中导出
overrideProvider 报「找不到可覆盖的 Provider」 该 Provider 不在当前测试模块里 先把它加进模块,或在测试里用 providers 直接声明
全局管道没生效 管道没在 main.ts 全局注册,或注册在了子模块 用 app.useGlobalPipes(...) 或 APP_PIPE
认证没拦住某些接口 守卫只挂在部分路由上 用 APP_GUARD 全局注册,再用 @Public() 放行
响应没被包成 { ok, data } 拦截器没全局注册 在 main.ts 里 app.useGlobalInterceptors(...)
关停时连接没释放 没开 app.enableShutdownHooks() 在 main.ts 里开启
关停钩子没执行完 钩子是同步返回,没 await 钩子写成 async 并 await
工厂拿到的配置是 undefined 用了 config.get 而不是 getOrThrow 换 getOrThrow,让错误在启动时暴露

循环依赖的正确处理方式

forwardRef 是应急手段,不是设计目标:

@Injectable()
export class CardsService {
  constructor(@Inject(forwardRef(() => CommentsService)) private readonly comments: CommentsService) {}
}

看到循环依赖,先问一句:这两个东西是不是放错了模块? 绝大多数循环依赖是分层没划对的表现——把两个 Service 合并,或者把共用逻辑提到第三个模块,比加 forwardRef 更能治本。

十二、参考资料

标签

  • #NestJS
  • #dependency-dependency-injection
  • #module
  • #architecture