1. 从“t3code”这个代号说起:它到底指什么
第一次看到“t3code”这个词,很多人会一头雾水。它不像“Python入门”“Docker实战”那样自带说明,更像是一个圈内人才懂的暗号。我最初接触这个代号,是在一个做前端工具链的小圈子里,当时有人丢出一句“你那个 t3code 跑通了没”,群里立刻炸出一堆讨论。后来我才慢慢摸清楚,t3code 并不是某个官方框架的名字,而是社区里对一类“三层代码结构”实践的口语化称呼——t 代表 three,3 代表层级,code 就是代码本身。说白了,它描述的是一种把项目代码按职责切成三层的组织方式,通常对应接口层、逻辑层、数据层,也有人叫表现层、领域层、基础设施层。
这个代号之所以能流传开,是因为它精准戳中了一个痛点:很多项目写着写着就变成一锅粥,改一个按钮颜色要翻五个文件,加一个字段要动七八处地方。t3code 的核心主张就是——让每一层只干一件事,层与层之间通过明确的契约通信。它不绑定任何语言,Java、TypeScript、Python、Go 都能用;也不绑定任何框架,Spring、NestJS、Django、Gin 都可以套。换句话说,t3code 是一种架构习惯,而不是一个可以 npm install 的包。
那它适合谁呢?如果你正在维护一个超过三千行的项目,或者团队里超过三个人同时改同一份代码,又或者你发现自己每次改需求都要“牵一发而动全身”,那 t3code 这套思路就值得你花时间吃透。它尤其适合中小型团队从“能跑就行”向“可维护”过渡的阶段——大厂有成熟的架构组兜底,小项目一个人写完全没必要分层,但处在中间地带的项目,t3code 往往是最划算的选择。接下来我会从它解决的问题、三层怎么切、每层怎么写、常见坑怎么避这几个角度,把我在实际项目里踩过的路完整摊开。
2. 为什么“三层”这个数字刚刚好
2.1 一层和两层的致命缺陷
先说说为什么不是一层。一层结构就是所有代码堆在一起,路由、校验、业务计算、数据库查询全写在一个函数里。这种写法在项目初期效率极高,我见过一个接口三十行搞定增删改查,确实爽。但问题会在第三次需求变更时集中爆发:产品说“这个字段要加密存储”,你得在查询和写入两处都改;测试说“这个逻辑要单独测”,你发现它和数据库连接死死绑在一起,根本没法 mock。一层结构的本质问题是变化频率不同的代码被绑在了一起——界面文案可能一天改三次,而数据库表结构可能三个月才动一次,把它们放一起,等于让高频变化拖着低频变化一起跑。
那两层呢?很多人会自然想到“前端一层、后端一层”,或者“Controller 一层、Service 一层”。两层比一层好,但依然不够。我拿一个真实例子说明:一个电商下单接口,Controller 层负责收参数、返响应,Service 层负责算价格、扣库存、写订单。看起来挺清晰,但当你需要把“算价格”这段逻辑复用到另一个入口(比如后台手动改价)时,你会发现它和“写订单”的事务绑在一起,拆不出来。两层的问题在于业务逻辑和数据处理没有分开,导致业务规则无法独立演进。t3code 之所以是三层而不是两层,就是为了把“业务规则”这层单独拎出来,让它不依赖任何数据库、不依赖任何 HTTP 框架,纯粹到可以用一个普通函数测完。
2.2 三层各自的边界怎么划
三层听起来简单,但真正难的是划边界。我见过太多项目号称三层,结果逻辑层里塞满了 SQL 拼接,数据层里又在做业务判断,等于白分。我的经验是抓住一个核心问题:这一层如果换掉,其他层要不要跟着改?接口层换掉(比如从 REST 换成 GraphQL),逻辑层和数据层不应该感知;数据层换掉(比如从 MySQL 换成 MongoDB),逻辑层和接口层也不应该感知。如果换一层导致其他层大面积修改,说明边界划错了。
具体来说,接口层的职责只有三件事:收参数、做格式校验、把结果转成响应格式。它不应该知道“库存不足”这个业务规则,只应该知道“逻辑层返回了一个错误码,我把它转成 400 响应”。逻辑层的职责是所有业务规则的唯一住所:价格怎么算、库存怎么扣、优惠券能不能叠加,全在这里。它不应该出现SELECT、INSERT这种词,也不应该出现req、res这种对象。数据层的职责是把逻辑层的意图翻译成存储操作:逻辑层说“给我 ID 为 5 的用户”,数据层负责去 MySQL 查、去 Redis 缓存、去文件读,但绝不判断“这个用户是不是 VIP”。这三条边界守住了,t3code 才算真正落地。
2.3 一个反直觉的结论:三层不是越多越好
有人听完三层觉得有道理,转头就搞出五层、七层,什么“门面层”“转换层”“防腐层”全加上。我试过在一个小项目里搞五层,结果一个简单的查询请求要穿过五个文件,改一个字段名要动六处,团队里新来的人三天没看懂调用链。t3code 的精髓不在于“层数多”,而在于每层都有不可替代的职责。如果某一层只是把上一层的参数原样传给下一层,那它就是多余的。我现在的判断标准很简单:如果删掉这一层,代码总量减少但可读性不降,那就该删。三层是经过大量项目验证的“最小可行分层”,再多就是过度设计。记住,架构的目的是让改动变便宜,不是让图看起来漂亮。
3. 接口层:别让“收参数”变成“写业务”
3.1 参数校验放在这一层的理由
接口层最容易犯的错,是把业务校验和格式校验混在一起。比如“手机号必须 11 位”是格式校验,应该放接口层;“这个手机号已经注册过”是业务校验,必须放逻辑层。为什么这么分?因为格式校验是与入口强相关的——Web 入口要校验 JSON 字段,CLI 入口要校验命令行参数,消息队列入口要校验消息体,它们的格式规则完全不同。而业务校验是与入口无关的——不管从哪进来,“已注册手机号不能重复注册”这条规则都不变。如果把业务校验写在接口层,那每加一个入口就要重写一遍,迟早漏掉。
我在实际项目里用过一个很实用的技巧:接口层只做“类型和范围”校验,逻辑层只做“状态和关系”校验。类型就是 string、number、boolean,范围就是长度、大小、正则。状态和关系就是“这个 ID 是否存在”“这个状态是否允许操作”。这样分完之后,接口层的代码几乎全是声明式的,看一眼就知道这个接口收什么、返什么,没有任何隐藏逻辑。逻辑层则全是 if-else 和领域判断,测试的时候直接构造数据调函数就行,不需要启动 HTTP 服务。
3.2 响应格式的统一与例外
接口层另一个重要职责是统一响应格式。我见过很多项目每个接口返回的 JSON 结构都不一样,有的{code, data, msg},有的{success, result},前端每调一个接口就要写一套解析逻辑。t3code 建议在接口层做一个统一的响应包装器,所有成功响应走同一个函数,所有错误响应也走同一个函数。这样做的好处是前端只需要写一次拦截器,而且日志、监控、埋点都可以在包装器里统一处理。
但统一不等于死板。有些场景确实需要例外,比如文件下载接口返回的是二进制流,不能包成 JSON;比如第三方回调接口要求返回特定格式的字符串。我的做法是默认统一,例外显式声明。在代码里用一个@RawResponse之类的标记或者单独的 handler 来区分,而不是让每个开发者自己决定。这样既保证了 95% 的接口格式一致,又给 5% 的特殊场景留了口子。关键是这个口子要看得见——代码审查时一眼能发现“哦这个接口是特殊的”,而不是藏在某个角落没人知道。
3.3 接口层不该出现的三类代码
根据我踩过的坑,接口层有三类代码一旦出现就要立刻警惕。第一类是数据库查询语句,比如在 Controller 里直接写db.query('SELECT ...'),这意味着逻辑层被架空了,业务规则散落在接口层。第二类是复杂的条件分支,比如if (user.level > 3 && order.amount > 100 && ...),这种判断属于业务规则,应该下沉到逻辑层。第三类是事务控制,比如在接口层开事务、提交事务,这会导致逻辑层无法被复用——因为逻辑层不知道自己在不在事务里。
我遇到过一个典型案例:一个接口在接口层开了事务,然后调用逻辑层的“创建订单”方法,逻辑层内部又调用了“扣库存”方法。后来另一个入口也需要“扣库存”,直接调逻辑层,结果因为没有事务导致库存扣了但订单没创建。修复方法就是把事务控制移到逻辑层,让逻辑层自己决定事务边界。接口层只负责“调用逻辑层并返回结果”,不参与任何事务决策。这个教训让我明白:接口层越薄越好,薄到像一张纸,透过去能直接看到逻辑层的轮廓。
4. 逻辑层:业务规则的唯一住所
4.1 逻辑层如何做到“不依赖任何框架”
逻辑层是 t3code 的心脏,也是最难写好的部分。它的理想状态是:把这一层的代码复制到一个空项目里,不装任何框架,也能跑通单元测试。这意味着逻辑层不能 import 任何 Web 框架的类,不能 import 任何数据库驱动,不能 import 任何缓存客户端。它只依赖两样东西:领域模型和数据层接口。领域模型就是那些纯数据的类,比如Order、User、Coupon;数据层接口就是一组抽象方法,比如findUserById、saveOrder。
为什么这么强调“不依赖框架”?因为框架是会变的。我经历过从 Express 迁移到 NestJS 的项目,如果逻辑层绑定了 Express 的Request对象,迁移时就要重写所有逻辑。但如果逻辑层只接收普通对象,迁移时只需要改接口层的适配代码。同样,数据库从 MySQL 换到 PostgreSQL,如果逻辑层直接写 SQL,迁移就是灾难;如果逻辑层只调用userRepository.findByEmail(),换数据库只需要换一个实现类。这种“依赖倒置”的写法,是 t3code 能长期存活的关键。
4.2 领域模型该放什么、不该放什么
领域模型是逻辑层的核心资产,但很多人把它写成“数据库表的映射”,这是最大的误区。数据库表是存储结构,领域模型是业务概念,两者不应该一一对应。比如数据库里可能有一张user_profile表和一张user_account表,但在业务里它们就是一个User对象。领域模型应该包含业务属性和业务行为,而不是存储字段。举个例子,Order模型里应该有totalAmount属性,但不应该有createdAtTimestamp这种纯存储字段——后者是数据层的事。
我习惯在领域模型里放三类东西:状态字段(比如status、amount)、业务方法(比如order.canCancel()、order.applyDiscount())、不变量校验(比如构造函数里检查金额不能为负)。业务方法的好处是把规则收拢到模型内部,而不是散落在各个 service 里。比如“订单能否取消”这个判断,如果写在 service 里,每个调用方都要自己判断;如果写成order.canCancel(),所有调用方都调同一个方法,规则变了只改一处。这就是 t3code 强调的“把行为绑在数据上”。
4.3 逻辑层的事务边界怎么定
事务是逻辑层最棘手的问题。我的原则是:一个业务用例一个事务。什么叫业务用例?就是“用户下单”这件事,它可能包含扣库存、创建订单、发优惠券三个步骤,这三个步骤要么全成功,要么全失败,所以它们在一个事务里。而“用户查询订单”是另一个用例,它不需要事务。判断标准是:这个操作是否改变了系统状态,且多个改变之间是否必须原子。
具体实现上,我倾向于在逻辑层的入口方法上开事务,而不是在数据层的方法上开。因为数据层的方法可能被多个逻辑层方法复用,如果每个数据层方法自己开事务,会导致事务嵌套或者事务粒度过细。比如saveOrder和deductStock如果各自开事务,那么“下单”这个用例就无法保证原子性。正确做法是在逻辑层的placeOrder方法上开一个事务,然后依次调用saveOrder和deductStock,它们共享同一个事务。这样事务边界清晰,而且逻辑层完全掌控了“什么算一个完整业务”。
5. 数据层:把“存储”这件事关进笼子
5.1 数据层接口的设计原则
数据层在 t3code 里扮演的是“翻译官”角色:把逻辑层的业务语言翻译成存储引擎能听懂的操作语言。它的接口设计应该以业务需求为导向,而不是以数据库表为导向。什么意思?如果逻辑层需要“查找所有超过 30 天未登录的用户”,数据层就应该提供一个findInactiveUsers(days)方法,而不是让逻辑层自己去拼WHERE last_login < ?。前者是业务语言,后者是存储语言。把存储语言关在数据层里,逻辑层才能保持干净。
我设计数据层接口时有个习惯:方法名用业务动词,参数用业务对象,返回值用领域模型。比如findActiveSubscriptions(userId)返回Subscription[],而不是返回RowDataPacket[]。这样逻辑层拿到返回值就能直接用,不需要再做一次转换。同时,数据层内部可以自由优化——今天用 MySQL 查,明天加 Redis 缓存,后天换成 Elasticsearch,只要接口不变,逻辑层完全无感。这就是“把变化关进笼子”的价值。
5.2 缓存该放在哪一层
缓存的位置是很多团队争论的焦点。有人放接口层,有人放逻辑层,有人放数据层。我的经验是:缓存属于数据层。因为缓存的本质是“存储的加速器”,它和数据库、文件、外部 API 一样,都是数据的来源。逻辑层不应该知道“这个数据是从缓存来的还是从数据库来的”,它只关心“我拿到了数据”。如果把缓存放在逻辑层,逻辑层就会混入if (cache.has(key))这种存储细节,违背了 t3code 的边界原则。
但缓存放在数据层不等于“无脑加缓存”。我见过最糟糕的做法是在每个数据层方法里都加一层 Redis 查询,导致代码里一半是缓存逻辑。正确做法是在数据层内部做缓存策略,比如用一个装饰器模式:基础实现是数据库查询,缓存实现包在外面,先查缓存再查数据库。这样逻辑层调用的还是同一个接口,但底层已经自动带了缓存。缓存失效策略也由数据层自己管理,比如“用户更新时删除缓存”,这个逻辑放在数据层的saveUser方法里,而不是让逻辑层去手动删缓存。
5.3 数据层如何应对多存储源
现代项目很少只用一种存储。MySQL 存核心数据,Redis 存会话,Elasticsearch 做搜索,对象存储放文件。t3code 的数据层需要把这些差异全部吸收掉。我的做法是按业务领域拆分数据层实现,而不是按存储类型拆分。比如UserRepository这个接口,它的实现类UserRepositoryImpl内部可能同时用了 MySQL 和 Redis:读用户基本信息走 MySQL,读用户在线状态走 Redis。逻辑层只调userRepository.findById(),完全不知道背后用了两种存储。
这种设计的好处是存储选型可以独立演进。今天用户量小,全部走 MySQL;明天用户量大了,加一层 Redis 缓存;后天要做全文搜索,加一个 Elasticsearch 实现。每次变化只影响数据层的某个实现类,逻辑层和接口层纹丝不动。我经历过一个项目从 MySQL 迁移到 MongoDB,因为数据层接口设计得好,迁移只花了三天,逻辑层一行没改。这就是 t3code 分层带来的实际收益。
6. 落地 t3code 时最容易踩的四个坑
6.1 坑一:为了分层而分层,代码量翻倍
我见过最极端的案例是一个简单的“查询用户列表”接口,被拆成了UserController、UserService、UserServiceImpl、UserRepository、UserRepositoryImpl、UserConverter、UserDTO、UserVO八个文件。一个查询请求要穿过八层,改一个字段名要动六个文件。这种“为了分层而分层”的做法,完全违背了 t3code 的初衷。t3code 的目的是让改动变便宜,如果分层导致改动更贵,那就说明分错了。
我的判断标准是:如果两个层之间的代码只是简单的参数传递,没有任何逻辑转换,那就合并。比如UserService只是调UserRepository然后返回,没有任何业务逻辑,那这个 Service 就是多余的。同样,如果UserDTO和UserVO字段完全一样,只是名字不同,那也没必要分开。t3code 的三层是逻辑分层,不是文件分层。一个文件里可以同时有接口层和逻辑层的代码,只要它们职责清晰、边界明确。关键是思维上分层,而不是物理上分文件。
6.2 坑二:逻辑层偷偷依赖了框架
这是最隐蔽的坑。逻辑层表面上没有 import 框架,但代码里用了框架的全局状态。比如在逻辑层里直接调ConfigService.get('db.host'),或者用了AsyncLocalStorage这种框架提供的上下文。这些依赖不会在 import 语句里暴露,但会让逻辑层无法脱离框架运行。我踩过一次坑:逻辑层里用了 NestJS 的Logger,结果单元测试时因为没启动 Nest 容器,日志直接报错。后来改成注入一个Logger接口,测试时传一个空实现,问题才解决。
检查逻辑层是否干净,有个简单方法:把逻辑层的代码复制到一个空的 TypeScript 项目里,只装类型定义,不装任何框架,看能不能编译通过。如果编译报错说找不到某个模块,说明逻辑层依赖了外部框架。这个方法我每次重构都会用,屡试不爽。逻辑层应该只依赖标准库和自己定义的接口,其他一切都要通过依赖注入传进来。
6.3 坑三:数据层返回了“半成品”
数据层返回的数据结构,直接决定了逻辑层的写法。如果数据层返回的是数据库原始行(比如{ user_name: '张三', user_age: 20 }),逻辑层就要做字段名转换、类型转换、空值处理,这些杂活会污染逻辑层。我见过一个项目,数据层返回的日期是字符串,逻辑层每次用都要new Date()转一次,后来有个地方忘了转,导致时间比较出错。正确的做法是数据层返回完整的领域模型,字段名、类型、嵌套结构全部对齐业务概念。
具体来说,数据层在查询到原始数据后,应该立刻做一次“组装”:把user_name转成name,把字符串日期转成Date对象,把关联表的数据组装成嵌套对象。这样逻辑层拿到的就是“开箱即用”的模型,不需要再做任何转换。这个组装过程可以放在数据层的toDomain()方法里,也可以用一个专门的 Converter。关键是转换只做一次,且只在数据层做。逻辑层如果还在做字段转换,说明数据层没做到位。
6.4 坑四:三层之间的调用链变成“迷宫”
t3code 的调用方向应该是单向的:接口层调逻辑层,逻辑层调数据层。但实际项目中经常出现反向调用或者跨层调用。比如数据层为了“方便”,直接调了逻辑层的某个方法;或者接口层绕过逻辑层,直接调数据层。这些都会让调用链变成迷宫,改一处不知道会影响哪里。我见过一个项目,接口层直接调数据层做查询,逻辑层又调接口层做参数校验,整个依赖关系乱成一团,最后没人敢改代码。
防止调用链混乱,我的做法是用目录结构强制约束。比如src/api/只允许 importsrc/domain/,src/domain/只允许 importsrc/infra/,src/infra/不允许 import 上面两层。然后用 ESLint 的no-restricted-imports规则来强制检查,违反就报错。这样从物理上杜绝了反向依赖。另外,每个层只暴露必要的接口,比如数据层只暴露 Repository 接口,不暴露具体的数据库连接对象,这样上层想绕也绕不过去。
7. 一个真实项目的 t3code 改造记录
7.1 改造前的代码状态
去年我接手了一个运营后台项目,代码量大概一万五千行,用的是 Express + MySQL。改造前的状态是典型的“一层结构”:所有逻辑都在路由处理函数里,一个文件平均八百行,最长的文件两千三百行。改一个需求平均要动三个文件,而且经常改完 A 功能坏了 B 功能。测试覆盖率不到 10%,因为逻辑和数据库绑死,根本没法写单元测试。团队三个人,每次发版前都要全员加班回归测试。
我印象最深的一次事故:产品要求“优惠券金额不能超过订单金额”,开发在路由里加了一个if判断。结果另一个入口(后台手动改价)没有加这个判断,导致出现了负金额订单。排查时发现,同样的业务规则在三个地方各写了一遍,改的时候只改了一处。这就是一层结构的典型问题:业务规则没有唯一住所,散落在各个角落,迟早会漏。
7.2 改造步骤与时间分配
改造分四步走,总共花了三周。第一周做接口层剥离:把路由处理函数里的参数校验、响应格式化抽出来,放到独立的中间件和工具函数里。这一步不改变业务逻辑,只是把“收参数”和“返响应”的代码从业务代码里拆出来。第二周做逻辑层提取:把路由处理函数里的业务判断、计算逻辑抽到独立的 service 文件里,service 只接收普通对象,不接收req和res。这一步最耗时,因为要理清哪些是业务逻辑、哪些是数据操作。
第三周做数据层封装:把所有 SQL 语句从 service 里抽出来,放到 repository 文件里,service 只调 repository 的方法。同时把数据库返回的原始行转换成领域模型。第四周做测试补全:给逻辑层写单元测试,因为逻辑层已经不依赖数据库了,测试写起来很快,一周补了 60% 的覆盖率。改造完成后,改一个需求平均只动一个文件,发版前的回归测试从两天缩短到半天。
7.3 改造后的收益与遗留问题
改造后最明显的收益是新人上手时间从两周缩短到三天。因为代码结构清晰,新人只要看懂接口层就知道系统有哪些功能,看懂逻辑层就知道业务规则是什么,看懂数据层就知道数据从哪来。另一个收益是单元测试变得可行,逻辑层的测试不需要启动数据库,跑一次只要几秒钟,开发过程中可以频繁运行。线上事故率也明显下降,因为业务规则收拢到了逻辑层,不会再出现“改一处漏一处”的情况。
但改造也留下了两个问题。一是性能略有下降,因为多了一层对象转换,接口响应时间从平均 30ms 增加到 45ms。不过这个代价换来的是可维护性,我认为值得。二是部分复杂查询仍然绕过了逻辑层,因为有些报表查询需要多表联查,硬套三层反而麻烦。我的处理方式是允许例外,但例外要显式标记,在代码里加注释说明“此处为性能优化,直接查询数据库”,而不是偷偷绕过。这样既保证了 90% 的代码遵循 t3code,又给 10% 的特殊场景留了空间。
8. 关于 t3code 的几个常见疑问
8.1 小项目有必要用 t3code 吗
我的答案是:看项目预期寿命。如果这个项目只跑一个月就下线,或者只是个人练手,那完全没必要分层,一层结构写起来最快。但如果这个项目预期要维护半年以上,或者会有其他人接手,那 t3code 就值得投入。我见过太多“临时项目”最后变成“长期项目”的例子,一开始图快不分层,三个月后改不动了,重构的成本比一开始分层高十倍。所以我的建议是:如果不确定项目会不会长期存在,就按长期项目来写,因为分层的成本其实很低,一个接口多写两个文件而已。
8.2 t3code 和 MVC 有什么区别
很多人会把 t3code 和 MVC 混为一谈,其实它们关注的点不同。MVC 关注的是用户界面和数据的交互,Model 是数据,View 是界面,Controller 是协调者。t3code 关注的是业务逻辑和存储的隔离,它不关心界面,只关心“业务规则写在哪”和“数据怎么存取”。一个项目可以同时用 MVC 和 t3code:MVC 的 Controller 对应 t3code 的接口层,MVC 的 Model 对应 t3code 的逻辑层加数据层。但 t3code 比 MVC 更强调逻辑层不依赖框架,这是 MVC 没有明确要求的。
8.3 逻辑层要不要拆成多个 service
这个问题没有标准答案,我的经验是按业务领域拆,不按技术职责拆。比如电商项目可以拆成OrderService、UserService、PaymentService,每个 service 负责一个领域的业务规则。不要拆成ValidationService、CalculationService、PersistenceService,因为这样拆会导致一个业务用例要调多个 service,事务边界很难定。按领域拆的好处是一个业务用例通常只涉及一个 service,事务边界清晰,代码也容易理解。如果某个 service 太大,可以再按子领域拆,比如OrderService拆成OrderCreateService和OrderQueryService,但这是优化,不是必须。
8.4 数据层要不要做 DTO 转换
要,而且必须在数据层做。我见过有些项目在逻辑层做 DTO 转换,结果逻辑层里一半代码是user.name = row.user_name这种赋值。这些转换应该全部下沉到数据层,逻辑层拿到的就是干净的领域模型。转换的时机是数据层从数据库拿到原始数据后立刻转,而不是等到逻辑层用的时候再转。这样逻辑层完全不知道数据库字段名是什么,换数据库时逻辑层无感。转换的代码可以放在 repository 的实现类里,也可以单独写一个 Converter 类,关键是转换只做一次,且只在数据层做。
9. 我个人的几条实操心得
第一条心得:先写逻辑层,再写接口层和数据层。很多人习惯从接口层开始写,先定义路由,再写逻辑,最后写数据库。但这样容易导致逻辑层被接口层的需求牵着走,写出“为了这个接口定制”的逻辑。我的做法是先想清楚业务规则是什么,把逻辑层写出来,然后用单元测试验证。逻辑层稳定后,再写接口层去适配它,写数据层去支撑它。这样逻辑层是核心,接口层和数据层是外围,核心稳定了,外围怎么变都不怕。
第二条心得:用接口来定义层与层之间的契约。接口层调逻辑层时,不要直接调具体的类,而是调一个接口。逻辑层调数据层时,也是调接口。这样每一层都可以独立替换实现,测试时也可以传 mock 实现。我习惯在每一层都定义一个types.ts或者interfaces.ts,把这一层对外暴露的接口写清楚。这样新人看接口文件就知道这一层能做什么,不需要翻实现代码。
第三条心得:不要追求一步到位。t3code 是一个渐进的过程,不要想着一次性把整个项目重构完。我的做法是新代码严格按三层写,老代码改到哪就顺手分层。比如今天要改一个老接口,就顺便把它的逻辑抽到 service 里,把 SQL 抽到 repository 里。这样半年下来,老代码也慢慢分层了,而且每次改动都有测试覆盖,风险可控。一次性重构一万五千行代码,大概率会引入新 bug,而且团队会怨声载道。
第四条心得:分层不是目的,可维护才是。我见过一些团队把 t3code 当成教条,每个接口必须三个文件,每个文件必须不超过一百行。结果为了凑文件数,把简单的逻辑拆得七零八落,反而更难读。t3code 的本质是让变化发生在一处,如果某个项目就是很简单,一个文件能写完,那就一个文件写完。架构是为人服务的,不是人为架构服务。当你发现分层让代码更难懂了,那就是分错了,赶紧调整。
最后分享一个检查分层是否合理的小技巧:随便挑一个最近改过的需求,看这次改动动了几个文件。如果只动了一个文件,说明分层合理;如果动了三个以上,说明边界没划好,需要重新审视。这个指标比任何架构图都直观,因为改动的文件数直接反映了“变化的传播范围”。t3code 的目标就是让变化传播范围最小,改一个功能只动一个文件,这就是最好的分层。