3. 模块化架构设计
在 NestJS 中,模块(Module)是组织应用的最小架构单元,也是依赖注入容器划分作用域的边界。@Module 装饰器接收一个元数据对象,其中 imports 声明本模块依赖的其他模块,providers 声明本模块内可被注入的提供者,controllers 声明本模块处理的路由入口,exports 则决定哪些提供者可以被其他模块复用。理解这四个字段的语义,是划分模块边界的前提。
一个常见的误区是把所有 Service 都放进 AppModule,再让各 Controller 直接注入。这样做的直接后果是:任何模块都能访问任何服务,模块边界形同虚设,随着业务增长,依赖关系会变成一张无法维护的网。正确的做法是按业务域拆分模块,让每个模块只暴露必要的接口。
按业务域拆分:用户、订单、支付
以电商场景为例,可以拆分为 UserModule、OrderModule、PaymentModule 三个业务模块,外加一个 SharedModule 承载跨域能力。目录结构建议如下:
src/
├── modules/
│ ├── user/
│ │ ├── user.module.ts
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ └── dto/
│ ├── order/
│ │ ├── order.module.ts
│ │ ├── order.controller.ts
│ │ ├── order.service.ts
│ │ └── dto/
│ └── payment/
│ ├── payment.module.ts
│ ├── payment.controller.ts
│ ├── payment.service.ts
│ └── dto/
├── shared/
│ ├── shared.module.ts
│ ├── logger/
│ └── database/
├── config/
└── main.ts
每个业务模块内部自包含 controller、service 和 dto,模块之间通过 imports 建立依赖。例如 OrderModule 需要校验用户信息,就导入 UserModule;PaymentModule 需要读取订单金额,就导入 OrderModule。这种依赖是有向的,不应出现循环导入。
共享模块与全局模块
当多个模块都需要日志、数据库连接、配置读取等能力时,应将其封装为 SharedModule,并在 exports 中导出对应 provider。其他模块只需 imports: [SharedModule] 即可使用。如果某个模块几乎被所有模块依赖,可以加上 @Global() 装饰器,使其只需在根模块注册一次,其他模块无需重复导入。但全局模块应谨慎使用,滥用会让依赖关系变得隐式,降低可读性。
@Global() 的标准:该模块是否被超过 80% 的业务模块依赖,且不依赖任何业务模块。日志、配置、数据库连接通常满足这一条件,而用户、订单等业务模块不应设为全局。动态模块:让模块可配置
静态模块的 imports 是固定的,但有些模块需要根据运行时参数决定行为,例如数据库模块需要传入连接字符串,缓存模块需要传入 Redis 地址。这时应使用动态模块,通过静态方法 forRoot 或 forFeature 返回一个 DynamicModule 对象。
@Module({})
export class DatabaseModule {
static forRoot(options: DbOptions): DynamicModule {
return {
module: DatabaseModule,
providers: [
{ provide: 'DB_OPTIONS', useValue: options },
DatabaseService,
],
exports: [DatabaseService],
global: true,
};
}
}
调用方在根模块中写 DatabaseModule.forRoot({ host, port }),即可把配置注入到模块内部。动态模块的关键在于:module 字段必须指向自身类,providers 和 exports 根据传入参数动态生成。
模块依赖关系可视化
下图展示了三个业务模块与共享模块之间的依赖方向,以及每个模块对外暴露的能力。箭头方向表示“导入并使用”,不表示数据流向。
边界划分的实践原则
模块边界划分没有唯一正确答案,但有几条可操作的判断依据。第一,按业务能力而非技术层次划分,UserModule 比 ServiceModule 更稳定,因为业务能力变化慢,技术分层变化快。第二,模块对外只暴露 Service,不暴露 Controller,Controller 是路由入口,不应被其他模块引用。第三,避免循环依赖,如果 OrderModule 和 UserModule 互相导入,说明边界划错了,应把共享逻辑下沉到 SharedModule 或引入第三个模块。
第四,DTO 和实体类可以放在模块内部,也可以放在 shared 目录,取决于是否被多个模块复用。如果只有本模块使用,就放在模块内;如果被两个以上模块引用,再考虑提取到共享位置。过早提取会造成共享目录膨胀,反而增加维护成本。
forwardRef 来打破循环,但这只是补救手段。出现循环依赖时,应优先重新审视边界划分,而不是直接加 forwardRef 掩盖问题。模块注册与启动验证
所有业务模块最终在 AppModule 中注册。全局模块只需注册一次,其他模块无需重复导入。启动应用后,可以通过日志观察模块初始化顺序,确认依赖关系符合预期。如果某个 Service 注入失败,NestJS 会在启动阶段抛出明确错误,提示哪个 provider 无法解析,这是验证模块边界是否正确的第一道关卡。
@Module({
imports: [
DatabaseModule.forRoot({ host: 'localhost', port: 5432 }),
UserModule,
OrderModule,
PaymentModule,
],
})
export class AppModule {}
当模块数量增长到十几个以上时,建议按业务域建立子目录,并在每个子目录下放置 index.ts 统一导出,保持 AppModule 的 imports 列表简洁可读。模块化架构的价值不在于模块数量,而在于每个模块的职责是否单一、边界是否清晰、依赖方向是否可控。