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 掉 repository,interface 层用端到端测试跑真实 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 时的坚持,而不是文档的厚度。