大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于
Tiptap的富文本编辑、NestJS后端服务、实时协作与智能化工作流等核心模块。在这个项目的持续打磨过程中,我积累了不少实战经验,不只是
Tiptap的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信
yunmz777一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐
上一节里,Controller 负责接请求、取参数、返回结果。真正撑起接口价值的,多半不是"把请求接进来",而是背后的业务逻辑。
这段逻辑默认放在 Service 里。
先把 Service 想成"业务处理层"。它不太关心路由怎么对齐,也不太关心这次是 GET 还是 POST,更常琢磨的是下面这些:
- 数据怎么查、怎么写
- 规则怎么判定
- 结果怎么拼装
- 同一套逻辑别处还要不要复用
拿创建用户来说,麻烦往往不在收参数,而在查重、密码策略、默认状态、要不要发欢迎邮件。这些都更适合收紧 Service,而不是摊在控制器里。
下面的 UsersService 只在内存里摆个数组示意,重点看职责怎么收拢:
import { Injectable } from "@nestjs/common";
/** 内存里的用户结构,仅作示意 */
interface User {
id: string;
name: string;
}
@Injectable()
export class UsersService {
private readonly users: User[] = [
{ id: "1", name: "汤姆" },
{ id: "2", name: "杰瑞" },
];
/** 返回全部用户 */
findAll(): User[] {
return this.users;
}
/** 按主键查找,没有则 undefined */
findById(id: string): User | undefined {
return this.users.find((user) => user.id === id);
}
}
数组只是替身,要紧的是"查全部"、"按 id 查"已经归进 UsersService。控制器只管调方法,不必过问细节。
Service 带来的直接好处主要是两条:
- 控制器变薄,一层里不塞满所有事
- 业务逻辑方便复用、写测试、以后改实现
习惯可以记得很短:控制器对齐请求,Service 扛起业务。
Provider 的本质
不少人初学时会把 Provider 和 Service 混着说,其实分清也不难:Service 是很常见的一种 Provider,Provider 这个词包住的是所有"可注入实现"。
凡是能交给 NestJS 容器创建、保管,再注入给别的类的,都归在这一类里。常见例子包括:
- 业务服务,例如
UsersService - 仓储或数据访问类,例如
UsersRepository - 横切能力,例如
MailService - 配置对象、工厂返回值、自定义
token绑定的实例,也都算
框架把它们统称 Provider,并不是纠结类名该叫 Service 还是 Repository,而是在管三件事:
- 要不要由容器负责实例化
- 能不能被别人注入
- 生命周期怎么配合作用域
写进模块的 providers 数组,就是在向容器挂号。只有挂上的实现才会按作用域被实例化,并有机会出现在别人的构造函数里。类名是服务还是仓储,只影响阅读,不影响这条规则。
下面两个类分工不同,在容器眼里却一视同仁,都是 Provider:
import { Injectable } from "@nestjs/common";
@Injectable()
export class UsersService {
findAll(): string[] {
return ["汤姆", "杰瑞"];
}
}
@Injectable()
export class MailService {
sendWelcomeMail(email: string): string {
return `已向 ${email} 发送欢迎邮件(示意)`;
}
}
命名上你仍可以一个叫用户服务、一个叫邮件服务,登记方式没有区别。
记关系时只要两句就够:Provider 是框架侧的通用身份,Service 是业务里最常见的实现形态。以后遇到 Repository、工厂型 Provider 或自定义 token,仍然在同一个注入体系里处理。
Module 是什么
Service 扛业务,Provider 被容器托管,Module 则要再往上管一层:划清功能边界,把同一领域的控制器、Provider、对外约定装进一个盒子里。
在 NestJS 里,模块不是摆设,而是结构的基本单元,应用多半就是许多模块拼起来的东西。
用户、订单、认证可以各自落在 UsersModule、OrdersModule、AuthModule 上,每个模块维护自己的控制器、内部 Provider、以及愿意被别人用到的出口。
最小模块长这样:
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
/** 用户领域:对外入口 + 可注入服务 */
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
行数不多,信息量不小:这几个人同属于一块业务边界;控制器对外接请求,UsersService 在本模块内可注入,再往下还可以继续挂别的 Provider。
从结构上看,可以先扫一眼下面这张图。
节点不是漂在全局,而是先归进各自模块,再由 AppModule 一类根模块把业务模块接起来。
别把 Module 当成应付编译器的样板,它就是在替你划"这块功能从哪开始、到哪结束"。
imports 等四个字段各管什么
第一次看 @Module() 里的配置,最容易缠在一起的是 imports、providers、controllers、exports。拆开看就顺了。
下面在有用户模块的基础上多接了一个 DatabaseModule,并把 UsersService 对外导出,方便别的模块注入:
import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
/** 依赖数据库模块,并把用户服务暴露给 import 本模块的一方 */
@Module({
imports: [DatabaseModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
四个键可以先记成功能分工:
imports本模块依赖哪些别的模块已经exports出来的能力providers本模块自己要注册、仅供内部(默认可注入范围)使用的Providercontrollers本模块声明哪些 HTTP 入口exports本模块对外放行哪些Provider,供在别处imports了本模块的代码继续注入
最常绊脚的一对是 providers 和 exports:
providers是"家里有哪些实现"exports是"门口挂牌、准许邻居借用的有哪些"
留在 providers 里但没进 exports 的,别模块默认看不见。只有当别人也要注入这份实现,才需要把它写进 exports。
这有点像团队分工:内部实现可以多,对外接口要收束;别人要用,只能走你声明过的模块边界。
分文件夹只是把文件挪个地方,模块是在声明"谁允许依赖谁、谁对外可见"。
为什么业务逻辑不能全写在 Controller
新手很容易图省事,把业务全堆进 Controller:参数在手,就地校验、拼装、返回,看起来一气呵成。
项目一大,这样最容易长胖的是控制器。
下面这个例子能跑,但已经在兼职干 Service 的活:
import { Body, Controller, Post } from "@nestjs/common";
/** 创建用户时客户端传入的字段 */
interface CreateUserDto {
name: string;
email: string;
}
@Controller("users")
export class UsersController {
@Post()
create(@Body() body: CreateUserDto): { message: string } {
const exists = body.email === "tom@example.com";
if (exists) {
return { message: "该邮箱已存在" };
}
const user = {
id: Date.now().toString(),
name: body.name,
email: body.email,
status: "正常",
};
return { message: `已创建用户:${user.name}` };
}
}
收参、判重、造对象、定响应格式挤在同一层,后面要复用、单测、接库、发信、上事务,只能继续往控制器里糊。
把规则挪进 Service,控制器只做转发,形态会干净很多:
import { Body, Controller, Post } from "@nestjs/common";
import { UsersService } from "./users.service";
interface CreateUserDto {
name: string;
email: string;
}
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() body: CreateUserDto): { message: string } {
// 业务规则交给服务层
return this.usersService.create(body);
}
}
改完后的控制器基本只做四件事:接请求、拿参数、喊 Service、把结果交出去。
收益不只是顺眼,而是业务落在更容易复用和测试的一层,项目越复杂越省劲。
为什么 Module 是 NestJS 里最核心的那一层边界
Controller 管入口,Service 管业务落地,Module 管的是再底下那层:系统边界哪里画、依赖往哪收敛。
维护噩梦常常不是少写了类,而是边界糊掉:模块互相穿透实现细节,调用网越织越密。
NestJS 把 Module 摆得这么重,是要你把应用想成"多模块协作",而不是"一大撮控制器加一大撮服务"。
边界划清楚以后,好处很实在:
- 用户、订单、支付、认证各自有落脚模块
- 依赖不容易随便渗透到别的模块内部
- 拆分、复用、补测试都更顺手
- 新人找功能时有目录感
- 大重构可以按模块切块推进
反过来,模块若只是分文件夹,Service 和 Controller 再多也可能是一盘散沙。
所以 Module 不只是凑齐装饰器清单,而是在体积涨上去之前,逼你先想清楚谁能见谁、谁能用谁。
顺口溜可以记成 "Controller 开门口,Service 做生意,Module 砌围墙"。
这三层站稳以后,依赖注入、模块导入导出、动态模块、可插拔架构都会沿同一套边界往下长。
小结
这篇的重点不是多记几个词,而是把三条线拧到一根绳上:
Service承接大部分业务Provider是容器能注入的那类东西的统称Module划边界、装箱、再决定对外露什么
判断习惯可以压成四句:入口给控制器,规则给服务,可注入项进 Provider 列表,单元边界交给模块。
下一节讲 DTO 和校验。你会看到,光靠"拿到字段就用"在真实项目里往往不够。