t3code:基于三层架构的代码组织范式与落地实践

要说代码组织这件事,我见过太多项目从清爽走向混乱的过程:起初三五个人写一个小服务,Controller 里一把梭,Service 层层包裹,Mapper 随手一写,一个月跑通功能就上线。等到项目做到半年以上,开始加人、加需求、加外部系统对接,问题就全来了——改一个接口要找半天上下游,加一张表要动四五个文件,代码 review 的时候谁也说不清楚某个逻辑到底该放哪一层。

我去年接手维护的遗留系统就是这么个状态,三千多个类文件挤在一起,包名混乱,依赖关系环环相扣。当时我就跟团队定了个规矩:不管后续怎么写新功能,代码组织必须按照一套明确的分层标准来。这套标准最后沉淀下来,内部就叫它t3code。

t3code 不是什么开源框架,也不是某家公司的产品,它本质上是一套基于三层架构(Three-Tier Architecture)的代码组织范式,强调"表现层、业务层、数据层"三层边界清晰、依赖方向统一、可测试性强。T3 这个前缀,正好是 Three-Tier 的缩写;code 就是对代码工程的落地约束。简单来说,t3code 就是一套可以复用的代码分层设计方案和工程实践模板,它解决的问题是:当你的项目规模超出"一个人全记住"的范畴之后,如何让代码依然可读、可维护、可演进。

这篇文章我从头梳理一下这套方案的思路、细节、落地过程,以及我在真实项目中踩过的坑和总结出来的排查技巧。不管你是刚带团队的技术负责人,还是写了两三年业务代码想提升工程能力的后端开发,这套思路应该对你都有参考价值。

1. 整体设计与思路拆解

1.1 t3code 的三层边界:到底把代码切在哪几条线上

先说清楚 t3code 对"层"的定义。很多人一提三层架构,第一反应就是 Controller、Service、Mapper 三个包名,但这只是最粗的物理划分。真正让分层失效的,往往是层与层之间的职责越界——比如有人在 Controller 里直接写了 SQL 查库,有人在 Service 里拼 HTML 片段返回前端,还有人把第三方接口的调用逻辑散落在各个工具类里,最后谁也不敢动。

t3code 的核心思路,是把代码按照依赖方向来分,而不是按照"名字"来分。它的三层定义如下:

  • 表现层(Interface Layer):负责接收外部输入、调用业务能力、将结果转换为符合协议要求的输出。这一层只做"翻译"和"路由",不做任何业务决策。在 Java 后端里对应 Controller、GraphQL Resolver、RPC 服务实现类、消息队列的 Consumer 入口。
  • 业务层(Business Layer):承载全部业务规则和用例编排。这一层是整个系统的核心,它不感知 HTTP 协议、不感知数据库表结构、不感知前端传参格式。它只依赖"领域模型 + 业务接口",通过接口去获取数据和持久化结果。
  • 数据层(Data Layer):负责与数据库、缓存、外部存储、第三方服务对话。这一层只提供数据访问能力,不承载业务规则。它实现业务层定义的接口,隐藏具体的存储细节。

边界划分的核心判断标准是:任何一层的改动,都不应该强迫另外两层跟着改。表现层从 HTTP 换成 gRPC,业务层零改动;数据层从 MySQL 换成 PostgreSQL,业务层零改动。如果达不到这个效果,说明分层设计只是做了个样子。

1.2 依赖方向与倒置原则:为什么业务层必须定义接口

t3code 在这三层之外,还有一个容易被忽视的关键设计:依赖方向必须统一由外向内。

也就是说,表现层依赖业务层,业务层依赖数据层的接口(而不是实现类),数据层的具体实现反向依赖业务层定义的接口规范。这就涉及到一个重要的设计原则——依赖倒置(Dependency Inversion Principle, DIP)。简单来说,高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。

很多团队做得不彻底的地方就在这:他们让 Service 直接依赖 Mapper 接口,虽然代码上是面向接口编程,但这种 Mapper 接口是从 MyBatis 的持久化视角定义的,天然带上了数据层的烙印。比如一个UserMapper.selectByCondition(ConditionQuery query),查询条件直接暴露给 Service 层,Service 层就必须知道数据层有"按什么字段筛选"的能力。将来如果数据层换成别的存储,这个接口形状就得改。

