三层架构落地:从概念到可执行的工程规范
项目代号 t3code是我最近在一个中型 Node.js 项目重构时定的内部名字。全称是 Three-Tier Code拆开看就是三层代码表现层、业务层、数据层每一层只做本层该做的事不允许跨层乱写。起因很朴素——手头的服务跑了两年路由函数里开始直接拼 SQLController 里塞满了业务判断一个用户对象被十几个文件随手改每次改字段都要全项目排查。我相信很多做后端的朋友都有类似经历分层这个概念人人会说但真正落到代码里往往三周就烂了。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 为了判断这个用户能不能注册去调 serviceservice 为了省事直接查表最终所有层都糊在一起。我见过不少项目把这三层名字写进目录但实际互相引用乱成蜘蛛网。所以 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 只做 HTTPzod 只做校验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.findrepositories设计查询方法、实现 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 PickUser, email | passwordHash | nickname;紧接着定义数据层接口// src/repositories/user.repository.ts import type { User, NewUser } from ../types/user; export interface UserRepository { findByEmail(email: string): PromiseUser | null; findById(id: string): PromiseUser | null; create(data: NewUser): PromiseUser; updateNickname(id: string, nickname: string): PromiseUser; }接口定义里只放业务需要的数据操作不掺杂任何 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): PromiseUser | null { return this.prisma.user.findUnique({ where: { email } }); } async findById(id: string): PromiseUser | null { return this.prisma.user.findUnique({ where: { id } }); } async create(data: NewUser): PromiseUser { return this.prisma.user.create({ data }); } async updateNickname(id: string, nickname: string): PromiseUser { 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.infertypeof 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 被翻译成 409ZodError 被翻译成 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数据操作下沉到 repositorycontroller 只调 serviceservice 方法里写 res.json / res.status混淆了响应职责表现层统一处理响应service 返回数据或抛领域错误改数据库字段导致 API 返回结构变化ORM 实体直接穿透到响应定义 DTOcontroller 显式组装输出白名单repository 里出现权限判断把业务规则漏进了数据层权限判断搬到 servicerepository 只做数据访问两个 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 循环依赖、事务边界与 N1 陷阱分层守住了不等于没有坑这里分享三个在 t3code 里真实踩过的技术问题。第一个坑是循环依赖。表现层依赖业务层、业务层依赖数据层这条链路如果断了一个口子就会出现 A 文件 import BB 又 import A。典型场景是 service 里为了拼响应数据反向去 import controller 里的某个类型或常量。根治办法有两个一是统一使用数据从下往上流的方向任何跨层拿数据都必须从 repository 走二是在 ESLint 里开启import/no-cycle把循环依赖直接视为错误。TS 的import type也能解决一部分类型层面的循环但运行时依赖还是必须靠规则切断。第二个坑是事务边界到底放哪。很多初学者会在 repository 方法里写事务一个createWithProfile方法内部又插用户又插 Profile看起来很方便问题是一旦业务规则变化比如注册时还要发欢迎消息、要写日志你就得不停往这个 repository 方法里加参数。正确的姿势是 service 层开事务把多个 repository 的实例绑到同一事务连接上。这样 transaction 的粒度等于用例粒度而不是等于某次查询的粒度。这块代码在 3.2 已经给出过一个雏形真正落地时只要记住你的一步操作到底由哪些子步骤组成这个判断只属于 service。第三个坑是 N1 查询。分层之后很多人习惯在 service 里先拿用户列表然后循环调用 repository.findById 查询每个用户的订单。代码确实合规但性能烂到没法看。层分得好不代表可以忽略查询效率repository 层依然要提供批量方法比如findOrdersByUserIds(userIds: string[])。有一种声音说分层导致 N1这是替自己偷懒找借口——合适的批量方法本来就是数据层接口设计的一部分只是需要你对自己的数据访问模式有意识。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 直接操作了 ORMT1 测试的 mock 就会立刻暴露依赖关系如果哪层边界跨了测试的结构也会变得别扭。测试除了验证功能还在持续替架构站岗。5. 实操心得与后续演进5.1 开工前必须约定清楚的四件事结合这次 t3code 的落地经验如果团队要从零开始实施这套分层我建议开工前先把四件事定死能省掉后面 80% 的扯皮。第一件错误码字典。所有业务错误必须有唯一 codeHTTP 状态码只用于传输。不然 A 同事抛 400B 同事抛 422前端接起来各自为政。第二件DTO 与实体分离。ORM 实体比如 Prisma model 对应的 User不允许直接出现在 controller 的返回里必须显式白名单组装。一开始就养这个习惯比事后补安全审计容易得多。第三件跨层传参到底传对象还是传原始值。我的约定是controller 接req.body后作为整体传给 serviceservice 内部用 zod 解析repository 方法内部再按需组装查询条件。不要在 controller 里抽出一堆参数散着传散着传会让 service 的接口签名像流水账改一个字段牵连全链路。第四件代码评审检查清单。评审时逐项勾查有没有 controller 直接碰数据层service 里有没有 res 对象repo 里有没有业务 if这四件事比任何架构文档都实用因为它们是真正在代码合并前把关的闸门。5.2 落地后我看到的实际变化重构完成后的第三四个月感受最明显的变化是需求迭代速度。以前改一个注册时增加用户名唯一性检测的需求要在路由函数里翻半天逻辑现在只需要改 service 和 repository 各一个方法git diff 通常不超过 30 行。测试因为分层而变得好写团队新同学上手时看到目录结构就知道新功能应该往哪里放不用再追着老同事问这个查询写哪。如果你正准备做类似的事情我的建议是不要上来就追求完美分层。从当前最痛的一个模块开始比如用户体系先把它的 controller、service、repository 拆干净跑通工具链再逐步推广到其他模块。分层的价值在三个月后才会明显浮现那时候你会很难说服自己再回去写大泥球。t3code 目前对我来说已经不只是个代号它变成了一套判断代码归属的直觉。技术选型可以换工具链可以升级但每一层只做一件事依赖方向只许单向这套约束无论用在单体还是未来的模块化拆分里都长期有效。