1. 从“t3code”这个代号说起:它到底指什么
第一次看到“t3code”这个词,很多人会一头雾水。它不像“React”“Docker”那样有明确的官方定义,也不像“Hello World”那样有约定俗成的含义。我在几个技术社区和代码托管平台翻了一圈,发现“t3code”这个叫法在不同圈子里指向的东西并不完全一样。有人用它指代一套轻量级的编码规范,有人把它当成某个内部工具链的代号,还有人把它理解成“TypeScript + 3层架构 + Code”的缩写组合。这种模糊性恰恰是它有意思的地方——它更像一个“约定俗成的标签”,而不是一个注册商标式的产品名。
我个人的判断是:t3code 最合理的理解方式,是一套围绕“三层结构”组织代码的实践方法论。所谓“t3”,可以拆成 Three-Tier 或者 Three-Layer 的简写,核心思想是把一个项目拆成清晰的三个层次,让每一层只干自己该干的事。这个思路不新鲜,MVC、Clean Architecture、Hexagonal Architecture 都在讲类似的事情,但 t3code 的特别之处在于它足够“轻”——不强制你用某个框架,不要求你引入一堆依赖,甚至不规定你必须用某种语言。它更像是一种“代码组织的手感”,一种让项目在变大之后依然能保持可读性的习惯。
为什么值得单独拿出来聊?因为我在实际带项目和 review 代码的过程中发现,大部分项目的腐化不是从“技术选型错误”开始的,而是从“代码放错位置”开始的。业务逻辑写进了控制器,数据库查询散落在视图层,工具函数和领域模型混在一个文件夹里——这些问题单看都不致命,但积累三个月之后,整个项目就变成了一团谁都不敢动的乱麻。t3code 这套思路,本质上就是给这种混乱提供一个低成本的解药。
这篇文章适合谁看?如果你正在维护一个“还能跑但已经不太敢改”的中小型项目,如果你刚接手一个别人写的代码库不知道从哪下手整理,如果你自己从零搭项目时希望一开始就把结构定清楚——那接下来的内容应该对你有用。我不打算把它写成学术论文,而是按照我实际落地时的顺序,把每个环节的思考、踩过的坑、以及可以直接抄的做法都摊开讲。
2. 三层结构到底怎么分:职责边界的划定逻辑
2.1 为什么是三层,而不是两层或五层
先回答一个最常被问到的问题:为什么偏偏是三层?两层不够吗,五层不是更清晰吗?
我试过两层结构,也就是常见的“逻辑层 + 数据层”。项目小的时候没问题,一旦业务复杂起来,逻辑层就会膨胀成一个巨大的“上帝模块”——它既要做参数校验,又要做业务规则判断,还要拼装数据库查询,最后还要处理返回格式。这个模块会越来越长,越来越难测,最后没人愿意碰。
我也试过五层甚至更多层的分法,比如把“校验层”“转换层”“领域层”“持久层”“接口层”全部拆开。结果是:为了改一个字段的返回格式,我要在四个文件之间跳来跳去。层数太多带来的不是清晰,而是“导航成本”。每多一层,就多一次“这个逻辑到底该放哪”的犹豫。
三层是一个比较舒服的平衡点。它的划分逻辑是这样的:
- 第一层:接口层(Interface Layer)。负责和外界打交道——接收请求、解析参数、返回响应。它不关心业务规则,只关心“输入是什么格式、输出是什么格式”。
- 第二层:领域层(Domain Layer)。这是整个项目的核心,承载所有的业务规则、计算逻辑、状态流转。它不关心数据从哪来、往哪去,只关心“这件事按规则应该怎么处理”。
- 第三层:数据层(Data Layer)。负责数据的存取——读写数据库、调用外部服务、操作缓存。它不关心业务规则,只关心“怎么把数据拿回来、怎么存进去”。
这三层的依赖方向是单向的:接口层依赖领域层,领域层依赖数据层。反过来不行。这个“单向依赖”是整套结构能站住脚的根基,后面我会专门讲违反它会发生什么。
2.2 每一层的“准入标准”:什么东西该放进去
光知道有三层还不够,真正难的是判断“这行代码到底属于哪一层”。我总结了一个简单的判断方法,叫“换场景测试”:
假设我要把这个项目的接口从 HTTP 换成命令行调用,哪些代码需要改?需要改的部分,就属于接口层。假设我要把数据库从 MySQL 换成文件存储,哪些代码需要改?需要改的部分,就属于数据层。剩下的、两边都不需要改的部分,就是领域层。
这个测试很实用。举个例子:一个“计算订单折扣”的函数,不管你是通过网页下单还是通过脚本下单,不管订单存在数据库还是存文件,折扣的计算规则都不变——所以它属于领域层。而“把订单对象转成 JSON 返回”这件事,只有 HTTP 接口才需要,所以它属于接口层。
再举一个容易搞混的例子:参数校验。“用户 ID 不能为空”这个校验,如果换成命令行调用也依然需要,那它其实偏向领域层;但“请求头里必须带 token”这种校验,只有 HTTP 场景才有,就属于接口层。很多项目把这两种校验混在一起写,导致领域层里混入了 HTTP 相关的判断,这就是典型的边界模糊。
2.3 一个具体的目录结构参考
说再多理论不如看一个实际的结构。下面是我在多个项目中反复调整后固定下来的目录组织方式,以 TypeScript 项目为例(其他语言可以类比):
src/ interfaces/ # 接口层 http/ controllers/ middlewares/ routes/ cli/ commands/ domain/ # 领域层 entities/ # 领域实体 services/ # 领域服务(业务规则) value-objects/ # 值对象 errors/ # 领域错误定义 data/ # 数据层 repositories/ # 仓储实现 adapters/ # 外部服务适配器 mappers/ # 数据映射 shared/ # 跨层共享的工具 utils/ types/这个结构的关键不在于文件夹名字,而在于每个文件夹里的代码只能依赖同层或更内层的代码。interfaces可以 importdomain,domain可以 importdata里的接口定义,但data绝对不能反过来 importdomain里的具体实现。这条规则如果守住,项目就不会烂。
3. 落地时最容易翻车的三个地方
3.1 领域层被“数据形状”污染
这是我在 review 代码时见到频率最高的问题。表现是这样的:领域层里的函数,参数和返回值直接用了数据库的模型对象。比如一个calculateDiscount(orderRecord)函数,orderRecord是直接从数据库查出来的行对象,字段名是order_id、user_id、total_amount这种下划线风格。
问题在哪?领域层被迫知道了数据库的字段命名和数据结构。哪天数据库加了一个字段、改了一个字段名,领域层的代码就得跟着改。更糟的是,如果同一个业务概念在数据库里存成了两张表,领域层就得处理这种“存储细节”,而它本不该关心这些。
正确的做法是在领域层定义自己的“领域模型”,字段命名用业务语言,比如orderId、customerId、amount。数据层负责把数据库的行对象转换成领域模型,这个转换动作放在data/mappers里。这样数据库怎么变,只要 mapper 改一下,领域层纹丝不动。
我踩过的坑是:一开始觉得“多写一层转换好麻烦”,直接让领域层用数据库对象。结果三个月后加了一个“订单快照”功能,需要把订单数据存成另一种结构,领域层里到处都在判断“这个字段有没有、那个字段叫什么”,改得痛不欲生。那一次之后我就明白了,转换层的成本是一次性的,不转换的成本是持续累积的。
3.2 接口层偷偷塞业务逻辑
第二个高频问题:接口层里写了业务判断。比如在 controller 里写“如果用户是 VIP 就打九折,否则打九五折”。这种代码单看没问题,但它把业务规则泄露到了接口层。后果是:如果以后加一个 CLI 入口,这段折扣逻辑就得复制一遍;如果折扣规则变了,你得记得去改 controller,而不是去改领域服务。
判断标准很简单:如果这段逻辑在“换一个入口”之后依然需要,它就不该待在接口层。接口层应该只做三件事:解析输入、调用领域层、格式化输出。任何“如果……就……”的业务判断,都应该下沉到领域层。
我见过一个更隐蔽的变种:接口层里做“权限判断”。比如“只有管理员才能删除订单”。这个判断看起来像是接口层的事,但其实它是业务规则——如果通过 CLI 删除订单,同样需要这个规则。所以正确的做法是在领域层的deleteOrder服务里做权限校验,接口层只负责把“当前用户是谁”传进去。
3.3 数据层反向依赖领域层
第三个问题相对少见,但一旦出现就很致命:数据层里 import 了领域层的具体类或函数。比如在 repository 实现里直接调用了一个领域服务来计算某个值。
这会导致循环依赖:领域层依赖数据层的接口,数据层又依赖领域层的实现,两边缠在一起,谁也拆不开。更实际的影响是:你没法单独测试数据层了,因为一跑测试就得把整个领域层拉起来。
避免的方法是在数据层只依赖“接口定义”,不依赖“具体实现”。如果数据层确实需要某个计算逻辑,那说明这个逻辑应该被抽成一个独立的、无依赖的纯函数,放在shared里,两边都可以用,而不是让数据层去 import 领域层。
4. 从零搭一个 t3code 风格项目的完整步骤
4.1 先定接口契约,再写实现
我的习惯是:不要一上来就写数据库和业务逻辑,而是先把“接口层要什么”定下来。具体做法是先把 API 的请求和响应格式写出来,哪怕只是写在注释里或者一个types.ts文件里。
为什么这样做?因为接口契约是三层之间协作的“合同”。合同定好了,领域层就知道自己需要提供什么能力,数据层就知道自己需要支持什么查询。如果先写实现再定接口,很容易出现“实现写完了发现接口设计不合理,但改起来牵一发动全身”的情况。
举个例子,我要做一个“创建订单”的功能。我会先写:
// 接口层期望的输入 interface CreateOrderRequest { customerId: string; items: Array<{ productId: string; quantity: number }>; } // 接口层期望的输出 interface CreateOrderResponse { orderId: string; totalAmount: number; status: string; }有了这个,领域层就知道自己要提供一个createOrder(customerId, items)的方法,返回一个包含orderId、totalAmount、status的领域对象。数据层就知道自己要能根据productId查价格、要能保存订单。每一步都有依据,不会瞎写。
4.2 领域层先写“纯逻辑”,不碰 IO
领域层最理想的状态是:所有函数都是纯函数,输入确定则输出确定,不依赖任何外部状态。这意味着领域层里不应该出现await fetch(...)、await db.query(...)这类调用。
那需要查数据库怎么办?答案是:领域层定义“我需要什么数据”,由数据层去取,取完传进来。比如calculateTotal(items, priceMap),priceMap是数据层查好的价格表,领域层只负责算。这样领域层的测试极其简单——给一组输入,断言输出,不需要起数据库、不需要 mock 网络。
我实际写的时候会遵循一个原则:领域层的函数签名里,不出现任何“外部世界”的类型。不出现Request、Response、Row、Connection这些词。一旦出现,就说明边界破了。
4.3 数据层用“接口 + 实现”分离
数据层的组织方式是:先在领域层(或者一个共享的types里)定义仓储接口,然后在数据层写具体实现。
// 定义在 domain 或 shared 里 interface OrderRepository { findById(id: string): Promise<Order | null>; save(order: Order): Promise<void>; } // 实现在 data/repositories 里 class MysqlOrderRepository implements OrderRepository { async findById(id: string) { const row = await this.db.query('SELECT * FROM orders WHERE id = ?', [id]); return row ? mapRowToOrder(row) : null; } // ... }这样做的好处是:领域层只依赖OrderRepository这个接口,不关心背后是 MySQL 还是内存。测试的时候可以换一个InMemoryOrderRepository,跑得飞快。将来换数据库,也只需要写一个新的实现类,领域层一行不用改。
4.4 接口层做“薄”,薄到只剩翻译
接口层的代码应该薄得像一张纸。一个 controller 方法大概长这样:
async function createOrder(req, res) { const { customerId, items } = req.body; const order = await orderService.createOrder(customerId, items); res.json({ orderId: order.id, totalAmount: order.totalAmount, status: order.status, }); }没有业务判断,没有数据库调用,只有“取参数、调服务、拼响应”。如果发现 controller 里出现了if判断业务规则,那就是该往下沉了。如果出现了 SQL 或者 ORM 调用,那就是该往数据层挪了。
我给自己定的一个硬指标是:单个 controller 方法的行数不超过 15 行。超过就说明它干了不该干的事。
5. 这套结构在真实项目里的收益与代价
5.1 收益:改需求时不再“牵一发动全身”
t3code 这套结构最大的收益,体现在“需求变更”的时候。举个真实例子:我维护的一个项目,原本订单金额是直接存数据库的,后来业务要求“金额要根据用户等级动态计算,不能存死”。
如果是一团乱麻的项目,这个改动会波及:数据库 schema、查询语句、业务逻辑、接口返回格式,可能还有前端。但在三层结构下,改动范围被压缩到了领域层——calculateAmount函数改一下规则,数据层不再存金额字段,接口层完全不用动。改动范围可控,是这套结构最实在的价值。
另一个收益是测试。领域层因为是纯逻辑,单元测试覆盖率可以轻松做到 90% 以上,而且跑一次只要几百毫秒。数据层和接口层的测试可以少而精,只测关键路径。整体测试成本比“什么都混在一起”的项目低很多。
5.2 代价:前期多写一些“看起来没用”的代码
说实话,t3code 不是没有代价的。最明显的代价是:项目初期会多出一些“看起来多余”的文件。一个简单的功能,可能要写接口定义、领域服务、数据映射三个文件,而“一把梭”的写法一个文件就搞定了。
我刚开始用这套结构的时候,也怀疑过“是不是过度设计”。但几次项目迭代下来,我的结论是:如果这个项目预计活不过一个月,或者只有一两个功能,那确实没必要。但只要项目有持续迭代的预期,前期的这点“多余”会在第一次需求变更时就回本。
还有一个代价是“心智负担”。团队成员需要理解三层的边界,需要知道“这行代码该放哪”。新人上手时会有一段适应期。我的做法是在项目 README 里写清楚每一层的职责和判断标准,并且在 code review 时严格把关,几次之后大家就形成肌肉记忆了。
5.3 什么时候不该用 t3code
不是所有项目都适合这套结构。我的判断标准是:
| 项目特征 | 是否适合 t3code |
|---|---|
| 一次性脚本、临时工具 | 不适合,直接写就行 |
| 原型验证、Demo | 不适合,速度优先 |
| 预计迭代超过 3 个月的项目 | 适合 |
| 多人协作的项目 | 适合 |
| 业务规则复杂的项目 | 非常适合 |
| 纯 CRUD 且规则极少 | 可用可不用,看团队习惯 |
一句话总结:业务复杂度越高、生命周期越长、参与人越多,t3code 的收益就越大。反过来,如果只是写个脚本处理一下数据,套这套结构就是自找麻烦。
6. 几个我反复踩过又爬出来的细节坑
6.1 错误处理该放在哪一层
错误处理是三层结构里最容易放错位置的东西。我的经验是:领域层定义“业务错误”,接口层负责“翻译错误”。
比如“余额不足”是一个业务错误,它应该在领域层被抛出,用一个自定义的InsufficientBalanceError。接口层捕获这个错误,然后决定返回什么 HTTP 状态码和错误信息。数据层的错误(比如数据库连接失败)则应该在数据层被包装成领域层能理解的错误,或者直接向上抛,由接口层统一处理。
我踩过的坑是:在领域层里直接抛了带 HTTP 状态码的错误,比如throw new Error('400: 参数错误')。这导致领域层知道了 HTTP 的存在,换一个入口就失效了。正确的做法是领域层只抛业务语义的错误,状态码的映射放在接口层。
6.2 事务边界应该划在哪
事务是另一个容易搞混的点。我的原则是:事务边界由领域服务决定,但具体实现由数据层提供。
也就是说,领域服务里会写“这一系列操作要么全成功要么全失败”,但它不关心事务是怎么实现的。数据层提供一个withTransaction的能力,领域服务调用它,把一组操作包进去。这样领域层表达的是“业务上的一致性要求”,数据层负责“技术上怎么实现”。
不要在 controller 里开事务,也不要在 repository 的单个方法里开事务。事务应该对应一个完整的业务用例,而业务用例的边界在领域层。
6.3 共享代码放哪里才不破坏分层
项目里总有一些“两边都要用”的代码,比如日期格式化、ID 生成、通用校验。这些代码放哪?我的做法是放在shared目录,并且规定:shared里的代码不能依赖任何一层,只能被依赖。
shared里的东西必须是“无业务语义的纯工具”。一旦某个工具函数开始包含业务判断(比如“VIP 用户返回不同格式”),它就不该待在shared,而应该下沉到领域层。这个界限如果守不住,shared会慢慢变成一个“什么都往里塞”的垃圾堆,最后三层结构就被它架空了。
6.4 如何说服团队接受这套结构
最后聊一个非技术问题:怎么让团队愿意用这套结构。我的经验是:不要一上来就讲架构理论,而是从一个具体的痛点切入。
比如团队正在被“改一个字段要改五个文件”折磨,这时候你提出“我们把业务逻辑收拢到一层,以后改规则只改一个地方”,大家就容易接受。或者团队正在被“测试跑得太慢”困扰,你说“我们把纯逻辑抽出来,测试不用起数据库”,大家就有动力。
理论说服力有限,痛点说服力无限。先解决一个具体问题,让大家尝到甜头,再慢慢推广到整个项目。强行推行一套“正确但抽象”的结构,往往会遭到抵触,最后不了了之。
我在实际项目里推行 t3code 的过程,大概花了两个月才让团队完全适应。前两周大家会频繁问“这个放哪”,第三周开始有人主动说“这个应该放领域层”,两个月后新人进来,看两天代码就能上手。这个适应曲线是正常的,不要指望一周就见效。结构带来的秩序感,是需要时间才能体会到的。