NestJS 入门实战:从工厂模式到 Todo CRUD,彻底理解模块化、依赖注入与测试
如果你接触过 Next.js,再看 NestJS 时,最先要调整的是对框架职责的认识:Next.js 可以同时处理页面与服务端逻辑,而 NestJS 更专注于 Node.js 服务端应用。它默认使用 TypeScript,并通过模块、控制器、服务、装饰器和依赖注入,把后端代码组织成清晰的业务单元。
后端并不只是"写几个接口"。它还可能承担系统集成、并发服务、AI 基础设施以及微服务等工作。业务一旦变复杂,如果路由、参数处理和数据操作全部堆进一个文件,代码很快就会变成难以维护的"大杂烩"。NestJS 的核心价值之一,就是用明确的结构约束这种复杂度。
本文会用一个内存版 Todo CRUD 应用串起以下内容:
NestFactory如何创建应用;- Module、Controller、Service 分别负责什么;
- 装饰器如何把普通类变成模块、控制器和可注入服务;
- NestJS 如何完成依赖注入;
- 如何实现查询、新增、删除和局部更新;
- 如何用
NotFoundException统一表达"资源不存在"; - 单元测试与端到端测试分别在验证什么;
- TypeScript、ESLint、Prettier、Jest 和构建脚本如何配合。
一、先用奶茶店理解工厂模式
在进入 NestJS 之前,先看一个非常小的工厂模式示例。
假设一家奶茶店有冰激凌、柠檬水和奶茶三种产品。调用者不想记住每个产品类的构造过程,只想告诉店员自己要什么,然后直接使用拿到的产品。
js
class IceCream {
constructor() {
this.name = '冰激凌';
this.price = 3;
}
show() {
console.log(`${this.name} -> ${this.price}元`);
}
}
class LemonTea {
constructor() {
this.name = '柠檬水';
this.price = 4;
}
show() {
console.log(`${this.name} -> ${this.price}元`);
}
}
class MilkTea {
constructor() {
this.name = '奶茶';
this.price = 7;
}
show() {
console.log(`${this.name} -> ${this.price}元`);
}
}
class MixueFactory {
static create(type) {
switch (type) {
case 'ice':
return new IceCream();
case 'lemon':
return new LemonTea();
case 'milk':
return new MilkTea();
}
}
}
const product1 = MixueFactory.create('ice');
product1.show();
const product2 = MixueFactory.create('lemon');
product2.show();
const product3 = MixueFactory.create('milk');
product3.show();
运行后会得到:
text
冰激凌 -> 3元
柠檬水 -> 4元
奶茶 -> 7元
这里有三个关键点。
第一,调用者只和 MixueFactory 打交道,不负责 new IceCream()、new LemonTea() 或 new MilkTea(),产品的创建过程被集中到了工厂中。
第二,三个产品都提供了 show() 方法。这个 JavaScript 示例没有使用 interface 关键字,它依靠的是相同的方法约定。正因为产品具有一致的使用方式,调用者拿到产品后才可以直接调用 show()。
第三,调用者与具体产品类之间得到了一层隔离。以后创建规则发生变化时,主要修改工厂即可。不过当前实现也有一个明确边界:如果传入未知的 type,create() 会返回 undefined,随后调用 show() 就会报错。
NestJS 的应用入口也出现了一个工厂:NestFactory。它与上面的奶茶店示例并非完全相同的业务实现,但理解方式很接近------开发者把根模块交给工厂,由工厂完成应用实例的创建,不需要亲自拼装底层 HTTP 应用。
二、启动一个 NestJS 应用
先安装 Nest CLI,再创建项目:
bash
npm i -g @nestjs/cli
nest new hello
项目创建后,可以用以下命令启动开发服务器:
bash
pnpm run start
需要监听代码变化时,使用:
bash
pnpm run start:dev
应用入口非常短:
ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
这几行代码完成了整个应用的启动。
1. 为什么 bootstrap 是异步函数
NestFactory.create(AppModule) 会创建 Nest 应用实例,返回值需要通过 await 获取;app.listen(...) 启动 HTTP 服务同样是异步操作。因此入口函数使用了 async。
2. 根模块有什么作用
传给 NestFactory.create() 的不是某个控制器,而是 AppModule。根模块描述了应用由哪些模块、控制器和服务组成。Nest 会从根模块出发建立完整的应用依赖关系。
3. 端口为什么这样写
ts
process.env.PORT ?? 3000;
它表示优先读取环境变量 PORT;当这个值是 null 或 undefined 时,才使用默认端口 3000。
当前 ESLint 规则会对最后一行 bootstrap() 给出"Promise 未处理"的警告。若希望明确告诉检查器这里有意忽略返回值,可以写成:
ts
void bootstrap();
这不会改变启动流程,只是把意图表达得更清楚。
三、模块化:先划分业务,再组装应用
一个 NestJS 应用可以包含很多 Module,一个 Module 又可以组织自己的 Controller 和 Provider。Todo 业务可以单独形成一个模块:
ts
import { Module } from '@nestjs/common';
import { TodosController } from './Todos.controller';
import { TodosService } from './Todos.service';
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}
然后在根模块中引入它:
ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TodosModule } from './todos/todos.module';
@Module({
imports: [TodosModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
@Module() 接收的三个字段各有职责:
| 字段 | 当前应用中的内容 | 作用 |
|---|---|---|
imports |
TodosModule |
引入当前模块依赖的其他模块 |
controllers |
AppController、TodosController |
接收请求,处理路由参数和少量控制逻辑 |
providers |
AppService、TodosService |
提供可以被容器管理和注入的业务服务 |
当 AppModule 导入 TodosModule 后,Todo 业务就进入了整个应用的模块图。以后业务继续增加,可以继续增加独立模块,而不必把所有控制器和服务都塞进根模块。
Module 不是"把文件放进同一个文件夹"
真正让类成为 Nest 模块的是 @Module() 装饰器及其元数据,而不是目录名称。目录负责帮助开发者阅读,装饰器中的 imports、controllers、providers 才负责告诉 Nest 如何组装这些类。
四、Controller 与 Service:一次请求是怎样流转的
先看最简单的根路由。
服务只负责返回数据:
ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
控制器接收 GET / 请求,再把工作交给服务:
ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
console.log('/ 的控制器');
return this.appService.getHello();
}
}
请求链路可以概括为:
text
GET /
→ AppController.getHello()
→ AppService.getHello()
→ "Hello World!"
控制器知道"哪个请求应该调用哪个方法",服务知道"业务结果应该怎样产生"。这样拆分之后,路由规则与业务数据不会纠缠在一起。
有些资料会直接把这种结构称为 MVC。更准确地说,当前 API 示例主要体现的是 Controller 与 Service 的分层:它没有渲染 HTML 视图,也没有接入数据库 Model。Controller 与 Service 的职责分离,才是这里真正需要掌握的重点。
五、依赖注入:为什么没有手动 new AppService()
控制器的构造函数中有这样一行:
ts
constructor(private readonly appService: AppService) {}
这同时做了两件事:
- 它利用 TypeScript 的参数属性语法,声明并初始化了
this.appService; - 它告诉 Nest:创建
AppController时需要一个AppService。
AppService 被 @Injectable() 标记,随后又被登记到模块的 providers 中。Nest 的依赖注入容器就可以负责创建服务实例,并把它传给控制器。开发者不需要在控制器里写:
ts
const appService = new AppService();
Todo 模块也是同样的关系:
text
TodosModule
├── controllers: TodosController
└── providers: TodosService
↑
└── 注入 TodosController 的构造函数
这种方式把"如何获得依赖"交给容器,控制器只声明"我需要什么"。
装饰器在这里究竟做了什么
@Module()、@Controller()、@Get()、@Injectable() 都在给类或方法附加 Nest 能够读取的元数据。TypeScript 配置中的下面两项为这套装饰器机制提供支持:
json
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
同时,应用依赖中包含 reflect-metadata。编译之后,构造函数的参数类型等信息会以元数据形式保留下来,Nest 才能知道 TodosController 需要 TodosService。
因此,这里的"装饰"不只是给原对象增加一个可见方法。它更重要的作用是声明框架元数据,让普通 TypeScript 类进入 Nest 的模块、路由和依赖注入体系。
六、设计 Todo 数据结构与内存数据源
Todo 的数据结构很简单:
ts
export interface Todo {
id: number;
content: string;
completed: boolean;
}
三个字段分别表示编号、任务内容和完成状态。应用先准备两条内存数据,并用 nextId 生成下一条记录的编号:
ts
const todos: Todo[] = [
{ id: 1, content: '学习NestJS', completed: false },
{ id: 2, content: '学习CRUD', completed: false },
];
let nextId = 3;
这里没有数据库,数组就是暂时的数据源。因此它适合用来观察 CRUD 和请求流转,但有一个非常清楚的边界:应用进程一旦重启,运行期间新增、删除或修改的数据都会丢失。
七、在 Service 中实现 CRUD
TodosService 集中了真正的数据操作:
ts
import { Injectable, NotFoundException } from '@nestjs/common';
@Injectable()
export class TodosService {
findAll(): Todo[] {
return todos;
}
findOne(id: number): Todo {
const todo = todos.find((item) => item.id === id);
if (!todo) {
throw new NotFoundException(`Todo ${id} 不存在`);
}
return todo;
}
create(content: string): Todo {
const todo: Todo = {
id: nextId++,
content,
completed: false,
};
todos.push(todo);
return todo;
}
remove(id: number): void {
const index = todos.findIndex((item) => item.id === id);
if (index === -1) {
throw new NotFoundException(`Todo ${id} 不存在`);
}
todos.splice(index, 1);
}
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id);
Object.assign(todo, patch);
return todo;
}
}
逐个看这些方法。
1. findAll:查询全部
ts
findAll(): Todo[] {
return todos;
}
返回值被明确标记为 Todo[],也就是 Todo 数组。
2. findOne:查询单条并处理不存在的情况
ts
const todo = todos.find((item) => item.id === id);
find() 返回第一个满足条件的元素;如果没有找到,则返回 undefined。而方法声明的返回类型是 Todo,不能把"可能不存在"的结果直接返回,所以需要先判断:
ts
if (!todo) {
throw new NotFoundException(`Todo ${id} 不存在`);
}
NotFoundException 是 NestJS 提供的内置异常。抛出它后,框架会把异常转换为规范的 HTTP 404 响应,其中会包含状态码和错误消息。业务代码只需要准确表达"资源不存在",不必在每个控制器中手写一套错误响应。
这也是后端容错的一个重要原则:不要让 undefined 继续流向后面的代码,而要在能确定问题的位置尽早终止当前请求。
3. create:新增任务
ts
const todo: Todo = {
id: nextId++,
content,
completed: false,
};
每次创建时使用当前 nextId,然后执行自增。新任务默认未完成,加入数组后再把完整对象返回。
4. remove:删除任务
删除需要的是元素位置,而不是元素本身,因此这里使用 findIndex():
ts
const index = todos.findIndex((item) => item.id === id);
未找到时 findIndex() 返回 -1,找到后则使用:
ts
todos.splice(index, 1);
从数组中删除该位置的一个元素。
5. update:局部更新
ts
update(id: number, patch: Partial<Todo>): Todo {
const todo = this.findOne(id);
Object.assign(todo, patch);
return todo;
}
这里复用了 findOne(),因此更新不存在的 Todo 时会自动抛出相同的 404 异常,不需要再次编写查找与判断逻辑。
Partial<Todo> 会把 Todo 的所有属性变成可选属性,所以调用者可以只提交需要改变的字段。例如,只修改完成状态时不需要再次提交 content。
Object.assign(todo, patch) 会直接把补丁字段合并到原对象上。因为数组中保存的就是这个对象,所以修改会立即反映到内存数据中。
当前写法也意味着补丁可以包含 id,并且没有额外校验字段内容。这正好说明了它是一个用于理解分层与 CRUD 的最小实现,而不是完整的数据校验方案。
八、在 Controller 中把 HTTP 请求映射到 Service
完整的 Todo 控制器如下:
ts
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
} from '@nestjs/common';
import { TodosService, type Todo } from './Todos.service';
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
findAll(): Todo[] {
console.log('/todos controller');
return this.todosService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): Todo {
return this.todosService.findOne(Number(id));
}
@Post()
create(@Body('content') content: string): Todo {
return this.todosService.create(content);
}
@Delete(':id')
remove(@Param('id') id: string): { message: string } {
this.todosService.remove(Number(id));
return { message: '删除成功' };
}
@Patch(':id')
update(@Param('id') id: string, @Body() patch: Partial<Todo>): Todo {
return this.todosService.update(Number(id), patch);
}
}
类装饰器 @Controller('todos') 统一声明了路由前缀,方法装饰器再声明具体的 HTTP 方法和路径。
| HTTP 请求 | 控制器方法 | 读取的数据 | Service 操作 |
|---|---|---|---|
GET /todos |
findAll() |
无 | 返回全部 Todo |
GET /todos/:id |
findOne() |
路径参数 id |
按编号查询 |
POST /todos |
create() |
请求体中的 content |
新增 Todo |
DELETE /todos/:id |
remove() |
路径参数 id |
删除 Todo |
PATCH /todos/:id |
update() |
路径参数 id 与整个请求体 |
局部更新 Todo |
1. @Param() 读取路径参数
在 GET /todos/2 中,2 来自 URL,控制器通过下面的代码拿到它:
ts
@Param('id') id: string
路径参数进入控制器时是字符串,而 Service 的 id 参数是数字,所以调用前使用 Number(id) 做转换。
2. @Body('content') 只读取一个字段
创建接口只需要任务内容:
ts
@Body('content') content: string
它从请求体中取出 content,再把字符串传给 create()。
3. @Body() 读取完整请求体
更新需要接收不确定数量的补丁字段,因此直接读取整个请求体:
ts
@Body() patch: Partial<Todo>
例如,请求体只包含:
json
{
"completed": true
}
就只会修改 completed。
4. PUT 与 PATCH 的区别
PUT 和 PATCH 都可以表达修改,但语义不同:
PUT更接近用一个完整对象替换原资源;PATCH只修改请求中选中的字段。
当前更新方法使用 Partial<Todo> 接收局部字段,因此与 @Patch(':id') 是一致的。代码中不需要导入未使用的 Put,否则 ESLint 会报告未使用变量错误。
九、异常处理为什么应该放在业务层
查询、更新和删除都有可能遇到"编号不存在"。如果每个控制器方法都分别判断,会产生重复代码,还可能出现不同接口返回不同错误格式的问题。
当前实现把判断放在 Service 中:
ts
if (!todo) {
throw new NotFoundException(`Todo ${id} 不存在`);
}
这样有两个直接收益:
findOne()无论被查询接口还是更新逻辑调用,都遵守同一条规则;- NestJS 负责把内置异常转成 HTTP 错误响应,Controller 不需要写重复的
try...catch。
这并不意味着所有异常都可以不管。这里处理的是已经预期到的业务分支:资源不存在。重点不是"到处包一层 try...catch",而是让异常在合适的层级被准确表达,并由框架完成统一输出。
十、单元测试:只验证 Controller 的行为
NestJS 提供了测试模块,可以在测试中建立一个小型依赖注入容器:
ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
这段测试没有启动 HTTP 服务,也没有向 / 发送请求。它做了三件事:
- 注册
AppController和AppService; - 编译测试模块,让 Nest 完成依赖注入;
- 从容器中取得控制器实例,直接调用
getHello()。
因此,它验证的是控制器方法能够通过已注入的服务返回 Hello World!。
项目中的 Jest 配置把 src 作为单元测试根目录,用 .*\.spec\.ts$ 匹配测试文件,并通过 ts-jest 转换 TypeScript。执行命令是:
bash
pnpm run test
还可以使用:
bash
pnpm run test:watch
pnpm run test:cov
分别进入监听模式和生成覆盖率结果。
十一、端到端测试:验证完整 HTTP 链路
端到端测试导入整个根模块,并创建真正的 Nest 应用:
ts
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});
它通过 Supertest 对应用的 HTTP Server 发起 GET /,同时断言状态码是 200、响应内容是 Hello World!。这条链路会经过模块创建、路由匹配、控制器调用和服务返回,比直接调用控制器更接近真实请求。
afterEach() 中的 app.close() 也很重要,它确保每个用例结束后关闭应用,避免测试资源一直占用。
端到端测试有独立的 Jest 配置:测试根目录是 test,文件名由 .e2e-spec.ts 结尾的规则匹配。执行命令是:
bash
pnpm run test:e2e
两种测试的差别可以总结为:
| 对比项 | 单元测试 | 端到端测试 |
|---|---|---|
| 测试入口 | 直接调用 appController.getHello() |
发出 GET / 请求 |
| 组装范围 | Controller + Service | 完整 AppModule |
| 是否检查路由 | 否 | 是 |
| 当前断言 | 方法返回 Hello World! |
状态码 200 且响应为 Hello World! |
当前两组测试只覆盖根路由,还没有覆盖 Todo 的新增、查询、更新、删除和异常分支。这是阅读测试结果时必须看清的边界:测试通过只能证明已经写下的断言成立,不能自动代表所有 CRUD 行为都被验证过。
十二、从开发到生产:脚本和编译配置在做什么
项目脚本覆盖了开发、构建、检查和测试:
| 命令 | 作用 |
|---|---|
pnpm run build |
使用 Nest CLI 编译项目 |
pnpm run start |
启动应用 |
pnpm run start:dev |
监听文件变化并启动 |
pnpm run start:debug |
使用调试与监听模式启动 |
pnpm run start:prod |
运行编译后的 dist/main |
pnpm run format |
用 Prettier 格式化 src 与 test 下的 TypeScript |
pnpm run lint |
检查并自动修复指定目录中的 TypeScript |
pnpm run test |
运行单元测试 |
pnpm run test:e2e |
运行端到端测试 |
pnpm run test:cov |
运行测试并统计覆盖率 |
1. Nest CLI 配置
Nest CLI 以 src 为源码根目录,并在构建前删除旧输出目录:
json
{
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
这可以避免已经删除或改名的旧文件继续残留在 dist 中。生产启动脚本直接运行 dist/main,所以在运行生产命令前应先完成构建。
2. TypeScript 配置
与当前应用关系最直接的选项包括:
| 配置 | 含义 |
|---|---|
module / moduleResolution: nodenext |
按 Node.js 的模块规则处理模块与解析 |
target: ES2023 |
把 JavaScript 输出目标设为 ES2023 |
outDir: ./dist |
把编译结果输出到 dist |
declaration: true |
生成 .d.ts 类型声明文件 |
sourceMap: true |
生成 .js.map 源映射文件 |
removeComments: true |
编译输出中移除注释 |
experimentalDecorators: true |
支持当前使用的装饰器语法 |
emitDecoratorMetadata: true |
为依赖注入生成所需的类型元数据 |
incremental: true |
保存增量编译信息 |
strictNullChecks: true |
区分普通值与 null、undefined |
forceConsistentCasingInFileNames: true |
检查文件名大小写一致性 |
skipLibCheck: true |
跳过声明文件的类型检查 |
构建专用配置继承主配置,同时排除:
json
{
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
也就是说,业务构建不会把依赖目录、测试、旧编译产物和单元测试文件再次编译进生产输出。
3. 编译后为什么会看到多种文件
构建后主要会出现三类产物:
.js:Node.js 真正执行的代码;.d.ts:由declaration: true生成的类型声明;.js.map:由sourceMap: true生成的源映射。
装饰器编译后还会出现辅助逻辑和设计类型元数据。比如控制器构造函数中的 AppService 类型,会进入 design:paramtypes 元数据,这正是 Nest 运行时识别构造函数依赖的重要依据。
4. package.json 与锁文件的分工
package.json 声明直接依赖、开发依赖和允许的版本范围;pnpm-lock.yaml 则记录实际解析出来的完整依赖图和精确版本。前者表达项目需要什么,后者让团队安装时尽量得到一致的依赖结果。
运行应用直接依赖的核心包包括:
@nestjs/common:常用装饰器、异常和公共能力;@nestjs/core:Nest 核心与NestFactory;@nestjs/platform-express:Express 平台适配;reflect-metadata:运行时元数据支持;rxjs:响应式编程依赖。
Jest、Supertest、TypeScript、ESLint、Prettier、Nest CLI 等只用于开发、构建或测试,因此位于开发依赖中。
十三、ESLint 与 Prettier:一个管问题,一个管风格
Prettier 配置很克制:
json
{
"singleQuote": true,
"trailingComma": "all"
}
它要求字符串优先使用单引号,并在允许的位置保留尾逗号。
ESLint 使用 JavaScript 与 TypeScript 的推荐规则,同时接入 Prettier。它认识 Node 和 Jest 的全局变量,并启用了需要类型信息的 TypeScript 检查。几个自定义规则值得注意:
js
{
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
}
- 允许显式使用
any; - 未处理的 Promise 给出警告;
- 不安全的参数传递给出警告;
- 不符合 Prettier 的代码按错误处理,并自动适应不同系统的换行符。
这也解释了两个容易忽略的细节:没有使用的 Put 导入应当删除;入口处的 bootstrap() 会触发未处理 Promise 警告,可以用 void bootstrap() 明确意图。
十四、哪些内容不应该进入版本库
一个完整的后端项目会产生很多不适合提交的内容,因此忽略规则覆盖了:
dist、build等编译结果;node_modules依赖目录;coverage测试覆盖率结果;- 日志、临时目录、PID 和诊断报告;
- 本地环境变量文件;
- 操作系统与编辑器生成的文件。
这里尤其要注意 .env 及各种本地环境配置。它们属于不同机器或不同运行环境的本地状态,不应和普通业务代码混在一起提交。
十五、回头看完整请求链路
现在可以把 GET /todos/1 的完整过程串起来:
NestFactory根据AppModule创建应用;AppModule导入TodosModule;TodosModule注册TodosController和TodosService;- 依赖注入容器创建
TodosService,并注入TodosController; @Controller('todos')与@Get(':id')匹配GET /todos/1;@Param('id')取出字符串1,控制器将其转成数字;- Controller 调用
TodosService.findOne(1); - Service 从数组中查找 Todo;
- 找到则返回对象,未找到则抛出
NotFoundException; - Nest 把返回值或异常转换为 HTTP 响应。
这条链路把工厂模式、模块化、装饰器、依赖注入、分层和异常处理全部连接了起来。
总结
这个 Todo 应用很小,但它已经具备 NestJS 项目的核心骨架:
- 用
NestFactory和根模块启动应用; - 用业务 Module 管理边界;
- 用 Controller 映射 HTTP 请求;
- 用 Service 承担数据与业务操作;
- 用
@Injectable()、Provider 和构造函数完成依赖注入; - 用
NotFoundException表达可预期的业务错误; - 用 Jest 做方法级测试,用 Supertest 做完整 HTTP 链路测试;
- 用 TypeScript、ESLint、Prettier 和 Nest CLI 保证开发与构建流程清晰。
真正值得掌握的不是某个装饰器的写法,而是它们如何协作:模块负责组装,控制器负责接收,服务负责处理,容器负责连接,测试负责验证。当应用从一个 Todo 列表扩展成多个业务模块时,这套结构才会真正显出价值。