1. 这个项目到底想解决什么问题
先把话说在前面:如果你只是想把接口写通、页面能跳、数据能存,那t3code这套思路对你来说可能有点“重”。但如果你接手过一个三个月没人敢动的老项目,或者自己写的代码三个月后回看都头疼,那你大概能明白我在说什么——大部分业务系统烂掉,不是烂在某个技术选型上,而是烂在“代码没有任何结构感”。
t3code这个名字,我理解下来其实就是“三层代码”的简写,指代的是一套以三层架构为核心、以接口契约为边界的工程化编码范式。它的目标很直接:让团队里每一个人写出来的代码都长一个样,让新人接手老模块的时间从两周缩短到两天,让每次需求变更都能清楚地知道“改哪一层、不动哪一层”。
这个项目适合谁?适合中小型后端团队,适合那些还在用“Controller里写业务逻辑、SQL满天飞、实体类既当数据库映射又当前端传输对象”的团队,也适合想把自己的编码习惯往工程化方向掰一掰的独立开发者。它不是框架,不是中间件,而是一种写代码的组织方式——你可以把它理解成“代码的户型图”,不管里面用什么装修材料(Spring Boot、MyBatis、还是别的什么),户型合理了,住起来才不憋屈。
我最初看到“t3code”这个名字的时候,第一反应是“又一个造轮子的代码生成器”。但真正梳理下来发现,它的价值不在于生成多少代码,而在于它强制性地把系统分成了清晰的三个层次:接口层、业务层、数据层。听起来像废话?但你要是见过把MyBatis的Mapper写在Service里、把Service注入到Controller里、然后在实体类上挂满JSON注解的项目,就知道“分清楚”这三个字有多难。
2. 三层编码的思路拆解:边界才是灵魂
2.1 为什么非要三层,两层不行吗
很多人的第一反应是:我Controller调Service,Service直接操作数据库,这不也是两层吗?这不也挺清晰吗?
这里有个很容易被忽视的点:两层架构最大的问题不是“少了一层”,而是“没有地方放领域规则”。业务逻辑一旦复杂起来,比如一个下单动作要校验库存、要锁定优惠券、要写流水、要通知物流系统,这些逻辑如果全堆在Service里,Service很快就会变成一个几百行的方法。你会开始拆分出private方法,然后这些private方法越来越长,最后你发现你写的不是业务逻辑,是一锅粥。
三层架构的核心价值在于:它给每一类职责划定了“势力范围”。接口层只负责“翻译”,把HTTP请求翻译成业务参数,把业务结果翻译成HTTP响应;业务层只负责“决策”,处理所有业务规则和流程编排;数据层只负责“存取”,把业务对象的持久化细节藏起来。边界一旦清晰,复杂逻辑就有了固定的落脚点——它必须落在业务层,想乱跑都跑不掉。
我自己实践下来有一个特别明显的体感:当一个需求变更涉及三层同时修改的时候,大概率是这个需求本身就设计错了,或者你的边界划分从一开始就是歪的。正常的变更应该只落在一层里,最多再加一层做适配。如果你发现自己每次改需求都是三层一起动,那你要反思的不是代码,而是你对业务的理解。
2.2 t3code里的“三”到底指哪三块
展开说一下t3code这套编码范式里,三层各自管什么、不管什么。
接口层(Interface Layer)管的是协议相关的所有事。HTTP状态码怎么映射、参数校验怎么失败、报文格式是JSON还是XML、哪个字段要脱敏、哪次请求要记录审计日志,这些都在接口层处理。一个容易被忽略的原则是:接口层不应该有任何业务规则判断。哪怕你觉得“这个用户没权限访问这个接口,我在这里直接return一个403就行”很方便,也坚决不要这么做——权限判断属于业务规则,应该下沉到业务层,接口层只负责翻译业务层抛出的异常为HTTP响应。
业务层(Business Layer)是系统的心脏。所有业务规则、状态流转、金额计算、流程编排,都写在这里。它不对接任何具体的技术细节,不知道HTTP是什么,不知道SQL是什么,它只操作业务对象,只抛出业务异常。这么做有一个巨大的好处:业务层可以被纯单元测试覆盖,不需要启动Spring容器、不需要Mock数据库,跑起来飞快,测试覆盖率能轻松拉到80%以上。
数据层(Data Layer)是最简单也最容易被写烂的一层。它在t3code里的定位是:只做持久化和查询,不做任何业务判断,不返回任何业务异常。所有SQL、所有ORM映射、所有缓存读写,全在这一层收口。这一层的设计准则是“笨”——越笨越好,越没想法越好,它就是一个存取仓库,业务层要什么就给什么,存什么就存什么。
2.3 同层之间不许互相调用,这条规则最香
三层架构大家都听说过,但t3code这套实践里最精华的一条约束是:业务层内部,不同业务模块之间也不允许直接互相调用对方的Service。
打个比方,订单模块需要知道用户是否VIP,正确的做法不是让订单Service直接注入用户Service,而是让订单Service依赖一个“用户信息查询接口”,这个接口由用户模块提供,但定义在一个公共的契约包里。这样做的效果是:模块之间彻底解耦,订单模块的开发不需要关心用户模块的表结构、缓存策略、实现细节,只需要知道“给我一个用户ID,我就能拿到需要的用户信息”。
这条约束在项目初期会让人觉得很麻烦——明明直接注入一下就能调,非要绕一圈去定义接口。但项目到了中期、团队到了五个人以上、模块到了十个以上,这条约束的价值就会爆炸式体现出来:因为每个模块都能独立测试、独立演进、独立换实现。我见过很多项目死在“模块耦合”上,而这条规则就是最便宜的保险。
3. 实操要点:从目录结构到命名规范
3.1 标准目录结构长什么样
我根据t3code的思路整理出了一套可以直接抄的目录结构,适用于绝大多数基于Spring Boot的中小型项目:
com.company.project ├── application # 接口层(Application Layer) │ ├── controller # HTTP控制器 │ ├── dto # 入参出参对象 │ ├── assembler # DTO与业务对象的转换器 │ └── config # Web层配置(拦截器、过滤器) ├── domain # 业务层(Domain Layer) │ ├── model # 业务核心对象(Entity/Model) │ ├── service # 业务服务接口 │ ├── service.impl # 业务服务实现 │ └── event # 领域事件定义 ├── infrastructure # 数据层(Infrastructure Layer) │ ├── repository # 数据访问接口 │ ├── repository.impl # 数据访问实现(MyBatis/JPA) │ ├── mapper # ORM映射文件/接口 │ └── util # 基础设施工具 └── common # 跨层通用 ├── exception # 全局异常定义 └── result # 统一返回结构注意两个细节。第一,dto和model是分开的,数据层查出来的数据库实体和业务层使用的领域对象是两回事,绝不允许数据库实体直接传到接口层去。第二,common只能放无业务含义的通用内容,比如异常类、返回结构、常量定义,绝对不要往里面塞业务相关的东西,否则它就会变成著名的“垃圾桶包”,最后什么都往里扔。
3.2 实体、DTO、VO到底怎么分
很多项目混乱的根源在于“一个类到处用”:数据库的User实体,直接返回到前端;前端的入参,直接存进数据库。短期看很爽,长期看全是坑。
在t3code的实践里,对象被明确分为三类:
- 数据库实体(Entity/PO):对应一张表,字段和表结构一一对应,只存在于数据层。
- 业务对象(Model/Domain Object):承载业务规则的载体,字段是业务概念,可能由多个表组装而来。
- 传输对象(DTO):用于接口层和外部通信,字段是协议约定,可能需要对业务对象做脱敏、裁剪、格式化。
听起来像是多写了很多类,但换来的是每一层都可以安心做自己的事:数据层随便改表结构,只要在数据层内部做适配,业务层完全感知不到;接口层随便改协议,只要在装配器里调整映射,业务层也完全感知不到。
我自己最喜欢的一个规则是:“数据库表和对外协议都是不稳定的,而业务是相对稳定的。”所以业务对象是架构的核心,它应当保持干净——不继承任何框架基类、不挂任何框架注解、不强依赖ORM。
3.3 命名规范是团队共识的载体
t3code里对命名的约定也很有意思,不是那种教条式的“常量全大写”,而是围绕“边界”设计的:
- 接口层类名以
Controller、Request、Response、Assembler结尾,让人一眼知道这是协议相关的东西。 - 业务层类名以
Service、ServiceImpl、Model结尾,只描述业务概念,不出现技术名词。 - 数据层类名以
Repository、Mapper、PO结尾,让人一眼知道这是存取相关的东西。 - 方法命名也有讲究:接口层方法名贴着“动作”——
getUserInfo、createOrder;业务层方法名贴着“业务能力”——checkInventory、calculatePayableAmount;数据层方法名贴着“数据操作”——selectByUserId、insertBatch。
为什么这些细节重要?因为代码读得最多的场景不是“写的时候”,而是“三个月后回来维护的时候”。命名规范就是降低“重读成本”的最有效手段。试想一下,你打开一个类,只看类名就能判断它是哪层的、该不该在这里改业务逻辑,这是多么舒服的体验。
4. 实操过程:一个下单场景的完整走读
4.1 需求描述和初始分析
为了把这套实践讲透,我拿一个最常见的电商下单场景来做完整演示。需求很简单:用户提交订单,系统校验商品库存、锁定优惠券、计算金额、生成订单、扣减库存、标记优惠券已使用。
业务约束:
- 库存不足直接拒绝下单
- 优惠券只能使用一次
- 下单必须是原子操作,任何一步失败都回滚
如果你拿到这个需求直接开写,大概率会写出一个OrderController,里面注入OrderMapper、CouponMapper、InventoryMapper,然后一个方法把所有逻辑写完。这样做,功能能跑通,但这笔账会在三个月后加倍偿还。
按照t3code的思路,首先要画清楚“谁负责什么”:
- 接口层:接收创建订单请求,返回下单结果
- 业务层:校验库存、校验优惠券、计算金额、创建订单、扣减库存、标记优惠券
- 数据层:订单表、库存表、优惠券表的基本增删改查
4.2 接口层实现:只做翻译和校验
@RestController @RequestMapping("/api/orders") public class OrderController { private final OrderService orderService; private final OrderAssembler orderAssembler; public OrderController(OrderService orderService, OrderAssembler orderAssembler) { this.orderService = orderService; this.orderAssembler = orderAssembler; } @PostMapping public Result<CreateOrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest request) { // 翻译:请求对象 -> 业务对象 CreateOrderCommand command = orderAssembler.toCommand(request); // 调用业务层,只关心结果 OrderModel order = orderService.createOrder(command); // 翻译:业务对象 -> 响应对象 return Result.success(orderAssembler.toResponse(order)); } }这个Controller里没有一行业务逻辑。它做的事情只有三件:接收参数、调用业务层、返回结果。参数校验交给了@Valid和注解,这是接口层该干的事——协议层的事在协议层解决。
4.3 业务层实现:业务规则的唯一居所
@Service public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; private final InventoryRepository inventoryRepository; private final CouponRepository couponRepository; public OrderServiceImpl(OrderRepository orderRepository, InventoryRepository inventoryRepository, CouponRepository couponRepository) { this.orderRepository = orderRepository; this.inventoryRepository = inventoryRepository; this.couponRepository = couponRepository; } @Override @Transactional(rollbackFor = Exception.class) public OrderModel createOrder(CreateOrderCommand command) { // 1. 校验并锁定库存 InventoryModel inventory = inventoryRepository.findBySkuId(command.getSkuId()); if (inventory.getAvailable() < command.getQuantity()) { throw new InsufficientStockException("商品库存不足"); } inventory.lock(command.getQuantity()); inventoryRepository.update(inventory); // 2. 校验并锁定优惠券 CouponModel coupon = couponRepository.findById(command.getCouponId()); if (coupon.isUsed()) { throw new CouponAlreadyUsedException("优惠券已被使用"); } coupon.markUsed(); couponRepository.update(coupon); // 3. 计算实付金额 Money payableAmount = calculatePayableAmount(coupon, inventory.getPrice(), command.getQuantity()); // 4. 创建订单 OrderModel order = OrderModel.create(command.getUserId(), command.getSkuId(), command.getQuantity(), payableAmount); orderRepository.save(order); return order; } private Money calculatePayableAmount(CouponModel coupon, Money price, int quantity) { Money total = price.multiply(quantity); return coupon.apply(total); } }注意几个关键点。第一,所有的业务判断都写在业务层,接口层不会出现“库存不足”这种业务异常。第二,业务层操作的是数据层的接口(Repository),而不是直接操作Mapper——这就保证了数据层内部无论怎么改,业务层都不用动。第三,数据层返回的是领域模型(InventoryModel、CouponModel),不是数据库实体,业务层看不到任何ORM的痕迹。
4.4 数据层实现:安静做存取就好了
@Repository public class InventoryRepositoryImpl implements InventoryRepository { private final InventoryMapper inventoryMapper; public InventoryRepositoryImpl(InventoryMapper inventoryMapper) { this.inventoryMapper = inventoryMapper; } @Override public InventoryModel findBySkuId(String skuId) { InventoryPO po = inventoryMapper.selectBySkuId(skuId); return po == null ? null : po.toModel(); } @Override public void update(InventoryModel model) { inventoryMapper.update(model.toPO()); } }数据层实现的原则就一个字:笨。查询就查、更新就更新,不做判断、不抛业务异常。表结构变更的时候,只改这里;换ORM框架的时候,也只改这里。业务层对此一无所知。
4.5 一个细节:事务边界的匹配
事务放在业务层是刻意的。接口层做HTTP协议的事,不应参与事务;数据层做持久化的事,无法定义事务边界。只有业务层知道“一个完整的业务操作”从哪里开始、到哪里结束,所以@Transactional标注在OrderServiceImpl.createOrder上是最合适的选择。
还有一个经验:事务只加在需要事务的方法上,别图省事加在类上。一个类里如果只有一个方法需要事务,那就在那个方法上加注解;如果所有方法都需要,才考虑加在类级别。加在类上会让所有方法都事务化,包括那些只读查询——不仅慢,而且容易造成长事务、连接池耗尽之类的生产事故。
5. 常见问题和排查技巧实录
5.1 循环依赖怎么处理
三层架构刚开始落地的时候,最容易遇到的是循环依赖:OrderService依赖UserService,UserService又依赖OrderService。Spring Boot 2.6之后默认禁止循环依赖,启动直接报错。
先说结论:循环依赖的本质是模块边界没划清,而不是代码写错了。正确的解法是引入一个更高层的服务来做编排,或者把公共的部分抽到更底层。比如订单和用户互相依赖,那说明这两个模块之间有跨越边界的业务编排,应该抽一个OrderCoordinationService来处理这种跨模块流程,让原来的两个Service各司其职,不去互相依赖。
一个实用的排查技巧:遇到循环依赖时,不要急着加@Lazy糊弄过去,先画一下模块依赖图。如果依赖图里出现了环,那说明你的层次划分或者模块划分有地方是歪的——这个环就是架构坏味道的报警器。
5.2 懒加载导致的序列化异常
一个非常经典的坑:在接口层返回实体对象,实体对象里有一个懒加载的关联属性(比如订单关联的用户信息),接口层在做JSON序列化时触发懒加载,结果Session已经关闭,直接抛LazyInitializationException。
这个问题在t3code框架下其实很好解决——因为接口层根本不返回实体,而是返回DTO,DTO在业务层内部就已经组装好了,数据层什么关系、什么懒加载策略,和接口层完全无关。如果你正在被这个异常折磨,多半是因为你的Controller直接返回了ORM实体。解决方案不是调懒加载配置,而是老老实实引入DTO层,在业务层把需要的数据查好、组装好。
5.3 事务不生效,代码看着没问题但就是回滚不了
事务不生效的原因有很多,最常见的是这三种:
- 同类内部调用。
createOrder调用同一个类里的anotherTransactionalMethod,后者的@Transactional不生效,因为Spring事务代理是基于类的,内部调用直接绕过了代理。 - 方法不是
public。@Transactional只能对public方法生效,这是Spring官方文档明确写的,很多人忘了。 - 异常被吞了。自己写了
try-catch把异常吃掉,事务当然不会回滚。
排查思路也很简单:第一步看是不是private或内部调用,第二步看异常是不是被吞了,第三步看事务管理器配没配对。但按照t3code的规范,这类问题出现的频率会低很多——因为业务方法都在Service实现类里,都是public的,而且规范里明确了异常不能静默吞掉,必须向上抛,让最外层统一处理。
5.4 数据层查询性能差,排查路径怎么走
业务层和数据层分离之后,有一个容易被人误解的地方:是不是业务层不能用多表查询了?
答案是不是。多表查询完全可以放在数据层,数据层负责把查询结果组装成业务对象返回给业务层,这恰恰是数据层的职责。性能排查的通用路径是:先在数据层里看SQL执行计划,索引有没有命中、扫描行数多不多;再在业务层里看有没有明显的循环调用数据层,比如一个订单列表居然调用了N次用户查询——这就是经典的N+1问题,解决思路是批量查询,把N次查询合并成1次,尽最大努力避免在循环里查数据库。
6. 工程化落地:从一个人写作到一群人协作
6.1 先定规矩,再谈执行
t3code这套编码风格要想在团队里真正落地,光靠“建议大家遵守”是不够的,必须有强制手段。我见过最有效的组合是三件套:
- 代码规范文档:把上面这些边界、命名、分层规则写清楚,配正反例,挂在项目Wiki里。
- 代码审查Checklist:审查代码时明确问“这个Controller里有没有业务逻辑”“这个DTO是不是被数据层引用了”“这个Service是不是操作了Mapper”,把这些写成硬性检查项。
- 架构守护工具:用ArchUnit或者类似的架构测试框架,把分层规则固化成自动化测试,谁写坏了就立刻在CI里报红。
第三件是最狠的。比如我团队里就定过一条ArchUnit测试:Controller只能依赖Service和Assembler,不能依赖Mapper和Repository。这条测试跑在所有提交的前置检查里,只要有人违规,构建直接挂掉。
@Test void controllerShouldNotDependOnMapper() { JavaClasses classes = new ClassFileImporter().importPackages("com.company.project"); ArchRule rule = noClasses() .that().resideInAPackage("..controller..") .should().dependOnClassesThat().resideInAPackage("..mapper.."); rule.check(classes); }代码审查是人和人之间的协作,自动化测试是人和机器之间的协作。只有两者同时上,风格才不会随着人员流动而失传。
6.2 分支模型和提交规范
t3code虽然主要是编码风格,但它对开发流程也有连带要求。我这里只说两条最实用的:
分支模型不要搞得太复杂,main+feature两层就够了。所有开发在feature分支上做,做完合并回main,发布时从main切一个release分支出来,测试通过后打tag发布。比起那些develop/staging/release/hotfix满天飞的花哨模型,这种简单模型在中小团队里执行成本最低、出错的概率最小。
提交信息建议采用“前缀 + 描述”的格式:feat: 添加订单创建接口、fix: 修复库存扣减并发问题、refactor: 抽取优惠券计算逻辑。这条规则的意义在于,半年后看git log的时候,你能一眼看出来每次提交的意图是“加功能”还是“修问题”还是“重构代码”,这对于排查线上问题、做回归测试有非常大的帮助。
6.3 一次重构的推进建议
如果你的项目已经是一锅粥了,不要想着一次性把所有代码都按t3code重写一遍。我踩过这个坑,可以负责任地说:大爆炸式重构在绝大多数公司里都是灾难,因为业务一直在往前跑,你根本没有一个“静止窗口”去做这种规模的改造。
正确的路径是“绞杀者模式”:新的功能一律按照新规范来写,老的代码模块一个一个地分阶段改造。比如这个月目标是把用户模块拆清楚,下个月是订单模块,每个模块改完之后都要保证测试通过、功能不回退。这样三个月之后,你可能已经悄悄把系统里最核心的七八个模块都捋顺了,而且是在业务正常迭代的节奏里完成的。
每次改造,都坚持一个原则:动一层的时候,其他两层绝不动。在改数据层时,业务层和接口层的测试必须全部保持绿色,这证明抽象的边界是完好的。
7. 最后的几点实操心得
做了这么多年的编码实践,我自己的一个很深体会是:技术框架是容易变的,但层次划分的思想是稳定的。今天你用Spring Boot + MyBatis,明天可能换Quarkus + jOOQ,但是“接口层做翻译、业务层做决策、数据层做存取”这个边界,在任何技术栈里都成立。t3code最值钱的地方不是它写了多少样板代码,而是它把这层思想真正落成了可以执行的规则。
在项目里用这套实践,前期的确会觉得“贵”——多写了不少DTO和Model,多绕了不少层,但项目一旦超过半年、团队一旦超过三个人,这套结构的马太效应就开始显现了。新人的上手成本大幅度降低,因为他们不需要理解整个系统的全貌,只需要理解自己负责的这一层和相邻的一层接口。老系统的变更也变得可控,因为你知道每一次改动到底会影响到哪一层,不会出现“改一个字段、炸三个模块”的惨剧。
如果你也是一个被混乱代码折磨过的开发者,我建议你可以从最小的行动开始:挑一个你最熟悉的模块,按照上面的目录结构重新组织一下,把Controller里的业务逻辑挪到Service里,把Service里的SQL挪到Repository里,把实体类里不该有的JSON注解删掉。就做这么一件事,你就能直观感受到这套编码范式的威力。不需要等别人推行,不需要等公司发文,你自己的代码自己先写好,这就够了。