T3code 这个名字第一次出现,是在我们自己的一次架构复盘会上。当时团队里其实不缺 TypeScript 规范,也不缺测试框架,真正缺的是一个能把“类型怎么写、业务逻辑放哪、工具函数怎么隔离”统一起来的边界约定。T3code 后来成了我们内部沿用的一套工程落地规范,T 指 Type-first,3 指三件核心事:类型驱动、可测试、工具链自动校验。它不是脚手架,也不是某个开源框架,更像一份“代码边界约定”,用来回答一个很实际的问题:中大型前端和 Node 项目里,代码到底怎么放、类型到底怎么传、测试到底写在哪一层才不用反复返工。如果你带的是 5 人以上的研发团队,或者自己维护的项目已经出现改一个字段牵连十处报错的苗头,这篇内容大概率对你有直接参考价值。
1. 先拆清楚:T3code 到底在解决什么
1.1 没有边界约定的项目,维护成本会怎么失控
先说现象。不少项目在初期跑得飞快,等到第三四个版本迭代时,问题集中爆发:业务对象直接塞进 UI 组件,组件里又顺手发了请求,请求函数里又拼了一堆字符串模板;日志、校验、格式化逻辑散落在各层;类型要么全部写成any,要么把第三方 SDK 的定义满项目复制。最难受的不是代码乱,是改不动——一个字段的变更往往牵动接口层、页面层、缓存层、埋点层,谁都不敢动,最后只能用兼容字段继续堆。
T3code 想解决的就是这种“横向污染”。它把整个工程按依赖方向切成三层:类型契约层、业务用例层、适配器与工具层。依赖永远只从外向内、从上往下走,不允许反向引用。这样每条数据流都有清晰的来路,每次改动都能圈定一个最小影响面。
1.2 Type-first 的实际含义:先定契约,再写实现
很多团队理解 type-first 就是“多用 TypeScript 类型”,其实不对。真正的 Type-first 是“实现之前先定义类型,用类型表达业务规则”。比如要做一个订单服务,得先把订单状态、可执行操作、各状态下的入参和返回结构定义清楚,然后让实现代码去满足这些定义。类型不只是给编辑器提示用的,它本身就是需求和设计的载体。
我把这层叫做“契约层”。契约层严禁依赖任何运行时代码,只能由 interface、type、const enum、纯常量映射组成。它不 import 业务模块,不碰副作用,目的就是让领域概念在这个项目里有一个唯一的事实来源。谁要理解业务模型,看这一层就行;谁要建 API 文档,也从这一层生成。
1.3 为什么是三层,不是四层五层
有人会问:传统分层架构经常是 controller-service-dao,或者 ui-store-api-model,T3code 只有三层够用吗?我的看法是:层越多,边界越难守住。三层是最容易被团队达成共识的粒度。如果你发现某层内部过于庞大,那就用子模块继续切,而不是在全局再加一层统一的 xxx 层。三层结构很容易映射到实际代码目录,也容易在 Code Review 时用“你有没有引用上层模块”这一条规则快速检查。
T3code 的三层核心是:
- 契约层:放领域类型、配置结构、API 出入参定义。
- 用例层:放业务规则、状态流转、数据组合逻辑。
- 适配层:放数据库访问、HTTP 客户端、缓存、UI 组件、第三方 SDK 封装、日志文件写入等所有带副作用的操作。
只要是可变的、外部的、平台相关的,都往适配层塞;只要是稳定的、纯逻辑的、平台无关的,都往用例层放。
2. 三层架构的详细设计与边界规则
2.1 契约层:项目唯一的“术语表”
契约层在目录里通常叫contracts/,里面不再按后端接口或者前端页面分文件夹,而是按业务域分。比如订单域就是contracts/order/,用户域就是contracts/user/。这样做最大的好处是消除各自为政的命名。后端同学定义的OrderStatus和前端定义的OrderState如果在契约层统一了,联调时就不会出现状态映射表。
我在实际项目里还会把“枚举的可选值”也放在契约层,并且尽量不做字符串字面量的隐藏。举例,一个订单状态,不要散落在if (order.status === 1)这种魔法数字里,也不要让领域层出现status === 'SHIPPED'这种裸字符串,统一引OrderStatus.Shipped。这个成本非常低,但收益立竿见影——全局搜一个枚举引用,所有用到状态判断的地方全部暴露无遗。
契约层通常允许出现少量纯函数,比如枚举到文案的映射、状态的分组判断。但这类函数不允许抛异常、不允许读环境变量、不允许 import 外部模块。可以把它们理解成“类型附带的规则说明”,而不是业务动作。
2.2 用例层:业务逻辑的“主舞台”
用例层是 T3code 里价值最大也最容易写偏的一层。它负责表达“在某个业务场景下,系统应该做什么”。举例:用户下单、取消订单、合并购物车、计算运费,这些都是用例。每个用例应该依赖契约层的领域类型,并通过接口去获取外部能力,而不是直接去操作数据库或者请求上游接口。
这里的接口是应用内部自己定义的接口,不是 REST 接口。比如用例需要一个OrderRepository,那就定义一个OrderRepository接口,包含findById、save、updateStatus等方法。至于这个接口是接 MySQL 还是接第三方订单平台,用例层完全不关心。真正实现放在适配层。
这样设计之后,用例层自然变成纯逻辑层,可以非常方便地做单元测试。测试时只要传入一个 mock 的 Repo,就能把所有业务分支跑一遍。我见过最理想的一个项目里,用例层 3000 多行业务代码的测试覆盖率保持在 90% 以上,而实际运行从没连过真实数据库,所有测试都是毫秒级完成。
2.3 适配层:把所有“不纯”的东西关在一起
适配层是最后一个关卡。HTTP 路由、数据库 Model、Redis 客户端、消息队列、文件上传、第三方回调验签、平台特有的生命周期,全部放这里。它最大的作用是“消化外部世界的混乱”。
我特意强调一条规则:适配层不允许出现领域规则的判断。比如路由层只需要解析参数、调用用例、把结果回包;数据库层只需要做查询和持久化,不写if (订单金额小于包邮门槛)这类业务判断。一旦发现某个适配函数里面开始讨论业务规则,就停下来,把它移到用例层,用依赖接口的方式重新组织。这样做的好处是,以后换数据库、换 HTTP 框架、换云厂商 SDK,真正要动的只有适配层那几个文件,业务核心完全不受影响。
2.4 一个初步的目录模板
拿一个典型的 Node TypeScript 服务举例,我会这样摆目录:
src/ contracts/ order/ types.ts constants.ts mapping.ts user/ types.ts usecases/ order/ createOrder.ts cancelOrder.ts calculateFreight.ts user/ adapters/ http/ routes/ middlewares/ db/ mappers/ repositories/ third-party/ payment/ sms/ ui/ # 如果前端也沿用的话 pages/ components/这个结构不一定要从第一天就一步到位,但最好在项目前两周就完成初始化。否则等到一百多个文件铺开之后再想收口,重构成本会指数级增长。
3. 把 T3code 落到真实代码里:一份可照抄的示例
3.1 从契约层开始写:订单取消场景
假定业务规则是:只有待支付和已发货且允许取消的订单可以取消;已发货订单取消需要记录运费;取消成功后要通知用户。先把这些规则翻译成契约。
// contracts/order/types.ts export type OrderStatus = | 'PENDING_PAYMENT' | 'PAID' | 'SHIPPED' | 'CANCELLED' | 'FINISHED'; export interface Order { id: string; userId: string; status: OrderStatus; itemAmount: number; freightAmount: number; refundAmount: number; createdAt: string; updatedAt: string; } export interface CancelOrderInput { orderId: string; operatorId: string; reason?: string; } export interface CancelOrderResult { order: Order; refundAmount: number; needNotify: boolean; }这里我故意没有在类型上直接加“可取消状态”的联合类型,因为不同状态下可执行的操作不同,后续可以用映射类型来维护,而不是在每个函数里写一长串 if。比如:
// contracts/order/constants.ts import type { OrderStatus } from './types'; export const CANCELABLE_STATUS: ReadonlySet<OrderStatus> = new Set([ 'PENDING_PAYMENT', 'SHIPPED', ]);3.2 用例层的实现
用例层不 import 任何适配器,只依赖接口。这个接口可以定义在契约层,也可以定义在用例层自身旁边。
// usecases/order/cancelOrder.ts import type { CancelOrderInput, CancelOrderResult, Order } from '../../contracts/order/types'; import { CANCELABLE_STATUS } from '../../contracts/order/constants'; export interface OrderRepository { findById(id: string): Promise<Order>; updateStatus(id: string, status: Order['status'], extra?: Partial<Order>): Promise<Order>; } export interface OrderNotifyService { sendCancelledNotice(userId: string, orderId: string): Promise<boolean>; } export interface CancelOrderContext { repo: OrderRepository; notify: OrderNotifyService; } export async function cancelOrder( input: CancelOrderInput, ctx: CancelOrderContext ): Promise<CancelOrderResult> { const order = await ctx.repo.findById(input.orderId); if (!order) { throw new Error('ORDER_NOT_FOUND'); } if (!CANCELABLE_STATUS.has(order.status)) { throw new Error('ORDER_STATUS_NOT_CANCELABLE'); } const isShipped = order.status === 'SHIPPED'; const refundAmount = isShipped && order.refundAmount > 0 ? Math.min(order.paidAmount, order.itemAmount + order.freightAmount - order.refundAmount) : order.paidAmount; const updated = await ctx.repo.updateStatus(order.id, 'CANCELLED', { refundAmount, }); const needNotify = true; await ctx.notify.sendCancelledNotice(order.userId, order.id); return { order: updated, refundAmount, needNotify, }; }这才是关键地方:所有分支都是可测试的。假的 repo 想让它抛错就抛错,想让它返回已取消状态就返回已取消状态。不搭建任何真实环境,单测也能把 5 个核心分支全覆盖。
3.3 适配层的具体落地:路由、数据库、第三方
适配层里,数据库仓库只是把 ORM 查询结果映射成契约类型。
// adapters/db/repositories/orderRepository.ts import type { Order } from '../../../contracts/order/types'; import type { OrderRepository } from '../../../usecases/order/cancelOrder'; export class SqlOrderRepository implements OrderRepository { constructor(private readonly pool: any) {} async findById(id: string): Promise<Order> { const rows = await this.pool.query('select * from orders where id = ?', [id]); if (rows.length === 0) { throw new Error('ORDER_NOT_FOUND'); } return mapRowToOrder(rows[0]); } async updateStatus(id: string, status: Order['status'], extra?: Partial<Order>): Promise<Order> { const result = await this.pool.query( 'update orders set status = ?, refund_amount = ? where id = ?', [status, extra?.refundAmount ?? 0, id] ); return this.findById(id); } }映射函数放到adapters/db/mappers/里,也是一个容易被忽略但非常实用的约定。数据库字段名是蛇形,代码里是驼峰,这种映射动作如果发生在用例层,会让业务逻辑被数据库细节污染;发生在仓库内部则非常干净。
3.4 参考的依赖方向总表
| 层名 | 可以依赖 | 不能依赖 | 典型场景 |
|---|---|---|---|
| 契约层 | 同层其他文件、外部纯类型库 | 任何运行时代码、任意适配层 | 类型定义、枚举、映射 |
| 用例层 | 契约层、内部接口定义 | HTTP 框架、ORM、UI 组件、日志库 | 下单、取消、对账 |
| 适配层 | 契约层、用例层接口 | 其他适配层的内部实现 | 路由、数据库、短信 |
| UI 组件 | 契约层、用例层暴露的状态容器 | 直接调 API、操作 DB | 页面、表单 |
依赖方向一旦反了,项目就开始腐化。我在 Code Review 时第一个看的就是 import 路径,反方向引用一旦出现,直接打回重写。这听起来很严苛,但实际运行半年之后团队效率不降反升,原因就是每处代码的可替换性都极高。
4. 测试策略:让 T3code 真正省下返工时间
4.1 单元测试的靶子,就是用例层
用例层因为不依赖外部,是单测性价比最高的地方。测试应该围绕“分支覆盖”来写,而不是围绕“函数覆盖”。比如取消订单用例,重点测以下场景:
- 订单不存在
- 当前状态不可取消
- 已发货订单正确计算扣回运费
- 未发货订单直接全额退款
- 通知服务异常时用例如何响应
我习惯用表格把测试场景列出来,再逐一对应测试文件。这样写测试时思路不会乱,缺失的分支一眼就能看出来。
4.2 契约测试:保证适配层没有背叛约定
适配层往往需要依赖真实外部系统,这类测试不适合全部跑在 CI 的每个提交上。T3code 的做法是给每个仓库和第三方接口写契约测试,只验证两件事:输入参数是否正确传给了底层 SDK;返回结果是否被正确映射成了契约类型。
特别是数据库映射,最容易出问题。比如paidAmount在表里是paid_amount,结果某次重构后映射代码少了一个字段,用例层测试用的是 mock,完全感知不到,只有联调时才暴露。契约测试可以在 CI 里用 Testcontainers 起一个临时数据库跑,速度慢一点,但能兜住这一类回归。
4.3 端到端测试适可而止
端到端测试成本高、稳定性差,不适合大面积铺。许多人坚持“E2E 覆盖核心链路就行”,但真正执行起来还是要克制:一条主链路、两条关键分支已经足够。E2E 的目的不是替代单测,而是验证容器、配置、路由、中间件这些“最后一公里”没有被配错。配置类错误单测是发现不了的,只有真正启动应用才能暴露。
我在团队里定的大致比例是:用例层单测占总测试量的 60%~70%,契约测试占 20%,E2E 占 10% 以内。这个比例在多个项目里跑下来都比较舒服,既不会因为测试太多拖慢研发速度,也不会因为测试太少导致重构畏手畏脚。
5. 工具链与 CI 怎么配合 T3code 形成硬约束
5.1 让 TypeScript strict 成为默认,而不是可选项
T3code 要求所有项目开启strict: true,同时尽量开启noUncheckedIndexedAccess。这两个开关会在一开始带来不少类型报错,但一周之内团队就会适应。它们能把“可能为 undefined”这种事直接暴露在编码阶段,而不是运行时。
有一个实用技巧:如果存量代码整改吃力,可以先开启strict,把noUncheckedIndexedAccess列入下一阶段目标。但方向必须是逐步加大严格度,不允许反向放松。
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "exactOptionalPropertyTypes": true, "verbatimModuleSyntax": true } }exactOptionalPropertyTypes看起来严格得有点烦人,但它能避免很多“参数没传和传了 undefined 分不清”的隐蔽 bug。推荐在治理过一轮存量代码后再开,新手团队不要一开始就全开,容易挫伤积极性。
5.2 ESLint 规则里,最关键的一条是禁反方向 import
很多团队用 ESLint 只是统一格式、禁any,T3code 会额外加一条路径约束:契约层禁止 import 上两级目录,适配层禁止 import 用例层源码。实现方式不复杂,用import/no-restricted-paths或者写一个小型规则都可以。
// .eslintrc.cjs module.exports = { plugins: ['import'], rules: { 'import/no-restricted-paths': [ 'error', { zones: [ { target: './src/contracts', from: './src/usecases|./src/adapters' }, { target: './src/usecases', from: './src/adapters' } ] } ] } };这条规则比人肉 Code Review 可靠得多。人总有漏掉的时候,编辑器报红一遍就能把问题拦在提交之前。代码规范真正落地,靠的从来不是文档,而是工具。
5.3 CI 流水线的基本检查项
CI 至少应该包含:
tsc --noEmit做全量类型检查eslint .做静态规则检查prettier --check保证格式统一- 用例层单元测试,带上覆盖率阈值
- 选跑的契约测试
我的建议是把类型检查和规则检查放在合并请求的必选门禁上,单测必须全绿,覆盖率不低于 80%。如果某次改动涉及边界类型变化,强制要求对应契约测试同步更新,否则无法合并。这套流程跑起来之后,团队里流行一句话:合代码不再靠胆量,靠的是流水线放不放行。
6. 常见问题与排查实录:这些坑我都替你踩过
6.1 高频问题速查表
| 问题 | 根因 | 解决 |
|---|---|---|
| 类型定义和接口文档不一致 | 契约层没有作为唯一事实来源 | 用工具从 TS 类型生成 API 文档,废掉手写接口文档 |
| 用例层代码越写越厚 | 把事务、权限、日志都塞进用例层 | 事务边界放适配层工作单元,权限校验做成独立用例 |
| 适配层出现业务规则 | 图省事,直接在 controller 判断状态 | 移动到用例层,适配层只做参数映射和结果回包 |
| 测试跑得越来越慢 | 单测里大量真起数据库 | 单测全部 mock 接口;真实库只留给契约测试 |
| 类型在文件之间循环依赖 | 领域对象和 API 对象共用一套 model | 拆分出“内部模型”和“传输模型”,在适配层做映射 |
6.2 关于重构节奏的个人体会
T3code 不是一次就能铺完的。我在老项目上做过一次渐进式迁移:先划出新模块用三层结构写,旧代码不动;等新模块稳定后,再把旧代码按依赖方向一点一点拆。整个过程持续了三个月,中间没有出现过一次“推倒重来”的大动干戈。反而是那些一上来就要“全量重构”的计划,基本都失败了。
如果只给一条具体建议,我会说:从“新代码必须遵守边界,旧代码允许暂时违章,但每次改动必须顺手带走一片违章”开始。半年后再回头看,代码结构会自然趋于 T3code 定义的状态。这种方法不热血,但足够稳,适合绝大多数还在迭代中的真实业务项目。
最后说句自己的观察:一个项目能不能长期维护,很多时候不取决于用了多新的框架,而取决于改动一个核心字段时,需要翻开的文件数量。T3code 这套约定并没有发明什么高深理论,它只是把类型、用例、副作用这堵墙砌得足够直、足够高,让后来的人不用再靠猜去改代码。我自己受益最大的不是代码变少,而是每次需求变更时,能很笃定地在三个目录之间完成全部修改,这种确定性,比任何性能优化都让人安心。