
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可以 importdomaindomain可以 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): PromiseOrder | null; save(order: Order): Promisevoid; } // 实现在 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 的过程大概花了两个月才让团队完全适应。前两周大家会频繁问“这个放哪”第三周开始有人主动说“这个应该放领域层”两个月后新人进来看两天代码就能上手。这个适应曲线是正常的不要指望一周就见效。结构带来的秩序感是需要时间才能体会到的。