t3code 的做法是,业务层自己定义一套业务语义接口,比如UserRepository.findByRegistrationPeriod(Period period),数据层去实现这个接口,把"时间段"翻译成 SQL 条件或缓存遍历逻辑。这个倒置带来的收益是:业务代码只谈业务语言,数据代码只谈存储细节,两者之间的翻译工作被限制在数据层内部。

1.3 相比传统 MVC 和微服务的定位差异

有人可能会问:现在流行微服务,很多人已经在用 DDD(领域驱动设计),那 t3code 是不是过时了?我的看法是,t3code 不是和这些方法论对立的,它更像是单体应用或模块化单体阶段最务实的组织基线。

传统 MVC 的问题是 Controller 和 Model 之间的 Service 层经常被架空,业务逻辑要么写在 Controller 里,要么写在 Model 的 getter/setter 里,最终变成"贫血模型 + 天下大乱"。DDD 很好,但 DDD 对团队的业务抽象能力要求很高,不是每个团队都有领域专家,也不是每个项目都值得在初期做完整的领域建模。

t3code 踩在中间:它比 MVC 多了明确的依赖倒置约束,又比 DDD 轻得多,不强行引入聚合根、领域事件、CQRS 这些概念。它适合从传统 MVC 项目往更规范的方向演进,也适合作为引入完整 DDD 之前的过渡形态。我在实际中就是先让团队把 MVC 的野路子收敛到 t3code 的三层边界上,等系统长到一定程度,再在局部引入领域建模,这样渐进式改造的阻力要小得多。

2. 核心细节解析与实操要点

2.1 工程目录规范:包结构怎么切,类怎么命名

t3code 的落地,第一步是统一工程的包结构和命名规范。我基于这个思路,给出一个标准的目录模板(以 Java 项目为例,其他语言同理映射到模块结构):

com.example.project ├── interface │ ├── controller │ ├── request │ ├── response │ └── assembler ├── business │ ├── service │ ├── service/impl │ ├── model │ ├── repository │ └── exception └── data ├── repository/impl ├── mapper ├── entity └── config

几个容易出错的地方需要多强调一下:

  • request 和 response 只属于表现层。它们的作用是适配外部协议,不要把它们作为方法参数穿透到 Service 层。Service 方法签名应该使用业务模型或更抽象的 DTO,而不是HttpServletRequest或LoginRequestVO。
  • repository 接口定义在 business 层,实现放在 data 层。这是依赖倒置的关键。business 层的 service 只依赖repository包下的接口,而 data 层的repository/impl包去实现这些接口。
  • entity 只属于数据层,它直接对应表结构或存储文档的结构。不要在业务层引用 entity,更不要把 entity 直接返回给前端。业务层操作的是业务模型 model,不是持久化实体。
  • exception 在 business 层定义,表现层负责捕获并转换为错误响应。业务异常和 HTTP 状态码不应该绑死。

命名方面,我一般要求类名带上明确的角色后缀,例如OrderService、OrderRepositoryImpl、OrderMapper。最忌讳的是出现OrderUtil、OrderManager、OrderHelper这类"什么都能往里塞"的模糊类名——一旦出现这种类,几乎必然成为后续代码腐化的起点。

2.2 参数传递与数据装配:防止模型混乱的三个技巧

三层之间传递什么类型的数据,这是 t3code 实践中争议最大、最容易出问题的细节。我总结出三条实用原则:

第一,跨层传递尽量使用"接口层 DTO + 业务模型"两种形态。表现层接收的请求先转换为业务模型(或专用的业务入参模型),再交给业务层。业务层返回的结果也是业务模型,表现层再转换为 response 结构。避免出现一个UserVO既当入参又当出参、既包含密码哈希又包含表单 token 这种大杂烩。

第二,业务层方法入参不要超过 3 个。超过 3 个参数的时候,建议封装成一个对象。这既是为了可读性,也是为了后续扩展。比如注册接口要传用户名、密码、邮箱、手机号、验证码,与其写 5 个参数的方法,不如定义一个RegisterUserCommand,后面加字段不影响调用方。

第三,数据层返回的 entity 一律不直接上抛。业务层查询用户后要返回给表现层,应当先将 entity 转换成业务模型(model),再返回。这个转换动作放在哪?我建议放在业务层内部或者由业务模型自己的工厂方法来处理,而不是让表现层的 assembler 去读 entity 再组装——那会迫使表现层感知数据层结构。

2.3 事务与业务逻辑的边界:事务注解别乱加

