项目代号 t3code,是我最近在一个中型 Node.js 项目重构时定的内部名字。全称是 Three-Tier Code,拆开看就是三层代码:表现层、业务层、数据层,每一层只做本层该做的事,不允许跨层乱写。起因很朴素——手头的服务跑了两年,路由函数里开始直接拼 SQL,Controller 里塞满了业务判断,一个用户对象被十几个文件随手改,每次改字段都要全项目排查。我相信很多做后端的朋友都有类似经历:分层这个概念人人会说,但真正落到代码里,往往三周就烂了。t3code 这个项目要解决的,就是让"三层架构"从 PPT 概念变成可执行、可评审、能长期守住的工程规范。
这篇内容适合的人很明确:正在维护一个已经出现"架构腐化"信号的老项目、准备重新搭一个需要长期演进的全栈服务、或者带团队想统一代码边界的同学,都能直接参考。不需要你是架构师,只要写过一段时间的 CRUD,就能看懂下面的目录、规则和示例。
1. 项目概述与设计动机
1.1 为什么项目代号叫 t3code
定义这个东西不是拍脑袋。当时团队里争论最多的问题,是"到底要不要上微服务"。我的判断是:一个只有几万行代码、团队不到十人的服务,上微服务只会把复杂度从代码里搬到网络和部署里,现行架构的真正问题不是规模,而是边界。
所以我把重构目标直接定为"单体内部分层"。t3code 里的 T3,明确指三层:表现层处理 HTTP 和响应格式,业务层承载用例与业务规则,数据层负责持久化访问。加上 code 后缀,是因为这个项目的一切约束最终都落在代码上——目录、依赖方向、类型引用,全部要在代码层面可检查,不能只靠团队自觉。
分层到底能带来什么?最直接的一点,是让"改动"变得可定位。一个注册功能要调整密码策略,你只需要打开 service 文件;要加一个字段,你只需要动 schema 和 repository;要改接口返回格式,你只需要动 controller。如果不分层,这三件事会同时发生在同一个大函数里,每次需求变更都变成一次考古。
1.2 三层职责边界与依赖方向
我习惯先把职责边界写成一张表,贴在项目根目录的 README 里,比任何口头约定都管用。
| 层 | 核心职责 | 允许依赖 | 禁止行为 |
|---|---|---|---|
| 表现层(controllers) | 解析入参、调用服务、组织响应 | 只依赖业务层 | 拼 SQL、写业务规则、操作数据库 |
| 业务层(services) | 校验规则、编排流程、事务边界 | 只依赖数据层接口 | 出现 res.json / req.body、直接写 ORM 查询 |
| 数据层(repositories) | 增删改查、ORM/数据库细节 | 不依赖上层任何模块 | 出现业务判断、抛出 HTTP 异常 |
依赖方向是这套体系里最重要的一条铁律:上层可以依赖下层,下层绝不能反向依赖上层。为什么这么强调?因为反向依赖一旦出现,代码就会立刻开始互相传染——repository 为了判断"这个用户能不能注册"去调 service,service 为了省事直接查表,最终所有层都糊在一起。
我见过不少项目把这三层名字写进目录,但实际互相引用乱成蜘蛛网。所以 t3code 里我不光靠约定,还在 CI 里跑了依赖检查工具,凡是违反方向的代码直接构建失败。这个思路会在后面第 4 章详细展开。
2. 技术选型与目录结构设计
2.1 为什么用 TypeScript 做底座
t3code 的技术方案,底层第一块积木是 TypeScript。不是因为它"流行",而是因为分层架构对类型契约的依赖极重。三层之间怎么通信?靠接口定义。表现层调业务层时,入参是什么、返回什么;业务层调数据层时,又传什么、拿什么——这些边界如果靠人脑记忆,一定出错。TS 把边界契约变成了编译器能检查的东西,结构传错了、返回类型对不上,跑都跑不起来。
具体到三层,TS 还有三个非常实用的点要特别说明。
第一,type-only import 可以帮助切断运行时循环依赖。跨层引用类型时,用import type { User } from '../types/user',编译后不会产生 require 关系,两个文件互相引类型不会造成运行时死循环。
第二,可以很容易地区分持久化实体和 API 响应结构。数据层返回的User包含 passwordHash,表现层响应却绝不能把这个字段发出去。定义User、UserDTO两个类型,靠结构系统去限制,比在 controller 里手写删字段可靠得多。
第三,接口先行特别顺畅。业务层依赖的UserRepository可以先定义成 TS interface,数据层再写实现。业务逻辑不会被某个具体 ORM 锁死,测试时还能塞一个 fake 实现进去。
其他配套选型,我推荐一组很稳的组合:Express(或 Hono)做表现层框架,zod 做边界校验,Prisma 做数据层 ORM。这套组合的好处是每个工具只负责一个点,不越界:Express 只做 HTTP,zod 只做校验,Prisma 只做 CRUD。选型的关键不是追新,而是每个工具的职责范围刚好落在某一层里。
2.2 目录结构与每层约束
目录结构是 t3code 最直观的部分,我直接给出一个可以抄的版本:
src/ controllers/ # 表现层:HTTP 入参解析与响应 services/ # 业务层:用例编排与业务规则 repositories/ # 数据层:持久化访问(接口 + 实现) db/ # Prisma schema、migrations types/ # 跨层共享的领域类型 validators/ # zod schema,边界校验定义 utils/ # 密码哈希、错误类等基础工具 app.ts # 应用装配与依赖注入目录名字本身不产生约束,真正有约束力的是"每一层允许做什么、禁止做什么"。我把这些规则直接写进了代码评审清单,并且在 CI 里加了一道自动检查。
| 目录/层 | 可以做的事 | 绝对不能做的事 |
|---|---|---|
| controllers | 从 req 取值、调 service、统一响应格式 | 出现 SQL、操作 ORM model、写 if 业务判断 |
| services | 业务规则、编排多个 repo、开事务 | 出现 res.json、req.body、直接执行 prisma.user.find |
| repositories | 设计查询方法、实现 ORM/DB 细节 | 做权限判断、抛 HTTP 400/500、被 service 反向调用 |
| validators | 定义输入输出 schema,做类型推导 | 依赖 controller/service 里的逻辑 |
有人问我,为什么连"service 直接查表"也要禁止?这看起来只是代码位置问题。我的回答是:service 一旦直接操作 ORM,数据访问的细节就会散落到所有业务逻辑里,想换缓存、想统一分页、想审计,根本找不到一个收口的地方。repository 存在的意义,就是给数据访问一个"单独的门",让上层永远只对着接口说话。
3. 核心环节实操:以用户注册模块为例
3.1 数据层先行:先定义接口再写实现
很多项目写代码习惯从界面或者路由开始写,这是分层最容易崩的地方。t3code 的做法是反着来:先定义领域类型和 Repository 接口,再往上层写业务。原因很简单——数据层是地基,地基长什么样,决定了业务层能怎么站。
先定义用户类型:
// src/types/user.ts export interface User { id: string; email: string; passwordHash: string; nickname: string; createdAt: Date; } export type NewUser = Pick<User, 'email' | 'passwordHash' | 'nickname'>;紧接着定义数据层接口:
// src/repositories/user.repository.ts import type { User, NewUser } from '../types/user'; export interface UserRepository { findByEmail(email: string): Promise<User | null>; findById(id: string): Promise<User | null>; create(data: NewUser): Promise<User>; updateNickname(id: string, nickname: string): Promise<User>; }接口定义里只放"业务需要的数据操作",不掺杂任何 SQL、不掺杂"也许以后用得上"的猜测。这一步是在替业务层画好边界:业务层想拿用户数据,只能通过这些方法。
然后才写 Prisma 实现:
// src/repositories/prisma-user.repository.ts import { PrismaClient } from '@prisma/client'; import type { UserRepository } from './user.repository'; import type { User, NewUser } from '../types/user'; import type { Prisma } from '@prisma/client'; export class PrismaUserRepository implements UserRepository { constructor(private readonly prisma: PrismaClient) {} async findByEmail(email: string): Promise<User | null> { return this.prisma.user.findUnique({ where: { email } }); } async findById(id: string): Promise<User | null> { return this.prisma.user.findUnique({ where: { id } }); } async create(data: NewUser): Promise<User> { return this.prisma.user.create({ data }); } async updateNickname(id: string, nickname: string): Promise<User> { return this.prisma.user.update({ where: { id }, data: { nickname } }); } }注意实现里没有任何业务判断,Prisma 的类型会被映射成 User 返回,层与层之间的契约非常干净。如果后面想把 Prisma 换成 Drizzle 甚至裸 SQL,只需要替换 repositories 目录里的实现,service 和 controller 一行都不用动。
3.2 业务服务层:把规则留在中间
数据层接口就绪,业务层开始承载用例。以"注册"作为一个典型用例,它涉及的规则是:输入校验、邮箱唯一性检查、密码哈希、创建用户。这些规则放 controller 太厚,放 repository 又太脏,唯一的归宿就是 service。
// src/services/user.service.ts import { z } from 'zod'; import type { UserRepository } from '../repositories/user.repository'; import { hashPassword } from '../utils/password'; import { DomainError } from '../utils/errors'; export const registerSchema = z.object({ email: z.string().email(), password: z.string().min(8).max(64), nickname: z.string().min(2).max(20), }); export type RegisterInput = z.infer<typeof registerSchema>; export class UserService { constructor(private readonly users: UserRepository) {} async register(input: RegisterInput) { const data = registerSchema.parse(input); const existing = await this.users.findByEmail(data.email); if (existing) { throw new DomainError('EMAIL_TAKEN', '该邮箱已注册'); } const passwordHash = await hashPassword(data.password); const user = await this.users.create({ email: data.email, passwordHash, nickname: data.nickname, }); return user; } }这段代码有几个细节值得琢磨。
校验放在 service 而不是 controller,这是刻意的。controller 负责的是 HTTP 层合法性,比如字段是否存在;而业务层校验的是领域规则的合法性,比如密码长度、邮箱格式。把校验下沉到 service,意味着任何入口调用这个用例(HTTP、定时任务、脚本)都必须经过同一套规则,不会出现"同一个规则在接口层和内部调用时表现不一样"的经典 bug。
错误类型用 DomainError,而不是 HTTP 状态码。这一条特别关键。业务层绝不应该抛 400、409 这种 HTTP 语义的异常,因为业务层并不知道调用方是 HTTP 还是 RPC 还是消息队列。t3code 里统一用带 code 的领域错误,由表现层负责翻译成具体协议,职责就完整分开了。
事务边界也是 service 的活。如果注册时还要创建默认 Profile 表,就适合在 service 里开一个事务,把两个 repo 的操作包进去:
async function registerWithProfile(input: RegisterInput) { return this.db.$transaction(async (tx) => { const userRepo = new PrismaUserRepository(tx); const profileRepo = new PrismaProfileRepository(tx); const user = await userRepo.findByEmail(input.email); if (user) throw new DomainError('EMAIL_TAKEN', '该邮箱已注册'); const created = await userRepo.create(...); await profileRepo.create(created.id, { ... }); return created; }); }事务不能塞到 repository 里,因为单个 repo 只知道自己的读写,只有 service 才知道"注册"这个用例需要哪几步一起成功、哪几步可以分开。
3.3 表现层:只做翻译,不做决策
表现层在 t3code 里是最薄的一层,薄到几乎不需要"设计模式"。它只做三件事:把请求参数取出来,丢给 service,把结果翻译成 HTTP 响应。代码示范:
// src/controllers/user.controller.ts import { Request, Response } from 'express'; import { UserService, registerSchema } from '../services/user.service'; import { DomainError } from '../utils/errors'; import { z } from 'zod'; export class UserController { constructor(private readonly users: UserService) {} async register(req: Request, res: Response) { try { const user = await this.users.register(req.body); res.status(201).json({ data: { id: user.id, email: user.email, nickname: user.nickname }, }); } catch (err) { if (err instanceof DomainError) { res.status(409).json({ error: err.code }); return; } if (err instanceof z.ZodError) { res.status(400).json({ error: 'INVALID_INPUT', details: err.issues }); return; } res.status(500).json({ error: 'INTERNAL_ERROR' }); } } }这个 controller 有两个刻意设计。
第一,组装响应的对象不是 User 本身,而是一个明确的白名单结构。id/email/nickname三个字段是写死的,不把passwordHash直接暴露出去。类型系统帮了大忙:如果 service 返回的 User 类型以后新增敏感字段,这里不会自动带出去,必须显式加白。
第二,错误映射表统一放在 controller 层。DomainError 的 code 被翻译成 409,ZodError 被翻译成 400,未知错误统一 500。这样 service 始终保持"领域语言",HTTP 语义只在最外层出现一次。
我也见过大量反面代码,在这里给一个"怎么写出分层的恐怖场景"的范例:
// 反面教材:三层全糊在一个函数里 export async function register(req: Request, res: Response) { const { email, password, nickname } = req.body; if (password.length < 8) { res.status(400).json({ error: 'PASSWORD_TOO_SHORT' }); return; } const result = await db.query('SELECT * FROM users WHERE email = $1', [email]); if (result.rows.length > 0) { res.status(409).json({ error: 'EMAIL_TAKEN' }); return; } const hash = await bcrypt.hash(password, 10); await db.query('INSERT INTO users (email, password_hash, nickname) VALUES ($1, $2, $3)', [email, hash, nickname]); res.status(201).json({ ok: true }); }这个版本的问题不是"能不能跑",而是每一个问题都成倍放大:换数据库要改这个函数、加字段要改这个函数、调整校验规则要改这个函数、连想单元测试都没法测。分层的目的不是多写几个文件,而是让变化被隔离在最小范围。
4. 常见问题与排查技巧实录
4.1 分层泄漏的典型症状与根治
分层架构在落地时最困难的部分不是写出来,而是"守得住"。我总结了几个最常见的泄漏症状,对应根因和处理方法,做成速查表。
| 泄漏症状 | 根因 | 根治方法 |
|---|---|---|
| controller 里出现 SQL 或 ORM model 查询 | 图省事,绕过 service | 数据操作下沉到 repository,controller 只调 service |
| service 方法里写 res.json / res.status | 混淆了响应职责 | 表现层统一处理响应,service 返回数据或抛领域错误 |
| 改数据库字段导致 API 返回结构变化 | ORM 实体直接穿透到响应 | 定义 DTO,controller 显式组装输出白名单 |
| repository 里出现权限判断 | 把业务规则漏进了数据层 | 权限判断搬到 service,repository 只做数据访问 |
| 两个 service 互相 import | 职责划分不清 | 抽公共用例或用 domain service 做编排,禁止平级互绕 |
光靠代码评审拦不住所有泄漏,所以我在 t3code 里接了一个自动检查工具:dependency-cruiser。它能分析文件之间的 import 路径,在 CI 阶段直接拒绝违反依赖方向的代码。
npx depcruise --include-only '^src' src --config .dependency-cruiser.js配置文件里写上"控制器不能碰数据层、数据层不能依赖服务层"这两条核心规则:
// .dependency-cruiser.js module.exports = { forbidden: [ { name: 'no-data-layer-in-controller', from: { path: 'controllers', pathNot: '(controller|test|spec)' }, to: { path: '(repositories|prisma|db)' }, }, { name: 'no-repository-dependent-on-service', from: { path: 'repositories' }, to: { path: 'services' }, }, ], };把这条命令接进 CI 之后,代码评审的负担一下子轻了很多。人可以聊业务、聊命名、聊有没有更优雅的方案,机械的架构违规交给机器拦。这套组合拳实测下来,是我见过有效保持分层不腐化的最省力方式。
4.2 循环依赖、事务边界与 N+1 陷阱
分层守住了不等于没有坑,这里分享三个在 t3code 里真实踩过的技术问题。
第一个坑是循环依赖。表现层依赖业务层、业务层依赖数据层,这条链路如果断了一个口子,就会出现 A 文件 import B,B 又 import A。典型场景是 service 里为了拼响应数据,反向去 import controller 里的某个类型或常量。根治办法有两个:一是统一使用"数据从下往上流"的方向,任何跨层拿数据都必须从 repository 走;二是在 ESLint 里开启import/no-cycle,把循环依赖直接视为错误。TS 的import type也能解决一部分类型层面的循环,但运行时依赖还是必须靠规则切断。
第二个坑是事务边界到底放哪。很多初学者会在 repository 方法里写事务,一个createWithProfile方法内部又插用户又插 Profile,看起来很方便,问题是一旦业务规则变化,比如注册时还要发欢迎消息、要写日志,你就得不停往这个 repository 方法里加参数。正确的姿势是 service 层开事务,把多个 repository 的实例绑到同一事务连接上。这样 transaction 的粒度等于用例粒度,而不是等于某次查询的粒度。这块代码在 3.2 已经给出过一个雏形,真正落地时只要记住:你的"一步操作"到底由哪些子步骤组成,这个判断只属于 service。
第三个坑是 N+1 查询。分层之后,很多人习惯在 service 里先拿用户列表,然后循环调用 repository.findById 查询每个用户的订单。代码确实合规,但性能烂到没法看。层分得好不代表可以忽略查询效率,repository 层依然要提供批量方法,比如findOrdersByUserIds(userIds: string[])。有一种声音说"分层导致 N+1",这是替自己偷懒找借口——合适的批量方法本来就是数据层接口设计的一部分,只是需要你对自己的数据访问模式有意识。
4.3 测试按 T1/T2/T3 操作,验证架构边界
t3code 里的 T3 本意是三层架构,但测试策略我一样按 T1/T2/T3 分了三层,和标题正好对上。
| 测试层级 | 测什么 | 怎么做 | 关键价值 |
|---|---|---|---|
| T1 单元测试 | service 业务规则 | mock repository,构造各种输入 | 业务用例在无人依赖下可完整验证 |
| T2 集成测试 | repository + 真实数据库 | 用测试库跑 CRUD,验证 SQL/ORM | 数据库行为符合预期 |
| T3 端到端测试 | HTTP 接口全链路 | 起完整服务,发请求断言响应 | 验证三层组合后的最终行为 |
T1 测 service 时,fake repository 是关键。我常用一个内存版实现放到测试目录里,几十行代码就能模拟出"邮箱已存在""创建失败"等场景,不用连数据库,跑得飞快。这正好是"接口先行"的好处:业务层完全不依赖 Prisma,替换成内存实现毫无压力。
T2 测 repository 时一定要用真实数据库,哪怕用 SQLite 或者 Docker 里的 Postgres,至少要让 ORM 真的执行 CRUD,才能抓到字段映射、schema 不一致的问题。
T3 端到端测试不用覆盖所有业务分支,只把"注册成功""注册失败"这类关键链路走一遍,更多是对交付质量的兜底,不必贪多。
这套测试结构还有一层额外价值:如果哪天有人不小心让 service 直接操作了 ORM,T1 测试的 mock 就会立刻暴露依赖关系;如果哪层边界跨了,测试的结构也会变得别扭。测试除了验证功能,还在持续替架构站岗。
5. 实操心得与后续演进
5.1 开工前必须约定清楚的四件事
结合这次 t3code 的落地经验,如果团队要从零开始实施这套分层,我建议开工前先把四件事定死,能省掉后面 80% 的扯皮。
第一件,错误码字典。所有业务错误必须有唯一 code,HTTP 状态码只用于传输。不然 A 同事抛 400,B 同事抛 422,前端接起来各自为政。
第二件,DTO 与实体分离。ORM 实体(比如 Prisma model 对应的 User)不允许直接出现在 controller 的返回里,必须显式白名单组装。一开始就养这个习惯,比事后补安全审计容易得多。
第三件,跨层传参到底传对象还是传原始值。我的约定是:controller 接req.body后作为整体传给 service,service 内部用 zod 解析,repository 方法内部再按需组装查询条件。不要在 controller 里抽出一堆参数散着传,散着传会让 service 的接口签名像流水账,改一个字段牵连全链路。
第四件,代码评审检查清单。评审时逐项勾查:有没有 controller 直接碰数据层?service 里有没有 res 对象?repo 里有没有业务 if?这四件事比任何架构文档都实用,因为它们是真正在代码合并前把关的闸门。
5.2 落地后我看到的实际变化
重构完成后的第三四个月,感受最明显的变化是需求迭代速度。以前改一个"注册时增加用户名唯一性检测"的需求,要在路由函数里翻半天逻辑,现在只需要改 service 和 repository 各一个方法,git diff 通常不超过 30 行。测试因为分层而变得好写,团队新同学上手时,看到目录结构就知道新功能应该往哪里放,不用再追着老同事问"这个查询写哪"。
如果你正准备做类似的事情,我的建议是不要上来就追求完美分层。从当前最痛的一个模块开始,比如用户体系,先把它的 controller、service、repository 拆干净,跑通工具链,再逐步推广到其他模块。分层的价值在三个月后才会明显浮现,那时候你会很难说服自己再回去写大泥球。
t3code 目前对我来说已经不只是个代号,它变成了一套判断代码归属的直觉。技术选型可以换,工具链可以升级,但"每一层只做一件事,依赖方向只许单向"这套约束,无论用在单体还是未来的模块化拆分里,都长期有效。