如果你在搜索引擎里看到“t3code”这个词,大概率会以为它是一款新的代码生成器、命令行工具或者某个开源框架的名字。实际上,这是我给自己一个代码重构项目起的代号,全称可以理解为 Tier-3 Code,核心思想就一句话:把代码按三层边界组织,让每一行代码都清楚自己属于哪一层。这个项目前前后后折腾了三个月,从最初被混乱调用关系折磨到后来能比较顺畅地交付新功能,期间踩了不少坑。今天把完整思路、落地步骤和那些文档里不会写的教训一次性整理出来,给同样被代码耦合问题困扰的朋友做个参考。
这个项目最初源自一个比较典型的“屎山”场景:业务系统经过两年快速迭代,所有逻辑几乎都堆在页面组件和路由处理函数里,工具函数散落在各处,同一份数据校验逻辑至少有三份实现,新人接手一段代码往往要顺着 import 链翻五六个文件才能搞懂完整流程。t3code 就是在这个背景下启动的,目标是给代码建立清晰的层次边界,让依赖方向有规则可依,让测试有明确的落点。这套方案比较适合中大型业务系统、多人协作的 TypeScript 项目,如果你正在维护一个功能复杂、历史包袱重的仓库,或者打算在新项目之初就定好代码组织规范,这篇文章应该能给你一些直接可抄的灵感。
1. t3code 是什么:一个被名字误导的小项目
1.1 名字的来历与项目初心
项目代号 t3code 的来历其实很朴素:这是我在重构这个业务系统时做的第三版方案,T 代表 Tier,3 代表三层,code 就是代码本身。当时第一版方案是想引入完整的六边形架构,画了不少架构图,结果团队成员一看就觉得太复杂,落地阻力极大;第二版退了一步,只做目录整理,把相似功能的文件挪到一起,但本质上没有约束依赖方向,三个月后文件位置倒是整齐了,耦合程度一点没变。第三版我决定放弃宏大理论,只抓一条主线:给代码划分三个边界明确的层级,用工程手段强制保证依赖方向。
这条主线的背后,是当时团队最痛的三件事。第一,改一处功能经常需要同时动页面、动接口、动工具函数,风险面不可控;第二,测试没有目标,单元测试不知道测哪一层,集成测试又太重,最后干脆不写;第三,新人上手成本高,代码里到处都是“上帝文件”,几百行的 useEffect 里塞了各种业务规则。t3code 的设计目标就是让“代码属于哪一层”这个问题只有一个标准答案,任何代码审查时都可以快速判断一段代码放错了位置。
所以 t3code 不是一个开源库,也不是一个打包工具,它本质上是一套代码组织方法论加上对应的工程约束。方法论部分回答“怎么分层”,工程约束部分回答“怎么保证大家遵守分层”。这两件事缺一不可,只有方法论没有约束,三个月后边界必然失守;只有约束没有方法论,团队会为了分层而分层,反而制造新的混乱。
1.2 这套方案适合谁、不适合谁
先说适合的场景。如果你正在维护一个超过两万行代码、多人协作的 TypeScript 项目,并且已经被这些症状困扰——改一个工具函数导致十几个文件编译报错、同一个业务规则在页面里复制了三份、写单测不知道 anchor 在哪里——那 t3code 的分层思想大概率能直接帮到你。另外,如果你刚开始一个体量注定不会太小的新项目,在第一天就搭好三层边界,成本比后面重构低得多。
不适合的场景也很明确。一次性脚本、小型 demo、三五天就交付的内部小工具,还有那种只有一个人写、一个人读、永远不会大的项目,强行分层只会拖慢速度。我见过有人给一个二百行的脚本也硬套了三层结构,结果光找文件就比写代码还费劲。分层是有成本的,它的收益在代码规模和团队规模达到某个阈值之后才明显,低于这个阈值,简单的平铺直叙反而更好。一个基本判断标准:如果你的项目里最长文件超过 500 行,且经常需要两个以上的人同时改同一批文件,分层就值得做;如果还没有到这个程度,先把代码写干净、命名写清楚更重要。
2. 三层代码边界的划分逻辑:为什么是 Tier-3 而不是其他粒度
2.1 第一层:不可变的底层能力
tier1 是整个分层体系的地基,我把它定义为**“不依赖任何业务上下文的基础能力层”**。这里放的是纯函数、通用工具方法、领域类型声明、算法实现,特征是没有副作用、不读环境变量、不感知“订单”“用户”“商品”这些业务概念。
举个例子,一个格式化金额的函数就属于 tier1。它接收数字和货币代码,输出格式化的字符串,不关心这个金额是订单金额还是红包金额;一个校验手机号格式的正则函数也属于 tier1,它只验证字符串形态,不决定“哪些号码能注册”。我自己在落地时给 tier1 定了几条硬规矩:禁止 import 任何业务模块,禁止读写数据库,禁止直接引 UI 组件库。违反这些规矩的代码在 code review 时必须被拦下来。
这条规则的逻辑在于,tier1 是唯一一个“绝对安全”的层。因为没有任何东西依赖它之上的业务语义,它就能被最大程度地复用、单测和替换。我在重构时发现,原先散落在各处的日期格式化、金额计算、数组去重这些功能,合并归拢到 tier1 之后,整个项目里重复代码一下子少了很多。而且 tier1 的单元测试覆盖率是最容易做到 100% 的,因为它们没有副作用,测起来纯粹是“输入输出”的验证。
2.2 第二层:业务规则的可组合单元
tier2 是整套方案里最关键、也最容易做砸的一层。它存放的是业务规则和用例编排,可以理解为一组可以独立测试、可组合的服务。还是拿电商场景举例:计算订单折扣、校验库存、组装下单参数、编排“创建订单”的完整流程,这些逻辑都属于 tier2。
tier2 的规则是:可以依赖 tier1,可以依赖其他 tier2 服务,但禁止 import 任何 UI 相关的包,禁止直接写页面组件,也不直接操作数据库实例。这里很多人会有疑问:“不直接操作数据库,那数据从哪来?”答案是引入端口(Port)的概念——tier2 只定义“我需要一个能按订单 ID 查出订单数据的接口”,具体实现由 tier3 提供。这个设计比直接调用数据库多了一道抽象,但它让 tier2 可以在完全没有数据库的环境下跑测试,也让你将来换数据库、换 ORM 时不用动业务规则。
我在这层踩过的最大教训是不要过度设计。起初我参照某些 DDD 文章的做法,几乎每个业务动作都定义一个接口,结果 tier2 变成了一片接口森林,一个小功能要跨七个文件才能看懂。后来我把标准改成:只有当“这个能力可能有多种实现方式”或者“这个能力需要被测试替身替换”时才定义接口,其余情况直接依赖 tier1 的具体函数就够了。这个权衡让 tier2 既保持可测试性,又没有陷入抽象过度的泥潭。
2.3 第三层:最薄的接入层
tier3 是系统面对外部世界的壳子,包括 API 路由、页面组件、状态管理绑定、数据库适配器、消息队列消费者等等。它的核心准则是**“只做翻译和转发,不承载业务规则”**。
什么叫“只做翻译”?就是把 HTTP 请求参数翻译成 tier2 服务需要的入参,把 tier2 返回的结果翻译成前端需要的响应结构。比如一个创建订单的接口,tier3 要做的事是:从请求体里取参数、简单校验字段格式、调用 tier2 的创建订单服务、把结果包装成 201 响应。至于“满减规则怎么算”“库存怎么扣”“订单状态怎么流转”,tier3 一概不管,那是 tier2 的事。
这条边界划清楚之后,一个非常直观的好处是页面组件变薄了。重构前一个商品列表页有四百行代码,一半是取数逻辑、一半是过滤排序、一半是渲染逻辑;重构后组件里只剩渲染和用户交互,数据取数封装成一个 hook,过滤排序的逻辑挪到 tier2 成为一个可在别处复用的服务。页面变得容易读,也更容易改——想调整某个模块的视觉样式,不会牵一动百地影响业务规则。
可能你会问:为什么不是四层、五层,或者直接上 Clean Architecture 那种严格的洋葱架构?我的答案是层数越多,理解成本越高,强制约束的成本也越高。三层是团队认知的一个舒适区:新人花半天就能理解“底层能力、业务规则、接入层”分别是什么,再多一层“应用服务层”和“领域层”的区别,光解释就要开两次会。实际上我把 Clean Architecture 里的 Entity、Use Case、Adapter 做了归并,Entity 和纯工具归到 tier1,Use Case 归到 tier2,Adapter 和 Presenter 归到 tier3。三层的边界虽然粗,但足以覆盖绝大多数业务系统的核心痛点。
3. 从零搭建 t3code 项目骨架的记录
3.1 技术选型与工程初始化
t3code 的工程部分是在一个 TypeScript 项目里落地的,技术栈比较常规:pnpm 做包管理,ESLint 配合 typescript-eslint 做静态检查,Prettier 统一格式,用 tsconfig 的 paths 配置做了 @tier1、@tier2、@tier3 三个路径别名。为什么选 TypeScript?因为分层约束最怕“看不见依赖”,TS 的编译器和 lint 插件能帮你自动发现跨层引用,这在纯 JavaScript 项目里几乎不可能。
路径别名的主要价值是让代码里直观地表达层级归属。比如你在一个文件里看到import { validateOrder } from '@tier2/order',立刻知道这是业务规则层的东西;看到import { formatPrice } from '@tier1/money',就知道这是底层能力。相比相对路径,别名还有一个隐藏好处:当你通过 lint 规则检查依赖方向时,路径前缀就是最直接的匹配依据。
初始化的关键步骤大致是这样的:
- 用
pnpm init初始化项目,安装 typescript、eslint、prettier - 在 tsconfig.json 的
compilerOptions.paths里配置三个别名映射到src/tier1/*、src/tier2/*、src/tier3/* - 安装
eslint-plugin-import,开启import/no-cycle防止循环引用 - 安装
eslint-plugin-boundaries,配置允许的依赖方向:tier1 不能依赖 tier2/tier3,tier2 不能依赖 tier3,同层之间允许但建议谨慎 - 在 CI 里加一个 job,专门跑
eslint --ext .ts src,一旦出现跨层引用直接红牌
eslint-plugin-boundaries是我整个工程约束里最关键的一环。它的配置可以精细到“哪个目录可以 import 哪些别名”,基本等价于把分层规则写进了代码检查流程。团队里有人不小心在 tier2 里 import 了一个组件库,或者从 tier3 反向引用了 tier1 的东西,在开发机上就会立刻看到报错,不用等 code review 来人工抓。
3.2 目录结构与依赖方向的落地
t3code 的目录结构其实非常朴素,没有魔法,就是一个明确的 src 下分三个大目录:
src/ ├── tier1/ │ ├── money.ts │ ├── date.ts │ ├── validators.ts │ └── types.ts ├── tier2/ │ ├── order/ │ │ ├── discount.ts │ │ ├── stock.ts │ │ └── createOrder.ts │ ├── user/ │ │ └── register.ts │ └── ports/ │ ├── orderRepository.ts │ └── userRepository.ts └── tier3/ ├── api/ │ ├── orderRoute.ts │ └── userRoute.ts ├── adapters/ │ ├── orderRepositoryImpl.ts │ └── userRepositoryImpl.ts └── components/ └── OrderList.tsx这个结构看起来平平无奇,关键在两条边界:第一,tier3 的adapters目录实现了 tier2 的ports接口,所以依赖方向是 tier3 依赖 tier2 定义的接口,而不是反过来;第二,tier1 里没有任何业务模块的影子,它只是纯粹的工具和类型集合。
实际开发时,依赖方向的设计还要配合一个“跳级禁止”的规则。理论上 tier3 可以直接调用 tier1 的纯函数,这没有问题,但 tier1 的任何东西都不能被 tier3 绕过 tier2 直接去驱动业务逻辑,因为一旦允许跳过中间层,很容易在页面里重新堆积业务代码。我见过一个反模式:页面里直接调用 tier1 的一个字符串解析函数,然后通过一连串 if-else 自己拼出了业务规则,表面看每一步都符合分层,实际业务逻辑还是回到了 UI 层。所以除了依赖方向,还要审查“业务判断到底写在哪”。
4. 实际开发中我踩过的七个关键坑
4.1 循环依赖的根因与修复
循环依赖是我在 t3code 里遇到的第一个硬骨头。现象非常诡异:代码在本地开发时一切正常,但 CI 上执行类型检查偶尔报错,而且报错信息指向的文件和实际改动完全无关。最离谱的一次,只是改了一个工具函数的注释,CI 却报出三个文件的类型错误,排查了一个下午才发现竟然存在一个隐形的依赖环。
追根溯源的过程是这样的。tier2 的createOrder.ts里定义了一个CreateOrderResult类型,它里面包含一个orderDto字段,这个 DTO 类型当时我图方便放在了 tier3 的dto/orderDto.ts里。这样一来,tier2 在编译时需要读取 tier3 的类型定义。而 tier3 的业务逻辑又必须调用 tier2 的创建订单服务,于是两个方向都有了依赖:tier3 依赖 tier2 的实现,tier2 依赖 tier3 的类型。TypeScript 编译这种循环引用时,有时候能糊弄过去,有时候因为声明顺序问题就炸了。
修复办法是让共享类型“往上走”:把Order这种核心领域类型放回 tier1,tier3 的 DTO 在需要时通过类型映射转换成自己的响应格式。这样 tier2 只依赖 tier1,tier3 也依赖 tier1,环形依赖自然消失。自此我定了一条铁律:所有跨阶层共享的类型定义,一律放进 tier1;谁需要谁引用,禁止用自己的类型反过来污染其他层。
4.2 接口爆炸:过度抽象带来的维护负担
前面提到过过度设计的问题,这里展开说说。第一期重构时,我参考了网上很多“依赖倒置最佳实践”的文章,给 tier2 几乎每个服务都配了接口,从OrderRepository到DiscountCalculator,一应俱全,看起来非常符合教科书规范。结果到了第二个月,团队开始抱怨:改一个接口的入参,要同步改实现类、改调用方、改 mock、改测试,一个半小时的工作量硬生生拖到了一天。
回过头反思,接口爆炸的根源是把“面向接口编程”理解成了“处处都要有接口”。正确的依赖倒置应该是:只在“边界”上做接口,比如 tier2 与数据库、与外部 API 的边界;而 tier2 内部的具体业务服务之间,直接依赖具体实现反而更清晰。因为同一个 tier2 内部的服务永远不会被替换成另一个实现,接口只是徒增跳转。调整策略之后,tier2 的端口只剩了OrderRepository和UserRepository等少数几个真正需要抽象的地方,其余内部服务一律用具体类型,整个代码的阅读流畅度立刻回来了。
4.3 测试写不透导致分层形同虚设
分层的价值想要兑现,必须靠测试来兜底,否则边界只是审美偏好。初期我们只给 tier3 的接口写了集成测试,tier2 的业务代码基本裸奔。结果上线后出过两次事故,排查发现都是 tier2 里的折扣计算逻辑在某个月份边界条件下算错了,而集成测试因为测试数据太固定,根本没覆盖到这种分支。
修复的方法是建立“分层的测试金字塔”:tier1 要求逐函数全覆盖,这是成本最低、收益最高的一层;tier2 围绕业务分支写单元测试,重点是 if-else 边界和各种金额计算;tier3 的接口只做轻量的冒烟测试,验证参数传递和状态码正确即可。这个策略的关键逻辑是:大多数业务 bug 藏在 tier2 的复杂分支里,而不是 tier3 的翻译逻辑里。测试资源应该重点压在有业务规则的地方。后来我们用覆盖率工具卡指标,tier1 要求 90% 以上,tier2 要求关键业务文件 80% 以上,tier3 不设硬性指标,只保证主干链路有冒烟用例。
4.4 这四个坑分布在细节里
除了上面三个大坑,还有四个比较隐蔽但同样容易踩的问题。
第一个是组件库污染 tier2。曾经有段时间 tier2 的促销服务里直接import了一个日期选择组件的逻辑来算活动周期,虽然功能是对的,但架构上 tier2 从此绑死在了某个 UI 框架上。当项目想把组件库从 A 换到 B 时,业务层代码也得跟着改,分层的意义就崩了。 tier2 里的任何 UI 相关引用都应该被 review 拦下,除非你根本不指望这个业务层能脱离界面存在。
第二个是 utils 文件夹变成垃圾堆。一开始 tier1 底下只有一个utils.ts,结果三个月后这个文件膨胀到两千行,里面既有和业务相关的“订单状态文案处理”,又有完全通用的“数组分组函数”。这和没分层没区别。解决方案是把 tier1 按领域拆成money.ts、date.ts、validators.ts,并在 review 时严格执行“业务概念不能进底层”的规则。
第三个是模块只拆到目录没拆到依赖。有些同事为了应付分层要求,把业务代码复制进了 tier2,但原页面的代码没有删干净,形成新旧两份实现并存,后来维护时出现数据不一致。迁移不是复制,而是“先建新、再切流、后删旧”三步走,缺少最后一步都会留下隐患。
第四个是强推分层导致的短期效率下降。前两周团队普遍觉得写代码变慢了,因为每写几行就要思考“这个函数到底属于哪一层”。这个问题只能靠时间消化,大概三周之后大家形成了肌肉记忆,速度反而超过之前,因为不用在一堆乱麻里反复确认逻辑到底写在哪。如果团队有严格的交付压力,建议在启动初期预留缓冲,不要一边大改架构一边死守原排期。
5. 用数据验证 t3code 的效果
5.1 度量项怎么选
重构这种事,最怕自我感觉良好,必须用数据说话。但指标也不能乱选,选错了容易诱导团队做一些表面功夫。我实际用的度量项分两类,一类是工具能直接产出的客观数据,一类是团队感受的定性反馈。
客观数据里最有参考价值的是这几个:
| 度量项 | 说明 | 工具 |
|---|---|---|
| 循环依赖数量 | 重构前经常有隐形的 import 环,用工具直接扫 | eslint-plugin-import / madge |
| 单文件平均行数 | 超过 300 行的文件是代码味道的指示器 | cloc / jscpd |
| 单测覆盖率 | 分到 tier1/tier2/tier3 分别统计 | c8 / vitest |
| 新增需求平均改动文件数 | 指标越小,说明改动受控 | Git commit 统计 |
定性反馈主要通过两个问题收集:一是“改一个功能平均需要看多少个文件”,二是“新人从接手代码到定位首个 bug 需要多久”。这两个问题没有标准答案,但纵向对比自己项目前后半年的变化,很有说服力。
5.2 三个月重构前后的对比
t3code 在业务系统上跑了三个月,下面是几个核心指标的实测对比,这个项目是三十万行左右的 TypeScript 仓库,团队 8 人:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| 循环依赖数量 | 17 处 | 0 处 |
| 单文件平均行数 | 486 行 | 186 行 |
| tier2 业务代码覆盖率 | 约 20% | 82% |
| 新增需求平均改动文件数 | 8.6 个 | 3.2 个 |
| 新人首次定位 bug 耗时 | 平均一上午 | 约一小时 |
循环依赖从 17 处降到 0,这个效果很大程度上得益于 builds 时的红牌拦截。单文件平均行数降下来,是分层和拆解共同作用的结果,代码从“长而复杂”变成了“短而清晰”。覆盖率从 20% 提到 82%,绝大多数核心规则都有测试保护,团队改代码时的安全感明显提升。新增需求平均改动文件数从 8.6 降到 3.2,说明一个需求的影响面被控制住了,不会因为改一个字段牵出七八个文件。
当然这些数字也有它不完美的地方。比如后续老系统里还残留着一些边缘模块没有完全迁移到三层结构,所以“平均文件行数”整体数据里,tier3 有些组件依然偏胖。但方向已经很清晰:分层带来的不是某个单点指标的漂亮数字,而是让所有指标同时变好,并且没有哪个指标以牺牲另一个指标为代价。
6. 一些没人告诉你的实操心得
6.1 分层的红线清单
最后想分享一张我在团队内一直使用的红线清单,每当 code review 拿不准时,就拿出来对一遍:
- tier1 禁止 import 任何业务模块、禁止读写外部存储、禁止引用 UI 库
- tier2 禁止 import UI 相关包、禁止直接实例化数据库客户端、禁止依赖 tier3 的任何实现
- tier3 允许依赖 tier1 和 tier2,但组件和路由文件里不允许出现复杂的业务判断
- 跨层跳级不被鼓励,尤其是“页面直接拼业务规则再调用底层函数”的模式
- 同层之间的互相 import 允许,但当一个文件被同层五个以上模块引用时,仔细想想它是不是更应该放到底层
- 任何跨层共享的类型,第一时间考虑上移到 tier1,不要留在别的层里倒挂依赖
这些红线不是设计文档里的远大理想,而是用一次次线上事故和 CI 失败换来的。如果只能带走一条,我会选择:“依赖方向必须单一且自上而下,底层永远不要反向上帝视角。” 只要这条守住,其他细节出问题都只是时间问题,都可以后续修补;这条破了,分层的堤坝就彻底倒了。
6.2 这个思路后续还能怎么扩展
t3code 的三层方案跑通之后,团队开始把一些 tier2 的核心业务模块按业务域拆分,准备向 monorepo 演进。理论上这条路很自然:一旦代码已经按 tier2 的业务域切分清楚,把它们整个目录提出来变成独立的 package,只是工程化的一步而已,不需要再重新梳理边界。另外,如果项目规模进一步扩大,tier2 内部也可以再细分为“纯领域逻辑”和“应用编排逻辑”,但那是在人员规模超过二三十人时才值得考虑的复杂度。
我个人在这三个月里的最大体会是,项目代号叫不叫 t3code 根本不重要,重要的是边界意识。代码分层从来不是一个技术问题,而是一个团队习惯问题。任何方法论,只要团队没有把它变成每天写代码时的下意识动作,就只是一份漂亮的文档。t3code 的工程约束之所以起作用,是因为 lint 插件和 CI 不是靠自觉,而是靠机器强制,让人“不想违反”变成“不能违反”。如果你也想在自己的项目里试这套思路,我建议从一条最小约束开始:先给目录定好三层别名,再配好 boundaries 规则,拦住跨层 import。这一条落地之后,其余的边界细节会在真实的代码冲突里自然浮出来,那时候你就知道下一步该细化哪里了。