关于事务,网上有很多"标准答案",但真实项目里踩坑最多的偏偏就在事务上。t3code 对事务有两点约束:

  • 事务边界放在业务层,不要放在数据层。数据层的方法只负责单次数据操作,事务的开启、提交、回滚应该由业务服务编排。这样可以保证多个数据操作之间的原子性。
  • 不要在一个事务里做远程调用或耗时操作。比如事务方法里调第三方支付接口、发短信、调用另一个微服务——这些 IO 操作会长时间占用数据库连接,高并发场景下连接池很快就耗尽。t3code 的经验做法是:事务内只做必须保持一致性的数据变更,事务外做通知类、补偿类操作,或者通过异步事件来解耦。

另外一个细节是事务方法的自调用问题。在 Spring 这类基于代理的框架里,同类内部调用this.xxx()不会经过代理,事务注解会失效。我踩过这个坑:一个OrderService里的公共方法调用了自身的createOrder(),后者标了@Transactional,结果事务压根没生效,其中一个插入失败导致数据不一致。正确做法是调用方和被调用的方法拆到两个 Bean,或者通过注入自身代理的方式。

2.4 异常处理机制:业务异常与系统异常分流

异常处理是 t3code 中体现工程成熟度的环节。我的实践方案分两层:

业务层只抛业务异常,携带错误码和错误信息,例如BizException(ErrorCode.INSUFFICIENT_STOCK, "库存不足")。系统异常(比如数据库连不上、序列化失败)不包装、不吞掉,直接往外抛,由表现层的全局异常处理器兜底统一处理成 500 响应并记录详细日志。

这套分流的关键是:不要让业务异常伪装成系统异常。有些团队为了省事,所有异常都抛一个RuntimeException,前端收到的永远都是"系统繁忙",用户没法根据错误码做提示,排查问题也非常痛苦。合理的做法是维护一个错误码枚举,明确每个业务场景下的失败码和默认提示文案,同时允许业务层在抛异常时覆盖提示信息。

3. 实操过程与核心环节实现

3.1 从零搭建一个 t3code 项目骨架

下面我们完整走一遍用 t3code 思路搭建一个电商订单模块的流程。假设技术栈是 Spring Boot 3 + MyBatis-Plus + MySQL,你要复现的话,可以直接照着这个步骤操作。

第一步,创建工程目录并建立三层包结构。这里我不展开 Spring Initializr 的操作了,直接看核心部分。

第二步,在business层定义业务模型和仓储接口。先创建订单业务模型:

