1. 从“t3code”这个名字说起它到底指什么第一次看到“t3code”这个词很多人会一头雾水。它不像“React”“Docker”那样有明确的官方定义也不像某个大厂的开源项目那样自带文档。我在几个技术社区翻了一圈发现大家提到“t3code”时语境其实相当分散有人拿它当某个内部工具链的代号有人把它当成一套编码规范的简称还有人把它理解成“TypeScript 3层架构 Code Review”的缩写组合。这种模糊性恰恰是它有意思的地方——它不是一个被定义死的产品而更像一个在团队里口口相传、逐渐沉淀下来的工程实践标签。我个人的判断是t3code 最合理的解读是一套围绕“三层代码结构”展开的轻量级工程约定。所谓“三层”在不同团队里叫法不同但内核大同小异最外层是接口层负责接收请求、做参数校验和格式转换中间是业务逻辑层承载真正的规则判断和流程编排最里层是数据访问层只管和存储打交道不掺和业务决策。这套东西听起来老生常谈但真正落地时绝大多数项目都会在“业务逻辑到底写在哪一层”这个问题上翻车。为什么我要花篇幅先聊这个因为如果你把 t3code 当成一个现成的 npm 包去搜大概率会失望。它更像是一种约定优于配置的思路不强制你引入什么框架而是通过目录结构、命名规则和依赖方向来约束代码。适合谁来参考我觉得是那些已经写过一两个中小型项目、开始感受到“代码越写越乱”的开发者。纯新手可能觉得这些约束多余而资深架构师又觉得太浅——但对中间那批人来说t3code 这种轻量约定恰好能解决“知道要分层但不知道怎么分”的尴尬。接下来我会从目录结构、依赖方向、典型代码写法、常见踩坑几个角度把这套东西拆开讲清楚。所有内容都是基于我实际项目中的做法整理不保证是唯一正确答案但至少是跑通过、踩过坑之后还能站得住的方案。2. 三层结构到底怎么切目录与职责的对应关系2.1 为什么不是“按技术类型”分目录很多人习惯这样组织代码controllers/放所有控制器services/放所有服务models/放所有数据模型。这种分法在项目初期很清爽但一旦业务变复杂你就会发现改一个功能要同时打开三个相距甚远的文件夹而且很难判断某个 service 到底属于哪个业务域。t3code 的做法是先按业务域分再在域内按三层分。举个例子一个电商项目里会有order/、product/、user/这样的顶层目录每个目录内部再出现interface、logic、data三个子层。这样改订单相关的东西基本不会跳出order/这个文件夹。这种切法的好处在团队协作时特别明显。当两个人同时改订单和商品他们几乎不会产生文件冲突因为物理路径就是隔离的。而按技术类型分目录时两个人可能同时往services/里加文件合并时虽然 Git 能处理但心理上的“这块地盘是谁的”始终模糊。2.2 每一层具体放什么、不放什么我直接给一个可抄的目录模板以 TypeScript 项目为例src/ order/ interface/ order.controller.ts order.dto.ts logic/ order.service.ts order.validator.ts data/ order.repository.ts order.entity.ts product/ interface/ logic/ data/interface层只做三件事解析输入、调用 logic、包装输出。它不应该出现任何if (user.level vip)这种业务判断也不应该直接 import 数据库客户端。logic层是唯一允许写复杂条件分支的地方它调用data层提供的方法但不关心数据具体存在 MySQL 还是内存里。data层只负责增删改查返回的是领域对象或普通数据结构绝不返回数据库原始行对象——这一点后面会展开讲为什么。有一个容易被忽略的细节dto 和 entity 要分开。dto 是接口层用来接收和返回的entity 是 data 层用来映射存储的。很多项目图省事直接拿 entity 当 dto 用结果数据库加个字段就泄露到 API 响应里或者 API 改个字段名就要动数据库映射。分开之后两层之间靠 logic 层做转换虽然多写了几行代码但换来了两边独立演化的自由。2.3 依赖方向一条不能倒流的河t3code 最核心的约束就是依赖方向interface → logic → data箭头单向绝不反向。也就是说data层不能 importlogic层的任何东西logic层不能 importinterface层的任何东西。这个规则听起来简单但实际写代码时很容易破功。比如某天你发现order.repository.ts里需要根据用户等级做不同查询而用户等级的判断逻辑在order.service.ts里——这时候正确的做法是把判断结果作为参数传进 repository而不是让 repository 去 import service。我见过太多项目因为一次“就这一次”的反向依赖最后演变成 data 层里塞满了业务规则logic 层反而成了空壳。要守住这条线可以在 CI 里加一个依赖检查脚本用dependency-cruiser或类似工具扫描 import 关系一旦发现反向依赖就报错。这个投入非常值得因为它是唯一能对抗“临时妥协”的机制。3. 接口层与逻辑层的边界那些容易写错的地方3.1 参数校验该放在哪一层这是一个经典争议点。有人觉得校验是接口的事有人觉得是业务的事。我的经验是分两层做但目的不同。接口层做的是“格式校验”字段是否存在、类型对不对、字符串长度是否超限。这些校验不涉及业务规则纯粹是防御性编程可以用 JSON Schema 或 class-validator 这类工具自动完成。逻辑层做的是“业务校验”这个订单状态是否允许取消、这个用户是否有权限操作这个资源。这些校验依赖领域知识必须写在 logic 层。为什么不能全放接口层因为 logic 层可能被多个入口调用——HTTP 接口、定时任务、消息队列消费者。如果业务校验只写在 HTTP 接口里那定时任务调用时就会绕过校验埋下数据不一致的隐患。反过来如果格式校验也放 logic 层那每个调用方都要重复写一遍类型检查纯属浪费。3.2 异常怎么抛、怎么接t3code 建议在 logic 层抛领域异常比如OrderAlreadyCancelledError、InsufficientStockError而不是直接抛new Error(something wrong)。接口层负责捕获这些异常并映射成合适的 HTTP 状态码和错误响应体。这样做的好处是 logic 层不依赖任何 HTTP 概念它不知道自己是跑在 web 服务器里还是命令行里保持了纯粹性。具体实现上我会定义一个基础异常类所有领域异常继承它并带一个code字段用于客户端识别。接口层用一个统一的错误处理中间件根据异常类型决定返回 400 还是 409 还是 500。注意不要把所有异常都吞掉返回 200那是反模式。该报错就报错客户端才能正确处理。3.3 事务边界放在哪事务应该由 logic 层控制而不是 data 层。因为一个业务操作可能涉及多个 repository 调用比如创建订单同时扣减库存这两个操作必须原子完成。如果事务放在 data 层每个 repository 方法各自开事务就无法保证跨 repository 的一致性。logic 层持有事务管理器在业务方法开始时开启事务结束时提交或回滚中间调用多个 repository 方法时把事务上下文传进去。这里有个实操细节事务上下文最好通过参数显式传递而不是用 ThreadLocal 或 AsyncLocalStorage 隐式传递。显式传递虽然啰嗦但代码可读性强新人一眼就能看出哪些操作在同一个事务里。隐式传递在异步场景下容易出诡异问题排查成本很高。4. 数据层的克制repository 不该做的事4.1 为什么 repository 不能返回数据库原始对象很多 ORM 框架查出来的对象带有大量元数据比如 Sequelize 的实例上挂着_previousDataValues、_changed这些内部属性。如果直接把这个对象返回到接口层序列化时可能带出意外字段甚至泄露敏感信息。t3code 要求 repository 返回纯数据结构通常是普通对象或领域实体类的实例只包含业务需要的字段。这样做还有一个好处当数据库表结构变化时只需要改 repository 里的映射逻辑logic 层和 interface 层完全不受影响。我经历过一次从 MySQL 迁移到 PostgreSQL 的过程因为 repository 返回的是纯对象上层代码一行没改只调整了 data 层的实现。4.2 查询条件该由谁构造repository 的方法签名应该体现业务语义而不是暴露查询细节。比如不要写findByField(field: string, value: any)这种通用方法而应该写findByOrderNo(orderNo: string)、findPendingOrdersBefore(date: Date)这种具体方法。通用方法看似灵活实则把查询逻辑泄露到了 logic 层导致 logic 层开始拼 SQL 条件破坏了分层。如果确实需要动态查询可以在 repository 里定义一个查询参数对象把可选条件封装进去但方法名仍然要体现业务意图。比如searchOrders(criteria: OrderSearchCriteria)其中OrderSearchCriteria是 data 层定义的类型logic 层只负责填充这个对象。4.3 缓存逻辑放哪里缓存严格来说属于数据访问的一部分所以放在 data 层是合理的。但要注意缓存 key 的构造应该由 data 层内部完成不要暴露给 logic 层。logic 层调用findByOrderNo时不应该关心这个结果是否来自缓存。data 层内部先查缓存未命中再查数据库然后回填缓存。这样 logic 层保持简洁缓存策略调整时也不影响上层。不过有一种情况例外如果缓存失效需要业务事件驱动比如订单状态变更时主动清除缓存那 logic 层需要在事务提交后发布一个事件data 层监听这个事件来清缓存。这种跨层通信通过事件解耦比直接调用更干净。5. 落地 t3code 时最容易踩的五个坑5.1 把 logic 层写成“透传层”这是最常见的问题。logic 层的方法体只有一行return this.orderRepository.findByOrderNo(orderNo)。这种透传层没有任何存在价值反而增加了维护成本。出现这种情况通常是因为开发者把分层当成了形式主义为了分层而分层。正确的做法是如果 logic 层没有业务规则要处理那这个功能可能根本不需要 logic 层接口层可以直接调用 data 层——但这种情况应该极少因为大多数业务操作都有校验、转换或编排逻辑。5.2 循环依赖的隐蔽来源即使遵守了interface → logic → data的方向循环依赖仍可能出现。比如order.logic需要调用user.logic获取用户信息而user.logic又需要调用order.logic统计订单数。这种跨业务域的循环依赖靠分层规则解决不了。我的做法是引入一个共享的 domain 层把跨域的共同概念抽出来或者用事件机制解耦。如果两个域确实高度耦合那可能说明它们本就不该拆成两个域。5.3 测试时 mock 的粒度分层清晰的项目测试应该按层写data 层用集成测试连真实数据库logic 层用单元测试 mock 掉 repositoryinterface 层用端到端测试跑真实 HTTP 请求。但很多人图省事在 logic 层测试里 mock 了 repository 之后又去断言 repository 被调用的次数和参数——这其实是在测试实现细节而不是行为。一旦重构 logic 层内部实现测试就全红了。更好的做法是断言 logic 层的输出结果和副作用而不是它调用了谁。5.4 目录结构随业务演进而僵化初期按业务域分目录很合理但当某个域变得特别大时比如order/下面有几十个文件就需要考虑二级拆分。我的经验是当 logic 层文件超过 7 个时就该考虑按子领域拆了。比如拆成order/create/、order/cancel/、order/query/。但不要提前拆过早拆分会让简单功能变得难以定位。5.5 团队共识比工具更重要t3code 这套东西工具只能帮你检查依赖方向但无法强制你“业务逻辑写对地方”。我见过依赖方向完全正确、但 logic 层里全是 SQL 拼接的项目也见过目录结构完美、但 interface 层里塞满业务判断的项目。分层的本质是职责分配不是文件夹命名。所以落地时一定要在 code review 里反复强调“这段代码为什么放在这一层”让团队形成共识而不是靠工具兜底。6. 一个完整的小例子从请求到响应走一遍假设要实现“取消订单”功能。接口层收到POST /orders/:id/cancel先做格式校验id 是否为合法 UUID。然后调用orderLogic.cancelOrder(id, currentUserId)。logic 层先通过orderRepository.findById取出订单判断订单是否存在、是否属于当前用户、状态是否为“待支付”或“已支付”。如果都通过调用orderRepository.updateStatus(id, cancelled)并在事务提交后发布OrderCancelledEvent。data 层只负责执行UPDATE orders SET status ? WHERE id ?不关心为什么改状态。接口层捕获可能的OrderNotFoundError或OrderCannotCancelError映射成 404 或 409 返回。整个流程里每一层都只做自己该做的事。logic 层不知道 HTTP 状态码data 层不知道业务规则interface 层不知道数据库表名。这种隔离带来的直接好处是当业务规则变化时比如允许已发货订单取消但需要审批只需要改 logic 层其他两层纹丝不动。7. 关于 t3code 我个人的几点体会我在三个不同规模的项目里推行过类似 t3code 的约定最大的感受是它最大的价值不是技术上的先进性而是提供了一套团队沟通的词汇。当有人说“这个校验放 logic 层”大家立刻明白指的是业务校验而非格式校验当有人说“repository 不要返回 entity”大家知道是在说数据映射的边界。这种共同语言减少了大量“这个代码该放哪”的争论。另一个体会是不要试图一次性完美落地。新项目可以从最简单的三层目录开始遇到问题再调整。老项目重构时可以先从新增功能开始遵守约定逐步把旧代码迁移过来而不是停下来搞一次大重构。我试过在一个五万行的老项目里强行分层结果花了三周时间引入的 bug 比解决的还多。后来改成“新代码新规矩旧代码不动”半年后自然就迁移完了。最后说一个容易被忽视的点文档要写但不要写太长。一页纸说清楚依赖方向和每层职责就够了写成长篇大论没人看。真正让约定活下来的是 code review 时的坚持而不是文档的厚度。