t3code编码规范:轻量分层架构与工程实践指南
1. 从“t3code”这个名字说起:它到底是什么
第一次看到“t3code”这个词,很多人会下意识地把它当成某个开源库、某个命令行工具,或者某个内部代号。我最初接触它的时候也是这么想的,翻了半天仓库和文档,结果发现它并不是一个现成的、装完就能用的软件包,而更像是一套围绕“T3 场景”沉淀下来的编码约定与实现范式。这里的“T3”在不同团队里指代的东西不完全一样,有的指三层结构(Three-Tier),有的指某类终端到终端的链路,还有的干脆就是项目内部对某个模块的昵称。但不管指代哪一种,t3code 的核心气质是一致的:用一套轻量、可复制、低心智负担的编码方式,把复杂链路的实现收敛成可维护的代码。
我之所以愿意花时间把 t3code 这套东西梳理出来,是因为它解决了一个非常现实的痛点。很多团队在做中大型项目时,前期为了赶进度,代码写得非常随意,接口层、逻辑层、数据层混在一起,等到要加功能或者排查问题时,改一处崩三处。t3code 的思路不是推倒重来,而是在现有工程上做“编码规范化”的增量改造,让每一层职责清晰、边界明确,同时保留足够的灵活性。它适合谁看?我觉得三类人最需要:一是刚接手一个历史项目、被混乱代码折磨的开发者;二是准备从零搭一个中型项目、想一开始就把架子搭对的技术负责人;三是想理解“为什么有些代码看起来简单却特别好维护”的进阶学习者。
需要提前说明的是,t3code 并不是某个官方标准,也没有强制性的规范文档。它更像是一种在实践中被反复验证过的“编码习惯集合”。所以下面我讲的内容,一部分来自我实际项目里的落地经验,一部分是基于这类场景最常见的合理做法做的补全。你完全可以根据自己项目的实际情况做裁剪,不必照单全收。
2. 整体设计思路:为什么 t3code 要这么分层
2.1 核心诉求:让改动只影响一个地方
t3code 最核心的设计目标,用一句话概括就是:任何一次需求变更,理想情况下只需要改动一个层,而不是满仓库找代码。这个目标听起来朴素,但真正做到非常难。我见过太多项目,改一个字段名要从 Controller 改到 Service 再改到 DAO,中间还要改 DTO、VO、Entity 的转换逻辑,改完还得担心有没有漏掉某个分支。t3code 的分层就是为了把这种“牵一发动全身”的问题压下去。
它的分层逻辑大致是这样的:最外层负责接收请求和返回响应,只做参数校验和格式转换,不写业务判断;中间层负责业务逻辑编排,是真正“做决策”的地方;最内层负责数据存取和外部交互,只关心“怎么拿数据、怎么存数据”,不关心业务规则。这样分下来,业务规则变了只动中间层,存储方式变了只动最内层,接口协议变了只动最外层。三层各司其职,互不越界。
2.2 为什么不用更“重”的架构
有人会问,既然要分层,为什么不直接上更完整的领域驱动设计或者更严格的六边形架构?我的经验是,架构的复杂度要和团队的消化能力匹配。t3code 面向的往往是中小团队或者中型项目,人员流动快、需求变化频繁,如果一上来就搞一堆聚合根、值对象、领域事件,光是理解概念就要花掉大量时间,落地时还容易走形。t3code 选择的是“够用就好”的路线:分层清晰、依赖方向单一、每层职责明确,但不强制引入过多抽象。它牺牲了一部分理论上的纯粹性,换来了更低的上手成本和更高的落地率。
我踩过的一个坑是,早年在项目里强行套用了一套很重的架构,结果新来的同事看不懂,老同事嫌麻烦,最后大家各写各的,架构名存实亡。后来换成 t3code 这种轻量分层,反而因为“简单到不用解释”而真正被执行下去了。所以选型这件事,真的不是越先进越好,而是越能被团队稳定执行越好。
2.3 依赖方向:只能从外向内,不能反向
t3code 有一条铁律:依赖方向只能从外层指向内层,内层绝对不能反向依赖外层。也就是说,最外层可以调用中间层,中间层可以调用最内层,但最内层不能反过来调用中间层或最外层。这条规则看起来简单,却是保证分层不腐化的关键。
为什么这么强调?因为一旦内层反向依赖外层,分层就名存实亡了。比如数据层如果直接依赖了接口层的某个 DTO,那接口协议一改,数据层也得跟着改,分层带来的隔离效果就没了。我在 review 代码时,判断一个项目分层做得好不好,第一眼就看依赖方向有没有乱。只要发现内层引用了外层的类,基本可以断定这个项目的分层是“表面分层”。
3. 核心细节解析:t3code 的四个关键约定
3.1 命名约定:让代码自己说明它在哪一层
t3code 对命名有比较明确的要求,目的就是让人看到类名就知道它属于哪一层、干什么用。常见的约定是:最外层用XxxController或XxxHandler,中间层用XxxService,最内层用XxxRepository或XxxGateway。数据传输对象则根据用途区分,比如XxxRequest、XxxResponse、XxxDTO。
这套命名看起来是小事,但实际收益很大。我在一个几十万行的老项目里做过统计,光是靠统一命名,新人定位代码的时间就缩短了将近一半。因为当你想找“某个接口的逻辑在哪”时,直接搜XxxService就能命中,不用在一堆Manager、Util、Helper里翻找。命名统一之后,代码的可搜索性会大幅提升,这在大型项目里是实打实的效率。
注意:命名约定一旦定下来,就要在代码评审里严格执行。我见过太多团队定了规范但没人检查,几个月后命名就彻底放飞了。建议把命名检查做成 CI 的一部分,或者至少在 review checklist 里明确列出来。
3.2 数据转换:边界处转换,内部不传“脏数据”
t3code 强调数据转换只发生在层与层的边界上。最外层收到请求后,把Request转成中间层能理解的入参对象;中间层处理完,把结果转成最外层能返回的Response。中间层内部流转的对象,应该是干净的、只包含业务所需字段的对象,而不是直接把数据库实体或者接口 DTO 一路透传。
这么做的好处是,每一层拿到的数据都是“为自己量身定制”的,不用关心其他层的字段含义。比如数据库实体里可能有is_deleted、create_time这类字段,业务层根本不需要关心,转换时就过滤掉。反过来,业务层需要的某些组合字段,也可以在转换时组装好,而不是让业务层自己去拼。我实测下来,坚持边界转换之后,因为字段含义混淆导致的 bug 明显减少。
3.3 异常处理:分层捕获,统一出口
异常处理是很多项目的重灾区。t3code 的做法是:最内层抛出的技术异常(比如数据库连接失败、外部接口超时),在中间层被捕获并转换成业务异常;业务异常继续往上抛,到最外层统一处理成对用户友好的响应。每一层只处理自己关心的异常,不越权处理。
这里有个细节值得展开。最内层的异常信息往往包含技术细节,比如具体的 SQL 错误、堆栈信息,这些绝对不能直接返回给调用方,否则既有安全风险,用户也看不懂。中间层捕获后,应该转换成“业务语义明确”的异常,比如“订单不存在”“库存不足”。最外层再根据异常类型,决定返回什么状态码和提示文案。这样一条链路下来,异常信息既对用户友好,又保留了排查所需的上下文。
3.4 配置与常量:集中管理,避免散落
t3code 建议把配置项和业务常量集中管理,而不是散落在各个类里。配置项比如超时时间、重试次数、开关标志,统一放在配置文件或配置中心;业务常量比如状态枚举、类型编码,统一放在专门的常量类或枚举里。散落的魔法值和硬编码是维护的噩梦,我接手过一个项目,同一个超时时间在五个地方写了五个不同的值,排查问题时完全不知道以哪个为准。
集中管理之后,改配置只需要改一个地方,而且能一眼看到所有可调参数。对于枚举类常量,用枚举而不是用int或String常量,还能获得类型安全的好处,编译器会帮你挡住不少低级错误。
4. 实操过程:从零搭一个 t3code 风格的项目骨架
4.1 目录结构怎么划分
先看目录结构,这是 t3code 落地最直观的体现。我一般会按层来划分顶层目录,而不是按功能模块划分。比如:
src/ main/ java/ com/example/project/ api/ # 最外层:接口、请求响应对象 service/ # 中间层:业务逻辑 repository/ # 最内层:数据存取 gateway/ # 最内层:外部系统交互 common/ # 公共:常量、工具、异常定义 config/ # 配置类按层划分的好处是,依赖关系一目了然。你打开repository目录,里面绝对不应该出现api目录的 import。如果出现了,就是分层被破坏了。按功能模块划分的目录结构在小型项目里也够用,但项目一大,模块之间的依赖就容易变成一团乱麻,还是按层划分更稳。
4.2 一个完整的请求处理链路
假设我们要实现一个“查询用户订单详情”的功能,走一遍 t3code 的完整链路。
第一步,最外层的OrderController接收请求,拿到OrderQueryRequest,做参数校验(比如订单号不能为空),然后调用OrderService。
public class OrderController { private final OrderService orderService; public OrderResponse queryOrder(OrderQueryRequest request) { if (request.getOrderId() == null) { throw new BizException("订单号不能为空"); } OrderResult result = orderService.queryOrder(request.getOrderId()); return OrderResponse.from(result); } }第二步,中间层的OrderService负责业务编排。它可能需要调用OrderRepository拿订单,调用UserGateway拿用户信息,然后组装成OrderResult。
public class OrderService { private final OrderRepository orderRepository; private final UserGateway userGateway; public OrderResult queryOrder(Long orderId) { Order order = orderRepository.findById(orderId); if (order == null) { throw new BizException("订单不存在"); } User user = userGateway.getUser(order.getUserId()); return OrderResult.builder() .orderId(order.getId()) .amount(order.getAmount()) .userName(user.getName()) .build(); } }第三步,最内层的OrderRepository只负责从数据库取数据,返回干净的Order对象,不掺杂任何业务判断。
public class OrderRepository { public Order findById(Long orderId) { return jdbcTemplate.queryForObject( "SELECT id, user_id, amount FROM orders WHERE id = ?", orderRowMapper, orderId); } }这条链路走下来,每一层的职责都非常清楚。Controller 只管接口,Service 只管业务,Repository 只管数据。改接口协议只动 Controller,改业务规则只动 Service,改存储方式只动 Repository。
4.3 参数校验放在哪一层
参数校验的位置是很多人纠结的点。t3code 的建议是:格式校验放最外层,业务校验放中间层。格式校验比如“字段是否为空”“长度是否超限”“格式是否合法”,这些和业务无关,放最外层最合适,而且可以借助框架的校验注解自动完成。业务校验比如“订单状态是否允许取消”“用户是否有权限操作”,这些依赖业务规则,必须放中间层。
我见过把业务校验也塞进最外层的写法,结果就是 Controller 里一堆 if-else,业务逻辑散落各处。也见过把格式校验放到中间层的,导致中间层要处理一堆和业务无关的判空。分开之后,两边都清爽了。
4.4 日志怎么打才有用
t3code 对日志的要求是:入口和出口打点,关键分支打点,异常打点。最外层收到请求时打一条入口日志,记录请求标识和关键参数;返回响应时打一条出口日志,记录耗时和结果状态。中间层在关键业务分支上打点,比如“订单状态从待支付变为已支付”。异常发生时,在捕获处打点,记录异常类型和上下文。
日志最忌讳的是“打了等于没打”。我见过日志里全是“进入方法”“退出方法”,但没有任何有意义的参数和状态,出了问题根本查不到东西。也见过日志打得太细,一个请求打几十条,把有用信息淹没。我的经验是,围绕“排查问题时你最想知道什么”来打日志,而不是机械地在每个方法首尾都打。
5. 常见问题与排查技巧实录
5.1 分层被破坏的典型信号
分层被破坏往往不是一夜之间发生的,而是慢慢腐化的。有几个典型信号值得警惕:一是内层出现了外层的 import;二是某个类同时出现在多个层里;三是 Service 里直接写了 SQL;四是 Controller 里出现了业务判断。这些信号一旦出现,就要及时纠正,否则会像滚雪球一样越来越严重。
我一般会在代码评审时专门看依赖方向,发现反向依赖就要求改。如果项目已经腐化得比较严重,可以先用工具扫描出所有跨层依赖,列一个清单,然后逐个重构。不要想着一次性全部改完,分批推进更现实。
5.2 数据转换代码太啰嗦怎么办
边界转换的代价是会产生不少转换代码,尤其是字段多的时候,一个from方法可能几十行。这时候可以用一些工具来减少样板代码,比如 MapStruct 这类映射框架,能自动生成转换代码。但要注意,工具生成的代码也要能看懂,不能完全黑盒。我一般对简单的字段映射用工具,对涉及业务逻辑的转换还是手写,保证可读性。
5.3 事务边界应该放在哪一层
事务边界是 t3code 实践里比较容易出错的地方。我的建议是:事务放在中间层,也就是 Service 层。因为一个事务往往对应一个完整的业务操作,而业务操作是中间层编排的。最内层的 Repository 不应该自己开事务,否则多个 Repository 调用无法保证在同一个事务里。最外层也不应该开事务,因为接口层不应该关心业务的事务语义。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 改一个字段要改很多文件 | 分层不清晰,数据对象透传 | 检查是否在边界做了转换 |
| 内层引用了外层的类 | 依赖方向被破坏 | 扫描 import,重构依赖 |
| 异常信息直接返回给用户 | 异常未在中间层转换 | 检查异常处理链路 |
| 配置值不一致 | 配置散落各处 | 集中到配置文件或配置中心 |
| 日志查不到问题 | 日志打点无意义 | 围绕排查需求重新设计日志 |
5.5 几个我踩过的坑
第一个坑是过度分层。早期我为了追求“纯粹”,把一个很小的功能也拆成三层,结果代码量翻倍,收益却很小。后来我调整了策略:小功能可以适当合并,大功能必须严格分层。分层是为了可维护性,不是为了分层而分层。
第二个坑是转换对象太多。有一段时间我定义了Request、DTO、Entity、VO、Result一大堆对象,转换来转换去,自己都晕了。后来精简成“每层最多一个入参一个出参”,对象数量大幅减少,可读性反而提升了。
第三个坑是忽略了团队共识。规范定得再好,如果团队不认可,执行起来就会打折扣。我现在的做法是,规范落地前先和团队一起讨论,把大家的意见吸收进来,形成共识后再推行。这样执行阻力会小很多。
6. 工具选型与配套建议
6.1 静态检查工具怎么配
t3code 的很多约定,比如依赖方向、命名规范,都可以用静态检查工具来自动化。Java 生态里可以用 ArchUnit 来写架构测试,比如“repository 包不能依赖 api 包”这样的规则,写成测试用例,每次构建时自动检查。这样规范就不是靠人自觉,而是靠工具兜底。
我实测下来,ArchUnit 这类工具对保持分层非常有效。因为人总会疏忽,但工具不会。只要规则写好了,一旦有人破坏分层,构建就会失败,问题在合并前就被挡住了。
6.2 代码模板怎么沉淀
t3code 的落地离不开代码模板。我一般会准备一套脚手架,新建功能时直接生成 Controller、Service、Repository 的骨架,开发者只需要填业务逻辑。这样既保证了结构统一,又降低了上手成本。模板可以放在 IDE 的 live template 里,也可以做成项目内的代码生成器。
6.3 文档怎么写才有用
规范文档最怕写成“八股文”,没人看。我的经验是,文档要短、要有例子、要能直接抄。与其写一大段“应当如何如何”,不如给一个正例一个反例,对比着看一目了然。另外,文档要跟着代码走,代码变了文档也要更新,否则过期的文档比没有文档更害人。
7. 这套东西后续还能怎么扩展
t3code 这套分层思路,本质上是一种“关注点分离”的实践,它的适用范围远不止后端接口开发。我在做数据处理脚本、定时任务、甚至前端状态管理时,都借鉴过类似的分层思路:把“接收输入”“处理逻辑”“输出结果”分开,每部分只做一件事。效果是一样的,代码更好懂,也更好改。
如果要把 t3code 继续深化,我觉得有两个方向值得尝试。一是把分层规则和自动化测试结合得更紧密,让架构约束成为测试的一部分,而不是靠人工 review。二是针对不同规模的项目,沉淀出“轻量版”和“完整版”两套规范,小项目用轻量版快速起步,大项目用完整版保证长期可维护。我自己在实际操作中的体会是,规范的价值不在于多严格,而在于能不能被稳定执行。一套能被团队长期坚持的简单规范,远比一套没人遵守的完美规范有用。