package com.example.project.business.model; import java.math.BigDecimal; import java.time.LocalDateTime; public class Order { private Long id; private String orderNo; private Long userId; private BigDecimal totalAmount; private String status; private LocalDateTime createdAt; // 省略 getter/setter }

第三步,定义仓储接口。注意订单查询的语义用业务语言,而不是数据表语言:

package com.example.project.business.repository; import com.example.project.business.model.Order; import java.time.LocalDateTime; import java.util.List; public interface OrderRepository { Order findById(Long id); List<Order> findByUserAndPeriod(Long userId, LocalDateTime start, LocalDateTime end); void insert(Order order); void updateStatus(Long id, String status); }

第四步,在data层创建 entity 和 Mapper。这里的实体类对应t_order表:

package com.example.project.data.entity; import com.baomidou.mybatisplus.annotation.TableName; import java.math.BigDecimal; import java.time.LocalDateTime; @TableName("t_order") public class OrderEntity { private Long id; private String orderNo; private Long userId; private BigDecimal totalAmount; private String status; private LocalDateTime createdAt; // 省略 getter/setter }

Mapper 接口就直接基于 MyBatis-Plus 的BaseMapper,不需要写自定义 SQL:

package com.example.project.data.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.project.data.entity.OrderEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface OrderMapper extends BaseMapper<OrderEntity> { }

第五步,数据层实现业务层定义的仓储接口。这里是最能体现依赖倒置的地方:

package com.example.project.data.repository.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.example.project.business.model.Order; import com.example.project.business.repository.OrderRepository; import com.example.project.data.entity.OrderEntity; import com.example.project.data.mapper.OrderMapper; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.List; @Repository public class OrderRepositoryImpl implements OrderRepository { private final OrderMapper orderMapper; public OrderRepositoryImpl(OrderMapper orderMapper) { this.orderMapper = orderMapper; } @Override public Order findById(Long id) { OrderEntity entity = orderMapper.selectById(id); if (entity == null) { return null; } return convert(entity); } @Override public List<Order> findByUserAndPeriod(Long userId, LocalDateTime start, LocalDateTime end) { LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(OrderEntity::getUserId, userId) .between(OrderEntity::getCreatedAt, start, end); return orderMapper.selectList(wrapper).stream() .map(this::convert) .toList(); } @Override public void insert(Order order) { OrderEntity entity = new OrderEntity(); // 属性拷贝,字段很多时可以用 MapStruct orderMapper.insert(entity); } @Override public void updateStatus(Long id, String status) { OrderEntity entity = new OrderEntity(); entity.setId(id); entity.setStatus(status); orderMapper.updateById(entity); } private Order convert(OrderEntity entity) { // 实体转业务模型 Order order = new Order(); order.setId(entity.getId()); order.setOrderNo(entity.getOrderNo()); order.setUserId(entity.getUserId()); order.setTotalAmount(entity.getTotalAmount()); order.setStatus(entity.getStatus()); order.setCreatedAt(entity.getCreatedAt()); return order; } }

第六步,业务层实现核心逻辑。订单创建涉及订单插入和扣减库存,必须保证事务一致:

package com.example.project.business.service; import com.example.project.business.exception.BizException; import com.example.project.business.model.Order; import com.example.project.business.model.StockChange; import com.example.project.business.repository.OrderRepository; import com.example.project.business.repository.StockRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @Service public class OrderService { private final OrderRepository orderRepository; private final StockRepository stockRepository; public OrderService(OrderRepository orderRepository, StockRepository stockRepository) { this.orderRepository = orderRepository; this.stockRepository = stockRepository; } @Transactional public Order createOrder(Long userId, Long skuId, Integer quantity, BigDecimal amount) { // 校验参数 if (quantity <= 0) { throw new BizException("数量必须大于0"); } // 扣减库存 boolean success = stockRepository.deduct(skuId, quantity, userId); if (!success) { throw new BizException("库存不足"); } // 创建订单 Order order = new Order(); order.setUserId(userId); order.setTotalAmount(amount.multiply(BigDecimal.valueOf(quantity))); order.setStatus("CREATED"); // 生成订单号逻辑省略 orderRepository.insert(order); return order; } }

第七步,表现层定义请求和响应,并调用业务层:

package com.example.project.interface.controller; import com.example.project.business.model.Order; import com.example.project.business.service.OrderService; import com.example.project.interface.request.CreateOrderRequest; import com.example.project.interface.response.OrderResponse; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping public OrderResponse create(@RequestBody CreateOrderRequest request) { Order order = orderService.createOrder( request.getUserId(), request.getSkuId(), request.getQuantity(), request.getAmount() ); OrderResponse response = new OrderResponse(); response.setOrderNo(order.getOrderNo()); response.setStatus(order.getStatus()); return response; } }

这样整个模块就串起来了。注意几个细节:表现层没有出现任何 entity 类或 Mapper 引用;业务层没有出现 HTTP 相关的类型;数据层没有出现业务规则判断。你从任何一个类出发,都很容易说出它属于哪一层、和谁协作。

3.2 参数校验:在哪一层做,怎么做

参数校验是 t3code 里一个"看着简单但实际上经常翻车"的环节。很多人直接把校验塞进 Controller 方法里,用 JSR-303 注解一把梭。t3code 的建议是:基础格式校验放在表现层,业务语义校验放在业务层。

什么叫基础格式校验?用户 ID 不为空、手机号格式正确、金额非负,这些是协议层面的约束,用@NotNull、@Positive这类注解在请求 DTO 上处理即可。什么叫业务语义校验?比如"订单状态为 CANCELLED 时不能申请退款""该商品不在售卖区域",这些必须进业务层,因为只有业务层才知道完整的上下文。

实操中的典型错误是:在 Controller 里累死累活写了一大堆if...throw...,把业务规则都晒在表现层。一旦有其他入口(比如消息队列消费)调用同一个业务逻辑,这些校验就完全被绕过了。把业务校验下沉到业务层,代码复用和安全性都会好很多。

3.3 自动化测试该怎么切:让三层边界成为测试边界

t3code 的三层设计天然有利于测试。我的习惯是:

  • 业务层测试:用 Mockito mock 掉 repository 接口,专注验证业务规则和异常分支。因为业务层不依赖 HTTP 和数据库,测试速度飞快,可以做到毫秒级跑完。
  • 数据层测试:用真实数据库(H2 或 Testcontainers)跑 MyBatis 的 SQL 映射,验证 SQL 语法和映射关系。
  • 表现层测试:用 MockMvc 做接口级别的集成测试,验证请求参数校验、响应结构和状态码。

如果项目里代码只能靠@SpringBootTest全部加载起来才能测,那说明分层还不够彻底。我见过很多项目测试慢到十几分钟跑完,就是因为测试一个 Controller 要拉起整个 Spring 容器外加 Redis 和消息队列。按照 t3code 的边界来切测试,你会有意外的惊喜。

4. 常见问题与排查技巧实录

4.1 症状一:Controller 越来越胖,Service 越来越空

这是 t3code 落地过程中最常见的"逆行"。项目赶工阶段,新来的同学可能图省事,直接在 Controller 里把数据查出来、逻辑算完、响应拼好,Service 只剩一个空壳。

我的排查方法是:随机抽一个 Controller,看它的方法体里有没有超过 20 行的"非路由"代码。如果有,就需要重构。重构姿势不是简单把代码搬进 Service,而是先看清这段逻辑到底属于哪一层:

  • 如果是协议对象转换,留在 Controller 或放进 assembler。
  • 如果是业务规则或数据流转编排,下沉到 Service。
  • 如果是 SQL 拼接或缓存读写,下沉到数据层的 repository 实现。

实际操刀的时候,我会要求团队在代码 review 时加入一条硬性标准:Controller 方法体不超过 15 行。超过就要解释为什么。

4.2 症状二:循环依赖或者分层混乱导致无法启动

Spring 项目中经常出现BeanCurrentlyInCreationException,原因常常是表现层、业务层、数据层互相 new,导致依赖环路。t3code 的依赖方向本来就是单向的,正常设计不会出现循环。出现循环依赖,十有八九是有人把本该放数据层的 repository 实现放错了包,或者业务层直接依赖了另一个业务层实现类而不是接口。

排查思路分两步:先看启动日志里报的是哪个 bean,搜一下它在哪些类中被注入;然后判断这个依赖方向是否违反了"表现层→业务层→数据层接口"的单向规则。凡是反向依赖,一律通过引入接口或抽公共模块解决。

4.3 症状三:事务失效但代码看起来没问题

事务失效是个经典问题,表象是方法没有抛异常,但数据只改了一半。除了前面提到的同类自调用之外,还有几个常见原因:

  • 事务方法所在类没有被 Spring 管理。检查类上有没有@Service/@Component。
  • 事务方法不是 public 的。Spring 默认只对 public 方法代理。
  • 数据库表引擎不支持事务(比如 MySQL 的老 MyISAM 表)。这个坑隐蔽得很,我遇到过线上库某张表还是 MyISAM,事务回滚完全没有效果,排查半天才从建表语句里发现问题。
  • 异常被吞掉了。业务代码里try...catch把异常接住后没有重新抛出,事务自然感知不到。

排查时最有效的手段是:在事务方法的第一行记录日志,手动写一个必失败的 case 看数据是否回滚。如果没回滚,就检查上面四条,逐项排除。

4.4 症状四:接口莫名其妙变慢,怀疑是分层调用太多

分层设计被诟病最多的地方就是"多层调用有性能损耗"。实际上,只要没有在每一层做无意义的封装和对象拷贝,三层的性能开销可以忽略。真正影响性能的往往不是层数,而是:每一层内部是否做了额外的事情。

比如数据层查询一个列表,业务层拿到后逐条调 repository 查询关联信息,产生 N+1 问题——这在拆分层的初期很常见。我的排查工具是 MyBatis 的 SQL 日志,开启后能看到每条 SQL 的执行时机和调用栈。如果同一个请求打印了上百条相似 SQL,那基本就是 N+1,应该在数据层直接装配好聚合数据,而不是让业务层在循环里查库。

另外一个隐藏问题是:对象转换时用了反射工具类做深拷贝,在大批量数据下会产生明显开销。我的建议是,列表数据用流式映射加 MapStruct 这类编译期转换工具,不要用反射。

4.5 一个实用的排查清单

最后整理一份我在项目里打印出来贴墙上的排查清单:

问题方向快速检查项
类归属不清文件在哪个包?是否引用下层实现类?是否出现非本层类型?
事务问题类是否有 Spring 注解?方法是否 public?是否自调用?异常是否被吞?表引擎是否支持事务?
性能问题SQL 是否逐条执行?循环中是否调远程/数据库?对象转换是否用反射?
测试问题测试是否必须加载整个容器?业务测试是否依赖数据库?
依赖方向business 是否 import data 包?interface 是否 import data 包?

这张表基本覆盖了我在 t3code 实践中遇到的大部分典型问题。

5. 从单体到模块化:t3code 的演进路线

5.1 包分层 → 模块分层的渐进改造

t3code 的落地不一定一步到位。如果你的项目已经混乱了,我的建议是分阶段走:

第一阶段,先做包结构收敛。不改变任何运行逻辑,只移动类所在的包,把 controller、service、repository、entity 各归其位。这一步的风险最小,但收益立竿见影——至少大家找文件的速度快了很多。

第二阶段,做依赖方向修正。把跨层调用的实现类依赖改成接口依赖,把表现层对数据层的直接依赖逐步消灭。这一阶段可能需要改部分方法签名,如果你在第一步就严格限制了跨包访问(比如 data 包的实体类用包私有或者@Deprecated标注),改动会平滑很多。

第三阶段,在业务层引入领域模型和用例服务。这时候 t3code 自然演进为模块化单体——每一块业务(订单、库存、用户)有独立的三层结构,模块之间通过接口交互,数据表互不直接访问。这个阶段已经具备微服务拆分的基础条件了。

5.2 什么时候不该硬套 t3code

需要说句公道话:t3code 不是银弹。如果项目只有两三个接口、生命周期短、没有后续演进计划,硬套三层反而增加工作量。比如内部一次性脚本、原型演示项目,按业务逻辑直写就完了。

另外,对于纯查询为主的报表类系统,三层可能会显得繁琐。这种场景我会适当压缩:表现层直接通过数据访问服务查询数据,弱化业务层。但要记住一个底线——即使不做完整三层,Controller 里也别写 SQL,这是无论如何不能破的规矩。

5.3 团队执行落地的几点建议

t3code 能不能真正在团队里落地,核心不在技术方案本身,而在执行细节。我的几个体会:

  • 先拿一个小模块做试点,不要第一天就要求所有存量代码整改。我通常选一个存在感低、业务简单的模块,完整按 t3code 走一遍,让团队看到"也没有多麻烦"。
  • 把规范写成 checkpoint 清单放进 CI 的 review 模板,而不是写在 wiki 里吃灰。比如"本 PR 是否涉及跨层引用?""Controller 是否超过 15 行?""新加的类属于哪一层?"
  • 项目里用 ArchUnit 这类架构约束工具来自动把关。它能写规则断言"business 包不得依赖 data 包实现类""接口层不得 import entity 包",CI 跑一次就能抓到违规。这个工具我很推荐,相当于给架构规范上了把自动锁。

6. 写在最后(实战心得)

我带着团队从一团乱麻的 MVC 老项目走到现在,前后用了差不多四个月,过程不是一帆风顺。最大的阻力其实不是技术,而是"习惯"——老代码怎么写的,新来的就跟着怎么写,没人觉得有问题。直到我们把 t3code 的包结构立起来、在代码 review 里卡了几次违规案例之后,新代码的风格才逐渐统一起来。

有个细节我一直记得:一次线上故障排查,一个同事改了缓存 key 的规则,结果在 Controller 里直接 new 了一个 RedisTemplate 去写数据,绕过了业务层和缓存缓存层的统一入口。这个操作导致了缓存与数据库不一致,用户看到了脏数据。事后复盘,问题根因就是绕过分层、逻辑散落。倒不是说绕过分层一定出事,但一旦每个人都按自己的偏好"抄近路",系统就会滑向不可控。

根据我个人经验,t3code 最值得花力气的地方不是第一遍搭骨架,而是日复一日守住边界。你可以把一个模块的分层写得非常漂亮,但如果允许"这次赶时间先绕一下"发生,三个月后那层漂亮的墙就会千疮百孔。所以如果只让我给一条建议,那就是:在 CI 里加上架构校验,把"分层违规"和"编译报错"一样当成死线。这一件事做好了,项目维护成本能降一个数量级。

后续如果你也想在这套思路上多做一步,我建议试试把仓储接口按业务能力聚合,引入简单的 CQRS 读写分离,或者对业务层写一轮基于用例的单元测试补齐覆盖率,这些都是在 t3code 基线上值得投入的方向。