
1. 从t3code说起一个被低估的代码分层思路先说结论t3code不是某个框架的名字也不是什么新语言的代号。我第一次看到这个词是在一个开源项目的目录结构里项目作者用 t3code 作为工程命名拆开看就是 Tier 3 Code——三级代码。那个项目的服务端代码被分成了三层每层职责极其单一调用规则非常明确整个仓库的代码量不到两万行但业务迭代了三年多几乎没有出现过改一处崩一片的情况。我当时盯着目录结构看了很久意识到这个命名背后其实藏的是一套非常实用的代码组织方法论。很多团队在项目初期不重视分层所有逻辑堆在 Controller 或者 Service 里等业务复杂起来再想拆分成本已经很高了。t3code 的思路正好反着来在写第一行业务代码之前先定好三层的边界和依赖方向后面所有开发都在这个框架里填内容。这篇文章我打算把 t3code 这套思路完整拆开讲清楚每一层到底该放什么、不该放什么层与层之间的依赖规则怎么定以及落地时最常见的坑是什么。无论你是在维护老项目想重构还是新项目刚起步需要定规范这套东西都能直接参考。适用面很广Java、Go、TypeScript 甚至 Python 项目都能套用。我自己的实践结论是t3code 最大的价值不是它分了几层而是它把哪些代码能活多久这件事提前想清楚了。什么意思就是你要在设计阶段就分清哪些代码是长期稳定的、哪些代码是随业务频繁变动的然后用分层把这两类代码隔离开。下面我从设计思路开始拆。2. 为什么是三层t3code 的设计逻辑与核心概念2.1 三层到底分的是什么先不急着给三层命名我们先想一个问题一个业务的代码本质上在做几件事第一件事是跟外部世界打交道接收 HTTP 请求、解析参数、做基础校验、把结果转成响应格式。第二件事是处理业务规则比如下单要算总价、要扣库存、要检查优惠券是否过期。第三件事是跟基础设施打交道读写数据库、调用第三方接口、发消息队列。t3code 就是把这三种完全不同性质的工作拆到三个独立的层里。我习惯把这三层叫做接入层、业务层、数据层。有的团队也叫 Controller / Service / Repository有的叫 Interface / Application / Infrastructure名字无所谓核心是职责边界。如果你见过那种两三万行代码全堆在一起的老项目你一定会理解这种拆分的必要性——所有代码挤在一起意味着每次改动都要把整段逻辑从头到尾看一遍而且完全没办法独立测试。三层划分还有一个隐藏的好处它天然对应了团队分工。接入层关心协议和参数适合偏前端的后端同学维护业务层关心规则和流程适合核心业务开发数据层关心存储和查询效率适合对数据库更熟悉的同学。当然小团队可能一个人全包但分层的代码至少能让你在不同阶段切换上下文时脑子里有个清晰的地图。2.2 依赖方向的铁律只能从上往下t3code 里面最重要的一条规则不是要分三层而是依赖方向只能从上往下。什么意思就是接入层可以依赖业务层业务层可以依赖数据层但反过来不行。这一条规则比所有命名规范和代码风格都重要我甚至觉得它是 t3code 的灵魂。为什么这么强调方向因为一旦数据层反过来依赖业务层你的项目就会出现循环依赖编译期报错或者运行期死循环都是小事真正可怕的是逻辑混乱到没人能改。你想想看如果 Repository 里写了一行代码去调用 Service 里的某个方法那 Service 也调 Repository这两层就完全耦合在一起了分层就名存实亡。我自己在上一个项目就吃过这个亏。当时为了省事在数据层直接调了业务层的某个工具方法结果三个月后那个工具方法改了签名数据层所有调用点跟着崩排查了一个下午。从那以后我就立了一个规矩如果数据层需要某个逻辑这个逻辑就必须下沉到数据层自己实现或者抽到独立的公共模块绝不允许跨层向上调用。这个规矩写进团队的代码规范后来再没出过类似问题。依赖方向的检查其实很简单代码评审的时候多看一眼 import 语句就行。如果你用 Go可以用 go list 做依赖分析Java 可以用 ArchUnit 写单元测试来守护边界TypeScript 可以用 ESLint 的 import 规则限制目录之间的引用。工具一堆关键是要把规则定下来并坚持执行比用什么工具更重要。2.3 三个核心概念职责单一、依赖倒置、边界守护t3code 的整个方法论浓缩下来就是三个词职责单一、依赖倒置、边界守护。职责单一说的是每一层只干自己那一类事。接入层不写 SQL业务层不拼 HTML数据层不解析 HTTP 参数。这个原则听起来简单实际执行起来很容易被破坏因为总有一些代码你觉得放哪都行。比如一个把时间戳转成日期字符串的工具方法你可能随手就放在 Controller 里了然后 Service 也要用又复制了一份。这种代码就应该抽到独立工具类里或者放在数据层的基础能力里让上层按需调用。依赖倒置在 t3code 里的体现主要是通过接口把层与层之间的耦合降到最低。举个例子业务层需要的订单存储能力不直接依赖 MongoDB 或者 MySQL 的具体实现类而是依赖一个 OrderRepository 接口然后数据层去实现这个接口。这样一来业务层根本不关心底层是 MySQL 还是 NoSQL只要接口不变底层随便换。这对后面做读写分离、分库分表、单元测试打桩都极其重要。边界守护是自动化层面的保障。前面说了人的记忆和自觉都不可靠真正可靠的是把规则写进代码库里自动执行。我第一次在项目里引入 ArchUnit 写依赖规则测试的时候同事还觉得是小题大做结果这个测试在两个月内拦下了十几次违反依赖方向的提交。从那以后边界守护测试成了我们所有后端项目的标配。你要问我 t3code 和普通分层最大的区别是什么我的回答就是普通分层靠自觉t3code 靠机制。3. 逐层拆解接入层、业务层、数据层各自的核心职责3.1 接入层只做翻译不做决策接入层在 Web 项目里通常对应 Controller在命令行工具里对应 Command在消息消费者里对应 Listener。它的核心职责是翻译把外部输入翻译成内部结构再把内部结果翻译成外部响应。具体来说接入层要做的事情包括解析 HTTP 请求参数、做基础的格式校验比如邮箱格式、手机号位数、鉴权注解的声明、调用业务层方法、把返回结果包装成统一的响应体。除了这些什么都不要多干。不要在 Controller 里写业务判断比如 if (user.getBalance() price) 这种代码应该出现在业务层而不是接入层。这里我踩过一个特别典型的坑。有一次做订单接口我图省事在 Controller 里直接判断了库存是否充足不充足就返回错误信息。后来新增了一个渠道这个渠道允许超卖预下单我需要在 Service 里跳过库存检查但 Controller 里的判断拦在前面怎么都绕不过去。最后只能改 Controller 代码连带影响了其他渠道的调用。从那以后我彻底明白了Controller 里只该做跟 HTTP 协议相关的事情任何业务判断都必须下沉到 Service。接入层怎么判断自己是否干净有一个很简单的自测方法你把 HTTP 相关的注解全部去掉改成命令行直接调用核心逻辑是否还能跑如果业务代码仍然能顺畅执行说明接入层的逻辑确实没有侵入业务如果发现业务逻辑依赖了请求对象说明职责混了。我在重构老项目时经常用这个方法来检测每段代码的归属。3.2 业务层核心规则的唯一住所业务层是整个 t3code 里最重要的一层因为它承载了项目最核心的价值——业务规则。业务层的代码应该做到完全不感知 HTTP 的存在也不关心数据存储用的什么数据库。它只接收领域对象或者简单参数处理规则然后返回结果。业务层的典型内容包括订单金额计算、库存扣减与回滚、优惠策略匹配、状态流转判断、聚合根的操作等等。这些代码的特点是变化频率高因为业务规则总在变今天满减规则调整明天运费策略修改。正因为变化频繁它必须被隔离在单独的层里不能跟稳定的数据层代码混在一起。做业务层设计的时候有个重要的思维方式叫用例驱动。就是不要按照用户管理订单管理这种资源维度去组织 Service 类而要按照业务用例来组织。比如下单是一个用例、取消订单是一个用例、售后发起是一个用例。每个用例对应一个独立的业务方法方法内部清晰地表达核心业务逻辑。这种组织方式对于需求频繁变更的场景特别友好新需求来了只需要新增一个用例方法或者修改对应的方法不会牵连到其他无关逻辑。我在业务层还特别强调一个纪律事务边界放在这一层。也就是说Transactional 注解应该加在业务层方法上而不是数据层。因为一个业务用例往往要操作多个数据实体只有在业务方法这一级别开启事务才能保证所有数据变更的一致性。如果你把事务加在数据层方法上事务的粒度太细两个数据操作之间发生异常就没办法整体回滚。3.3 数据层屏蔽存储差异稳定压倒一切数据层在 t3code 里的角色是稳定的底座。它负责跟数据库、缓存、搜索引擎、第三方系统打交道向上层提供持久化能力。这一层的代码变化频率最低但基础重要性最高一旦出错就是线上事故级别的。数据层的核心设计目标是屏蔽存储差异。业务层调用 OrderRepository.FindByID它不应该关心这个 ID 是从 MySQL 查出来的还是从 Redis 缓存里读的。数据层内部自己处理缓存与数据库的一致性、读写分离的路由、分表的逻辑等等。所有这些存储相关的技术细节都被封装在数据层内部上层完全不需要感知。有些团队把 ORM 实体和数据库表结构完全暴露给上层业务代码里到处能看到数据表字段名这其实已经破坏了封装。数据层应该向上层返回领域对象而不是底层数据表结构。举个例子订单表里有 status 字段值是 0/1/2数据层应该把 0/1/2 翻译成订单状态的枚举对象再返回给上层而不是让 Service 去猜 0 是什么意思。这样后续数据库结构调整、字段值语义变化影响范围都能控制在数据层内部。另外我强烈建议数据层要做读写分离的预留设计。哪怕现在项目流量不大单库就够用也应该把读操作和写操作的接口分开定义将来做读写分离时就只改数据层内部实现即可业务层一行代码都不用动。这就是依赖倒置原则在存储层面带来的红利。4. 实操落地从零搭一个 t3code 工程4.1 目录结构与命名规范下面直接给一个可以抄作业的目录结构示例这里用 Go 语言展示但思路完全适配其他语言。project-root/ ├── internal/ │ ├── handler/ # 接入层HTTP Handler │ │ ├── order.go │ │ └── user.go │ ├── service/ # 业务层用例编排 │ │ ├── order_service.go │ │ └── user_service.go │ └── repository/ # 数据层持久化实现 │ ├── order_repo.go │ └── user_repo.go ├── pkg/ │ ├── domain/ # 领域对象与枚举 │ └── kit/ # 公共工具库 └── cmd/ └── server/ └── main.go # 启动入口这个结构里handler、service、repository 三个目录分别对应三层。domain 放领域对象和业务枚举它不属于任何一层而是被各层共同依赖的基础模型。kit 放工具方法比如时间处理、字符串转换、加密算法。cmd 是启动入口负责初始化依赖关系和启动 HTTP 服务。层与层之间的调用规则很简单handler 调 serviceservice 调 repository。domain 和 kit 可以被任何层引用但不允许反过来引用。在代码评审时重点检查这三个目录之间的 import 是否符合规则发现违规直接打回修改。我这里选的目录命名是行业里最流行的因为大部分开发者看到 handler/service/repository 就能秒懂代码位置。有的团队叫 controller/service/mapper意思一样看你团队的既有习惯。命名不统一是小事职责边界混乱才是大事。4.2 接口先行每层的通信契约怎么定t3code 在实操层面的一个重要做法是接口先行。上一层对下一层的依赖不是直接依赖具体实现类而是依赖接口。具体来说service 层依赖 repository 接口handler 层也尽量依赖 service 接口。接口先行的直接好处是测试变得异常容易。比如你要单测 service 层的下单逻辑如果 service 直接依赖一个具体的 repository 实现那测试就必须连数据库但如果 service 依赖的是 OrderRepository 接口测试时就可以用一个内存版的 mock 实现传入跑完断言结果即可。我在项目里特别喜欢这种模式——因为测试跑得快了大家才愿意多写测试如果每个单测都要连数据库没人会坚持下去。下面用一个伪代码示例说明接口定义方式// 数据层接口定义 type OrderRepository interface { Create(ctx context.Context, order *Order) error FindByID(ctx context.Context, orderID string) (*Order, error) UpdateStatus(ctx context.Context, orderID string, status OrderStatus) error } // 业务层接口定义 type OrderService interface { CreateOrder(ctx context.Context, req CreateOrderReq) (*CreateOrderResp, error) CancelOrder(ctx context.Context, orderID string) error }注意一个细节接口方法的参数和返回值尽量使用领域对象不要直接透传底层数据结构和高层 HTTP 结构。比如 CreateOrder 方法它接收的参数是 CreateOrderReq 这个业务请求对象而不是绑定了 JSON 标签的 HTTP Request DTO。这样业务层的服务就能被多种接入方式复用——HTTP 可以用GRPC 可以用命令行也可以用。4.3 依赖注入与启动装配有了接口定义接下来要做依赖注入。所谓依赖注入简单理解就是在程序启动时把各层的具体实现装配起来然后传给需要它们的上层。我用一个最朴素的手写依赖装配来说明不引入任何框架func main() { // 实例化数据层 db : mysql.NewConnection() orderRepo : repository.NewOrderRepo(db) userRepo : repository.NewUserRepo(db) // 实例化业务层 orderService : service.NewOrderService(orderRepo, userRepo) userService : service.NewUserService(userRepo) // 实例化接入层并注册路由 orderHandler : handler.NewOrderHandler(orderService) userHandler : handler.NewUserHandler(userService) // 启动 HTTP 服务 router : setupRouter(orderHandler, userHandler) router.Run(:8080) }这段代码的装配逻辑非常清晰先实例化依赖最底层的数据层再向上装配业务层最后装配接入层。每个构造函数都明确声明了自己需要哪些依赖没人能偷偷跳过一层去直接操作底层组件。有的团队用 Spring 或类似框架做自动装配这也可以但我建议即使有框架也要保持构造函数注入的习惯不要用字段注入或全局单例。字段注入最大的问题是你要运行到那段代码才能发现依赖没注入成功而构造函数注入在启动瞬间就能暴露问题。4.4 事务控制与并发安全业务层的两大责任事务控制前面说过要放在业务层这里展开讲一下具体怎么落地。在 Go 项目里我常用的方案是定义一个事务管理器接口type TransactionManager interface { WithinTransaction(ctx context.Context, fn func(ctx context.Context) error) error }调用方式是在业务层方法里这样写func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) error { return s.txManager.WithinTransaction(ctx, func(ctx context.Context) error { if err : s.orderRepo.Create(ctx, order); err ! nil { return err } if err : s.stockRepo.Deduct(ctx, productID, quantity); err ! nil { return err } return nil }) }这样写的好处是事务的开启与提交完全由 WithinTransaction 统一管理业务方法只需关注自己的核心逻辑。任何一步返回 error整个事务自动回滚不需要在每个方法里手动写 begin/commit/rollback。并发安全方面业务层要做的是关键资源的并发控制。典型场景是库存扣减。最稳妥的做法是在数据层用原子的条件更新操作比如 SQL 里直接写UPDATE stock SET quantity quantity - ? WHERE product_id ? AND quantity ?用数据库的原子性和行锁来保证并发安全。尽量避免在业务层先查出来再算一遍再更新回去这种读改写在三步之间极易发生并发覆盖问题。5. 常见问题与排查技巧实录5.1 循环依赖最先炸的雷循环依赖是 t3code 落地时最典型的问题。A 层调 B 层B 层又调 A 层代码编译期可能过比如 Go 包级循环引用会在编译期直接报错但逻辑上的循环依赖往往非常隐蔽。我遇到过的真实案例Service 层需要读取当前登录用户的信息直接调用了一个 UserHandler 的方法而 UserHandler 的正常调用链是 handler 调 service这就形成了 cycle 的印象。实际上正确做法是handler 在入口处解析登录用户信息传给 service 作为参数或者 service 依赖一个 SessionContext 的抽象来获取用户信息而不是直接调用另一个 handler。排查循环依赖最快的工具是看包依赖图。Go 语言编译时遇到循环导入会直接报 import cycle not allowedJava 的 ArchUnit 会在测试时给出完整的依赖环路径。如果你用的是 Python可以写一段脚本扫描 import 关系来检测环。实践中最有效的还是从设计上杜绝每写一层代码之前先问自己一句这行代码放在这层真的合适吗能把大部分循环依赖掐死在源头。5.2 事务失效为什么回滚没生效事务失效是业务层最容易踩的坑而且排查起来特别折磨人因为它不报错只是悄悄写入了脏数据。总结了我在实战中踩过的三类情况第一类同类方法互相调用导致事务绕过。Spring 的 Transactional 默认走代理同类内部方法调用 this.cancelOrder() 时代理不生效事务注解就成了摆设。解决方案是把需要事务的方法拆到独立的 Service Bean 里或者自己注入代理对象再做调用。第二类异常被吞掉。有的同学在业务方法里写了 try-catch把异常捕获后只记录日志不抛出事务管理器根本感知不到异常发生自然无法回滚。正确做法是事务管理器捕获到任何 error 都要向外抛至少抛到外层统一处理。第三类事务管理器没有真正开启事务。比如配置了错误的数据库连接池把连接设置为 autocommit或者事务管理器管理的数据库连接和真正执行 SQL 的连接不是同一个数据源。这种情况最隐蔽要通过打印 SQL 日志确认是否真的出现了 BEGIN / COMMIT 语句。5.3 命名混乱分层被伪分层架空还有一种我特别想提醒的情况目录结构按三层建了但代码实际写的还是伪分层。怎么判断最简单的指标是看 Service 层是否有大量的 SQL 拼接看 handler 层是否长了超过 50 行看 repository 层是否有 HTTP 请求逻辑。如果出现这些情况说明分层的框架只是摆设本质还是一个大泥球。我曾接手过一个项目Repository 类里居然有调用第三方支付接口的代码Service 类里有解析 Excel 的逻辑Handler 类里有发短信的代码。整个项目在结构上分了层但实际每个类都是一个大杂烩。当时我做了两个月的重构才把混乱的职责一点点归位。要避免这个局面除了在评审时从严把关我还推荐一个代码体检做法每季度随机抽几个关键类统计类的方法数量、每个方法的代码行数、方法内部依赖的组件类型。如果发现某个类方法数超过 15 个或者平均方法长度超过 80 行就列为重点重构对象。这个方法不花多少时间但对防止代码腐化非常有用。以下是排查速查表我整理在下面方便你直接参考症状可能原因优先排查方向修改底层字段导致上层报错上层直接使用了数据表字段检查业务层是否直接依赖 ORM 实体单元测试无法运行依赖了具体数据库实现改用接口注入 mock 实现事务回滚不生效异常被吞或同类内部调用检查 try-catch 和 this 调用编译报循环依赖跨层反向调用画出依赖图找到环路径新需求改动波及面大职责混杂重新归类后再改动5.4 过度分层不要为了分层而分层最后一类问题是过度分层。有些团队把三层又细分成了五层七层每层之间只做简单的透传代码绕了一圈最后什么都没做。这种设计不仅不会提升代码质量还会显著增加维护成本——改一个字段要穿透四五层函数签名。我个人的判断标准是如果某一层的方法只是无脑调用下一层同名方法没有任何逻辑编排或规则处理那这层就是多余的。t3code 的模型是三层不是因为它只能分三层而是因为大多数业务场景下三类职责的划分已经足够。你的项目如果真出现了四层、五层的诉求请先确认每层是否真的有独立的职责在支撑而不是为了让架构图好看。简单业务起步时我甚至建议从两层半开始handler 一层、repository 一层service 层暂时可做可不做。等业务规则真的开始复杂了再把 service 层独立出来。这比我一开始就硬套三层更务实也更能让团队理解每一层存在的理由。6. 工具选型与团队落地的经验补充6.1 常用工具推荐做 t3code 落地有四个维度的工具值得关注接口文档工具、依赖检查工具、单元测试框架、代码规范检查工具。接口文档方面如果你用 Go 可以用 swag 自动从 handler 注释生成 Swagger 文档Java 可以用 springdocTypeScript 可以用 nestjs/swagger。文档的作用不只是给前端看更重要的是反向约束 handler 层的参数定义——参数必须清晰、必须可校验、必须面向调用方。依赖检查方面前面提过 ArchUnit 是 JVM 生态的王者Go 可以用 go list 配合自定义脚本Python 可以用 pip-audit 或者简单的 AST 扫描。这类检查一定要接入 CI让规则自动跑而不是依赖人工评审记忆。单测框架按语言选成熟方案即可。为了推进单测我建议在业务层优先写核心用例的测试数据处理层连一个测试库做集成测试handler 层用轻量的 HTTP mock 测试。三层各有各的测试策略不必强求统一。代码规范方面Go 直接用 gofmt golangci-lintJava 用 Checkstyle SpotBugs前端用 ESLint Prettier。这些工具最重要的意义不在于它的规则本身而是让团队在格式层面减少争议把精力留给真正重要的逻辑讨论。6.2 团队推行时的三个要点在团队里推行 t3code光靠发规范文档是不够的。我有三点实操经验可以分享。第一小步试点不要全量切换。选一个中等复杂度的业务模块做样板整个团队看着这个模块一步步从零到落地比看十页文档都有效。样板模块做完后后续需求按同样模式开发新人也容易照着抄。第二把规则写进代码评审清单。我团队现在的评审清单里有一条固定问题这个改动涉及的层是否清晰有没有跨层调用每次评审必问形成肌肉记忆。第三定期做依赖方向审计。每季度用工具扫一遍依赖关系把违规点列出来修复。这个审计的结果要公开让团队看到趋势。是违规在变少还是在悄悄变多数据比感觉可靠。6.3 哪些项目不适合 t3code最后说实话不是所有项目都适合 t3code。以下几种情况强行套三层反而添乱。极小的原型项目或者一次性脚本没必要分层代码全部放一个文件更快更直接。数据密集型的批处理任务逻辑主要在 SQL 和数据处理流程上分层带来的收益有限。完全没有后续迭代的临时工具写了能用就行。还有如果团队人数只有一两个人且没有扩展计划分层的维护成本也可能超过收益。判断标准很简单这个项目是否会持续迭代半年以上是否有多人协作的可能是否有清晰的业务规则需要长期维护三个问题里有两个是t3code 就值得上否则怎么简单怎么来。7. 最后分享一点个人体会t3code 这套架构方法我使用了三年多从最初的手动检查依赖方向到后来靠自动化工具守护边界过程里踩过的坑比这里写到的要多得多。但它带给团队的最大改变不是代码结构变清晰了而是人的思维方式变了——每个人在写代码之前都会先想一句这行代码放到哪一层半年后我去改它的时候找起来方不方便这个问题的答案往往比任何架构原则都好用。你不需要背熟每一层的定义只需要在落笔之前多花三秒钟想一下职责归属三分之二的架构问题其实都能被消灭在编写阶段。等到项目到了几百个文件、几十万行代码的规模你会感谢当初那个愿意多思考三秒钟的自己。我给新人的建议也很简单不要急着在你的第一个项目里就追求完美的三层拆分先把功能写出来再在第二个版本里做一次职责归位的重构。重构的过程会让你真正理解每一层存在的理由。有了那次亲身体验你再回头看 t3code 的规则根本不需要背自然就懂了。