任何稍有规模的 Node.js 项目,如果不加约束地把路由处理、数据库查询、业务判断全部写在一个文件里,很快就会演变成难以维护的“面条代码”。后端分层架构的核心思想,就是将不同的关注点拆分到职责明确的层级中,让每一层只做好一件事。其中最常见、也最基础的分层就是 Controller(控制层)、Service(业务层) 和 DAO/Repository(数据访问层)。
这种三层架构在 Express、Koa、Fastify 乃至 NestJS 中都可以天然落地,无论项目采用什么框架,分层的基本原则是通用的。
三层各自的职责边界
1. Controller:控制层,负责协调请求与响应
Controller 是整个系统的入口,它直接与 HTTP 请求打交道,但不应该包含具体的业务逻辑。它的典型职责包括:
- 接收并校验请求参数(路径参数、查询字符串、请求体)
- 调用对应的 Service 方法,获取业务处理结果
- 构造 HTTP 响应(状态码、响应体、响应头)
- 统一错误处理(将 Service 层抛出的业务异常映射为合适的 HTTP 状态码和错误信息)
- 不做任何具体的业务判断或数据操作
简单来说,Controller 应该非常“薄”,像一个接线员,把请求转发给 Service,再把 Service 的返回打包成 HTTP 响应。
Express 代码示例:
// user.controller.js
const userService = require('./user.service');
const { validateCreateUser } = require('./user.validator');
async function createUser(req, res, next) {
try {
// 1. 校验输入
const { error, value } = validateCreateUser(req.body);
if (error) {
return res.status(400).json({ code: 400, message: error.message });
}
// 2. 调用业务层
const createdUser = await userService.createUser(value);
// 3. 返回响应
res.status(201).json({ code: 201, data: createdUser });
} catch (err) {
// 4. 错误传递
next(err);
}
}
module.exports = { createUser };
Controller 中不出现任何数据库查询语句,也不出现 if (user.role === 'admin') 这样的业务判断,它只负责 HTTP 层面的转接。
2. Service:业务层,负责核心业务逻辑与流程编排
Service 是分层架构的“大脑”,所有的业务规则、流程判断、数据组合都落在这一层。它不关心请求是从 HTTP、WebSocket 还是命令行触发的,只接受数据并返回结果。
Service 主要职责:
- 实现业务规则:验证数据合法性(业务校验,而非格式校验)、执行权限判断、计算字段
- 编排多个数据源操作:可能涉及多个 DAO 调用的组合、事务控制
- 提供清晰的语义化方法:如
createUser、transferFunds、publishArticle - 不直接操作 Express 的 req/res 对象,保持与传输层的解耦
Service 代码示例:
// user.service.js
const userDao = require('./user.dao');
const { hashPassword } = require('../utils/crypto');
const { BusinessError } = require('../utils/errors');
async function createUser(userData) {
// 业务校验:检查邮箱是否已存在
const existing = await userDao.findByEmail(userData.email);
if (existing) {
throw new BusinessError('该邮箱已被注册', 409);
}
// 数据处理:加密密码
const hashedPassword = await hashPassword(userData.password);
// 调用数据层创建用户
const user = await userDao.create({
...userData,
password: hashedPassword,
});
// 可能触发其他业务操作,如发送欢迎邮件
// await emailService.sendWelcome(user.email);
return user;
}
module.exports = { createUser };
Service 内部可以调用多个 DAO,甚至调用外部的第三方服务封装(如邮件服务、支付接口)。它把零散的数据库调用组织成有意义的业务操作,Controller 只需调用一个方法即可完成整个注册流程。
3. DAO/Repository:数据访问层,负责与数据库的直接交互
DAO(Data Access Object)或 Repository 是分层中最底层的一层,它的唯一职责就是封装对数据源的访问细节:拼接 SQL、调用 ORM、处理数据格式。业务层不需要知道底层用的是 MySQL 还是 MongoDB,也不需要知道查询语句具体怎么写。
DAO 主要职责:
- 封装数据库操作方法:增、删、改、查(CRUD)
- 提供按条件查询的方法:如
findByEmail、findAllActiveUsers - 返回业务层友好的数据格式(如去掉敏感字段、转换为驼峰命名等)
- 不包含任何业务判断,只做数据存取
DAO 代码示例(使用 Prisma ORM):
// user.dao.js
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function findByEmail(email) {
return prisma.user.findUnique({ where: { email } });
}
async function create(userData) {
return prisma.user.create({ data: userData });
}
async function findById(id) {
return prisma.user.findUnique({ where: { id } });
}
async function update(id, updateData) {
return prisma.user.update({ where: { id }, data: updateData });
}
module.exports = { findByEmail, create, findById, update };
如果是原生 SQL 驱动,DAO 中就会写具体的 SQL 语句,但对外暴露的仍然是语义明确的方法名。当未来需要切换数据库或优化查询时,只需修改 DAO 内部实现,Service 层完全不受影响。
三层之间的调用规则
分层架构能有效的前提是严格的依赖方向:
- Controller → 依赖 → Service
- Service → 依赖 → DAO
- 不能反向依赖:DAO 不应该引入 Service,Service 也不应该引入 Controller
- 不能跨层调用:Controller 不能直接调用 DAO,必须经过 Service
这种单向依赖保证了代码的可测试性和可维护性。例如,团队可以单独对 Service 编写单元测试,使用 Mock 的 DAO 来模拟数据库操作;也可以对 Controller 编写集成测试,Mock 掉整个 Service 层。
分层架构的实际好处
1. 高度可测试
每一层都可以独立测试:DAO 可以用内存数据库测试,Service 可以注入假 DAO 验证业务逻辑,Controller 可以发送模拟 HTTP 请求并断言响应。
2. 应对变化的能力
- 如果 HTTP 接口协议改成 GraphQL 或 gRPC,只需新增对应的 Controller,Service 和 DAO 可以完全复用。
- 如果把 MySQL 换成 MongoDB,只需重写 DAO,Service 无需改动。
- 如果业务规则变了(比如注册流程增加手机号验证),只需修改 Service,Controller 和 DAO 不受影响。
3. 团队协作清晰
新加入的开发者可以快速理解项目结构:想看接口定义看 Controller,想了解业务规则看 Service,想排查数据问题看 DAO。三层职责明确,降低了沟通成本。
避免常见误区
误区一:Controller 过厚
把所有逻辑塞进路由回调函数是初期开发的常见问题。一旦发现 Controller 里出现了几十行的业务判断或直接调用 ORM 操作,就应该立刻重构到 Service 层。
误区二:Service 变成“传话筒”
有些简单的 CRUD 操作,Service 里只是直接调用 DAO 并返回,看起来像是多余的中间层。此时不必教条主义,如果确实没有额外逻辑,小型项目也可以让 Controller 直接调用 DAO。但随着项目变复杂,这种“简洁”往往会成为重构的负担。一个可接受的折衷是保留 Service,哪怕它暂时只是转发请求,这为将来扩展留下了空间。
误区三:DAO 里写业务判断
例如,在 DAO 的 create 方法里插入权限校验或数据加密逻辑,这会让数据层职责混乱。数据访问层应该保持纯粹,只做存取,所有“加工”和“判断”都交给 Service。
误区四:混用 Repository 和 DAO 术语
实践中 DAO 和 Repository 并没有严格的技术界限。通常 DAO 偏向于一个数据表对应一个类,包含 SQL 操作;Repository 则是领域驱动设计(DDD)中的概念,更强调集合的语义。在 Node.js 项目中,除非采用 DDD 架构,否则使用 DAO 一词即可,核心是保持数据访问单独成层。
在实际框架中的落地
- Express / Koa:手动创建
controllers/、services/、daos/目录,每个文件导出函数或类。 - NestJS:天然支持分层,模块中的
controller、service、repository(结合 TypeORM 的Repository模式)已经架构好,开发者只需遵循即可。 - Fastify:类似 Express,通过目录结构和插件体系自然分层。
不管用什么框架,分层依赖的是开发者的设计习惯。“三层架构”是后端工程化的第一课,它为我们后续讨论中间件、依赖注入、测试策略奠定了基础——所有更高级的架构模式,都是从清晰的职责分离开始的。