上一篇:NestJS 入门(5):Pipe 与 DTO 校验 讲了入参怎么在进业务前拦住。
第二篇讲过依赖注入的写法;学到这里,最常见的启动报错往往是:
Nest can't resolve dependencies of the DocumentsService (?, PrismaService). Please make sure that the argument ProjectsService at index [0] is available in the DocumentsModule context.这篇文章只讲清楚一件事:
Service 不是「写了
@Injectable()就能到处用」。它只能在「看得见它的 Module」里被注入。
看得见 = 本模块providers里有,或从别的模块imports进来,且对方已经exports。
1. Module 是能力的边界,不是文件夹
文件夹可以随便互相 import 文件;Nest 的注入容器不会。
每个@Module()都有自己的一块「可见范围」:
@Module({imports:[/* 从外面引进来的能力 */],controllers:[/* 本模块的 HTTP 入口 */],providers:[/* 本模块自己造的、可注入的东西 */],exports:[/* 愿意分享给别人的东西 */],})exportclassDocumentsModule{}可以把它想成一个盒子:
DocumentsModule 盒子 里面有:DocumentsController、DocumentsService 需要外面的:PrismaService、ProjectsService 愿意拿出去的:DocumentsServiceController 一般不导出——路由是这个模块自己的门面。
导出的通常是 Service、Guard 这类「别人构造函数里要注入」的东西。
2. 跨模块注入的三步清单
A 模块要用 B 模块的XxxService,三步缺一不可:
| 步骤 | 写在哪 | 含义 |
|---|---|---|
| 1. 注册 | B 的providers | B 能创建这个类 |
| 2. 导出 | B 的exports | B 允许别人用 |
| 3. 引入 | A 的imports: [BModule] | A 打开这扇门 |
认证模块把 Guard 分享出去,就是这三步:
@Module({imports:[PrismaModule,PassportModule,JwtModule.register({/* ... */})],controllers:[AuthController],providers:[AuthService,JwtStrategy,JwtAuthGuard],exports:[AuthService,JwtAuthGuard],// 第 2 步:愿意分享})exportclassAuthModule{}其它业务模块要挂@UseGuards(JwtAuthGuard)、或注入AuthService时,需要:
@Module({imports:[AuthModule],// 第 3 步:引进来// ...})exportclassProjectsModule{}少任何一步,启动时就会报文章开头那种can't resolve dependencies。
排查顺序建议固定:
- 这个类有没有进某个模块的
providers? - 那个模块有没有
exports它? - 当前模块有没有
imports那个模块? - 构造函数里的类型名是否写对、有没有循环依赖?
3. 根模块imports不等于「全局都能注入」
根模块常会把业务模块都挂上:
@Module({imports:[PrismaModule,AuthModule,ProjectsModule,DocumentsModule,PromptTemplatesModule,TaskPromptsModule,// ...],controllers:[HealthController],})exportclassAppModule{}这只表示:应用启动时要加载这些模块(路由、Provider 会被创建)。
不表示DocumentsModule里可以直接注入ProjectsService。
DocumentsModule要用项目能力,自己还得写:
@Module({imports:[PrismaModule,ProjectsModule],controllers:[DocumentsController],providers:[DocumentsService],exports:[DocumentsService],})exportclassDocumentsModule{}可以记:
AppModule.imports= 「这些模块存在于应用中」
业务模块自己的imports= 「我这个盒子要用谁的导出」
4.@Global():少写 imports,但别滥用
数据库、日志这种几乎处处都要,可以做成全局模块:
@Global()@Module({providers:[PrismaService],exports:[PrismaService],})exportclassPrismaModule{}只要根模块imports: [PrismaModule]一次,其它模块通常不必再写imports: [PrismaModule],也能注入PrismaService。
日志模块同理:
@Global()@Module({providers:[LoggerService,MetricsService/* ... */],exports:[LoggerService,MetricsService],})exportclassObservabilityModule{}适合全局化的:
- 数据库客户端
- 日志 / 指标
- 配置读取
不适合全局化的:
- 具体业务 Service(项目、文档、模板……)
业务一全局,模块边界就糊了:谁依赖谁从文件上看不出来,后面拆分、测试都会变难。@Global()仍然要exports——全局只是「自动帮你 imports」,不是「不用导出」。
5. 只导出需要被注入的,不导出 Controller
一张对照表:
| 成员 | 通常导出? | 原因 |
|---|---|---|
| Service | 常常导出 | 别的模块构造函数要注入 |
| Guard / Strategy | 按需导出 | 别的 Controller 要@UseGuards |
| Controller | 基本不导出 | 路由属于本模块;导出也注入不到 HTTP 层 |
| 内部工具类 | 能不导出就不导出 | 缩小边界,避免外人依赖实现细节 |
项目模块会导出多个 Service,因为别的模块确实要用:
@Module({imports:[PrismaModule,AuthModule,forwardRef(()=>DocumentsModule),forwardRef(()=>PromptTemplatesModule),forwardRef(()=>TaskPromptsModule),],controllers:[ProjectsController],providers:[ProjectsService,ChapterPipelineService,ComplianceCheckService],exports:[ProjectsService,ChapterPipelineService,ComplianceCheckService],})exportclassProjectsModule{}ProjectsController留在盒子里;拿出去的是可复用的业务能力。
6. 循环依赖:两个盒子互相要对方
真实业务里很容易出现:
- 文档要问项目:这个
projectId合法吗? - 项目要问文档:删项目时先清文档
两边构造函数互相注入,Nest 解析依赖图时会卡住。这时会看到forwardRef:
模块层:
// documents.module.tsimports:[forwardRef(()=>ProjectsModule)]// projects.module.tsimports:[forwardRef(()=>DocumentsModule)]构造函数层:
constructor(privatereadonlyprisma:PrismaService,@Inject(forwardRef(()=>DocumentsService))privatereadonlydocumentsService:DocumentsService,){}forwardRef(() => Xxx)的含义是:
现在先别急着解析这个类,等模块图搭完再接线。
入门阶段的建议:
- 能改成单向依赖就改(A 依赖 B,B 不要依赖 A)
- 实在改不了再用
forwardRef - 模块
imports和构造函数@Inject两边都要包,只改一处常常仍报错
循环依赖往往是领域边界没切干净的信号,能拆就拆。
7. 用一张图看「谁能看见谁」
AppModule imports: PrismaModule(全局)、AuthModule、ProjectsModule、DocumentsModule ... PrismaModule (@Global) providers/exports: PrismaService → 各业务模块都能注入,不必再 imports AuthModule providers: AuthService, JwtAuthGuard, JwtStrategy exports: AuthService, JwtAuthGuard → ProjectsModule imports AuthModule 后才能用 Guard ProjectsModule imports: AuthModule、DocumentsModule(forwardRef)、... exports: ProjectsService、... → DocumentsModule imports 之后才能注入 ProjectsService DocumentsModule imports: ProjectsModule(forwardRef) exports: DocumentsService → ProjectsModule 才能注入 DocumentsService(形成环,所以才要 forwardRef)问自己一句就够:
当前这个类的构造函数里写的依赖,是在「本模块 providers」里,还是在「已 imports 且对方已 exports」里?
答不上来,就不要指望 Nest 能变出实例。
8. 最小反例与改法
假设DocumentsService要注入ProjectsService,但启动失败。
反例 1:没导出
// projects.module.tsproviders:[ProjectsService],exports:[],// 忘了导出改法:exports: [ProjectsService]
反例 2:导出了但对方没引入
// documents.module.tsimports:[],// 只写了 PrismaModule,没有 ProjectsModuleproviders:[DocumentsService],改法:imports: [ProjectsModule]
反例 3:只在 AppModule 引入了双方,以为就能互相注入
// app.module.tsimports:[ProjectsModule,DocumentsModule]两边模块自己的imports仍是空的 → 仍然失败。
改法:在真正注入的那个模块里写imports。
9. 小结
- Module 是注入可见范围,不是普通文件夹
- 跨模块三步:providers 注册 → exports 分享 → imports 引入
- 根模块挂上 ≠ 子模块能互相注入
@Global()适合基础设施;业务模块保持显式 imports- Controller 通常不导出;导出 Service / Guard
- 循环依赖用
forwardRef救急,优先考虑拆单向依赖
对照本系列:
- Controller / Service / Module?
- 依赖从哪注入?
- 有没有 Guard / Pipe / 统一信封?
- 这个依赖所在模块导出了吗?当前模块引入了吗?会不会成环?
下一篇会讲:生命周期钩子——OnModuleInit里适合做什么(连数据库、灌种子数据),以及和构造函数的差别。
系列导航
- 上一篇:NestJS 入门(5):Pipe 与 DTO 校验
- 第四篇:NestJS 入门(4):统一响应与异常处理
- 第三篇:NestJS 入门(3):Guard 如何挡住未登录请求?
- 第二篇:NestJS 入门(2):依赖注入到底解决了什么问题?
- 第一篇:NestJS 入门(1):先搞懂 Module、Controller、Service