NestJS 架构实战:给后端代码请来一位"项目经理"
后端项目最怕的,往往不是业务复杂,而是所有东西挤在同一个文件里:路由、参数处理、数据库查询、权限判断互相缠住。功能刚开始只有几十行,几个月后却没人敢改。
NestJS 做的事很像给后端代码请来一位"项目经理":谁负责接待请求、谁负责处理业务、谁负责提供依赖,都提前安排清楚。本文用一个 Todo 模块讲明白 Module、Controller、Service、装饰器和依赖注入之间的关系。
一、NestJS 不只是"后端版 React"
NestJS 是一个基于 Node.js 和 TypeScript 的后端框架,默认可运行在 Express 上,也可切换到 Fastify。它借鉴 Angular 的模块、装饰器和依赖注入设计,但并不是把前端框架搬到服务器。
它的核心是三件事:
| 能力 | 解决的问题 |
|---|---|
| Module | 按业务拆分和组织代码 |
| Decorator | 用声明式语法登记路由和依赖 |
| Dependency Injection | 由容器创建并连接对象 |
这是一种模块化架构,不必强行叫它"严格 MVC"。一个常见的 NestJS 业务链路是 Controller 接收 HTTP 请求,Service 承载业务规则,Repository 或 ORM 访问数据。

二、应用从 NestFactory 启动
入口文件通常只有几行:
ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();
NestFactory.create(AppModule) 会读取根模块的元数据,建立依赖注入容器、创建 HTTP 适配器,并注册控制器中声明的路由。你不需要亲自创建 Express 实例并一条条挂路由,但底层 HTTP 服务仍然存在。
工厂模式的价值是把初始化细节藏起来,让应用从一个明确的根模块开始组装。
三、Module:业务功能的装配清单
Todo 是一个独立业务,可以有自己的模块:
ts
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}
根模块再导入它:
ts
@Module({
imports: [TodosModule],
})
export class AppModule {}
controllers 放对外接收请求的控制器,providers 放可由 Nest 容器管理和注入的服务。模块不是普通文件夹,而是 NestJS 用来定义可见依赖范围的边界。
当一个服务需要被其他模块使用时,要在来源模块的 exports 中导出它,再由使用方 imports 该模块。不要把所有 Provider 都做成全局可用,否则模块边界很快失去意义。
四、Controller:只负责 HTTP 世界的事
Controller 的职责是匹配路由、取请求参数、调用服务,并把结果交给框架生成响应:
ts
@Controller("todos")
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return this.todosService.findOne(id);
}
}
访问 GET /todos/12 时,@Controller("todos") 提供前缀,@Get(":id") 匹配路径,@Param 取出参数。ParseIntPipe 会把合法字符串转成数字,并在 abc 这类非法输入时返回 400。
不要只写 Number(id)。Number("abc") 会得到 NaN,错误会拖到 Service 甚至数据库层才暴露。
Controller 不该堆复杂业务,例如"创建订单后扣库存、发消息、记审计日志"。这些属于 Service,控制器保持薄,才能复用和测试。
五、Service:业务逻辑待在这里
Service 要用 @Injectable() 标记为 Provider:
ts
@Injectable()
export class TodosService {
findOne(id: number) {
const todo = this.todos.find((item) => item.id === id);
if (!todo) throw new NotFoundException(`Todo ${id} 不存在`);
return todo;
}
}
NotFoundException 会被 NestJS 的异常层转换为 404 JSON 响应。它比在每个控制器里手动写 res.status(404) 更统一,也便于后续用全局异常过滤器记录日志。
上例的 this.todos 只是教程内存数组。真实项目中,Service 通常注入 Repository、Prisma Service 或其他数据访问层,而不是把数据库查询混进 Controller。
六、依赖注入:为什么不自己 new Service
构造器这一行:
ts
constructor(private readonly todosService: TodosService) {}
不是 TypeScript 的魔法。NestJS 读取构造器参数类型,在当前模块及其导入模块中找到对应 Provider,创建或复用实例后注入进来。
这带来三个实际好处:
- Controller 不需要知道 Service 如何初始化。
- 单元测试时可以替换成 mock Service。
- 一个 Service 依赖另一个 Service 时,依赖关系集中由容器管理。
依赖注入也不是"任何类都会被自动创建"。类必须是可见模块中的 Provider,或者通过自定义 token 明确注册;找不到依赖时,NestJS 会在启动阶段报错。
七、装饰器到底做了什么
常见装饰器的分工:
| 装饰器 | NestJS 从中读取什么 |
|---|---|
@Module() |
模块的 imports、providers、controllers |
@Controller("todos") |
控制器路由前缀 |
@Get()、@Post() |
方法对应的 HTTP 方法和路径 |
@Injectable() |
类可作为 Provider 被容器管理 |
@Param()、@Body() |
方法参数从请求的什么位置提取 |
装饰器不是简单地"动态修改原有代码"。在 NestJS 中,它们主要把元数据附着到类、方法或参数上;框架启动时读取这些元数据,再建立路由和依赖关系。这就是为什么你写的是声明,而 NestJS 能在运行时完成注册。
八、DTO 和 Pipe:不要把"参数校验"默认交给 Controller
@Body() 只能取到请求体,本身不等于校验。创建 Todo 时可以用 DTO 描述允许的输入:
ts
export class CreateTodoDto {
@IsString()
@MinLength(1)
title: string;
}
再在入口启用全局校验:
ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
这样 title 缺失、类型不对或额外字段都会在进入 Service 前被处理。Controller 负责接收和编排,Pipe 负责转换与校验,Service 才专注业务规则,这个分工比"所有逻辑都塞进方法里"更清楚。
九、面试怎么答
NestJS 用 Module 组织业务边界,用 Decorator 声明路由和 Provider 元数据,用依赖注入容器创建并连接对象。Controller 处理 HTTP 协议相关的输入输出,Service 承载业务逻辑,数据访问通常再交给 Repository 或 ORM。参数校验不是
@Body()自动完成的,需要 DTO 配合 ValidationPipe。
总结
| 组件 | 一句话职责 |
|---|---|
AppModule |
从这里开始组装整个应用 |
| Feature Module | 封装一块业务能力和依赖范围 |
| Controller | 把 HTTP 请求翻译成服务调用 |
| Service | 放业务规则和流程编排 |
| DTO + Pipe | 约束并转换外部输入 |
| DI Container | 创建、查找并注入 Provider |
NestJS 的价值不在于少写几行路由,而在于让项目从第一天开始就有清晰的职责边界。下一篇就沿着这个边界,把 Todo 的增删改查和 ESLint、Prettier 工程化配置补全。