t3code 这个项目名,乍看像个开源框架的代号,其实它背后一直说的是后端开发里最朴素、也最容易被低估的问题:你怎么组织业务代码,才不至于让项目在半年后变成一锅粥。我在一线写代码十来年,见过太多项目一开始只有几十个接口,三个月后 Controller 里堆了上百行业务 SQL,Service 互相调用绕成环,DTO 满天飞,改一个字段要翻遍五六个文件。t3code 本质上就是一套我在多个项目里反复调整后沉淀下来的三层架构代码实践——Three-Tier Code,把表现层、业务层、数据访问层拆清楚,把依赖方向定死,把每一层之间数据流动的规则定明白。这篇博文就围绕 t3code 的完整落地过程展开,覆盖分层设计、代码组织、实操示例、问题排查和复杂场景演化,适合刚接触架构的后端开发,也适合被现有代码折磨过、想重新理清模块边界的老手。
1. 整体设计拆解:三层架构到底解决什么问题
1.1 一个让所有后端都头疼的场景
先抛一个场景:需求方说下单时要检查库存、扣减库存、生成订单、发一条通知。新手最容易写成什么?一个createOrder方法搞定,先查库存,再UPDATE库存表,再INSERT订单表,再调消息服务。起初确实跑得通,但后面问题会一个个浮上来:下单逻辑里突然插入了一段短信发送代码,支付接口也想复用库存扣减,被迫把这部分复制粘贴过去;接口返回结构一会儿是Map,一会儿是裸对象,前端对接全靠猜;业务规则和 SQL 混在一起,DBA 想优化一条慢查询,得从 Controller 一路查到 Mapper。
这类问题的根子不在某个人的编码水平,而在于代码没有边界。t3code 给出的解法非常朴素:把系统纵向切成三块——表现层、业务层、数据访问层,每一层只允许做自己该做的事,并且强制规定依赖方向只能从上往下。这样设计的好处,最直接的一点就是改动局部化。数据库表结构变了,多半只动数据层;前端字段调整,多半只动表现层;真正核心的业务规则,在中间业务层被保护起来,不会被两边频繁变化的需求波及。
还有一种更隐蔽的价值,是团队协作时的“免沟通区”。两个人同时改订单模块,一个专注写 Controller 和参数校验,另一个专注写 Service 里的下单规则,只要接口签名提前约定好,代码冲突概率大幅下降。对新人来说,看到一个类就知道它属于哪一层、能干什么、不能干什么,上手成本也低很多。
1.2 三层各管一段,边界画清楚
先明确一下三层各自的职责,避免出现“我知道要分层,但不知道‘什么代码放哪层’”的尴尬。
表现层平时也叫接口层、Controller 层,它管的事情就四件:接收参数、做最基础的格式校验、调用业务层、把结果组装成响应对象。这里要特别强调一个原则:表现层不应该出现任何业务规则。比如“下单时库存不足返回 500”这种判断,就不该写在 Controller 里,Controller 只需要知道调用成功还是抛了异常;至于库存是否足够、要不要回滚,那是业务层的工作。
业务层是 t3code 的心脏。它负责用例编排、业务规则校验、事务控制、跨数据源的协调。同样拿下单举例,业务层要做的是:先校验参数(比如商品是否下架)、再查库存、再扣库存、再生成订单、再发送通知,整个过程包在一个事务里。业务规则都收拢在这里,最大的好处是可以脱离 HTTP 和数据库单独测试——直接 new 一个 Service 出来,传一个假的 Repository,就能把下单流程跑通。
数据访问层最纯粹,就是跟数据库打交道:定义 Entity、写 Repository、处理 SQL 映射、管理基础的数据读写。注意,这层不该有业务判断,也不该有事务逻辑,事务通常开在业务层,数据层只负责执行。我经常跟团队说,把数据层想象成一个“数据快递员”,它只负责准确取货、送货,至于这批货用来干什么,不归它管。
这样切完之后,整个系统的数据流向就固定下来:表现层接收外部请求,转成业务层认识的 DTO,业务层处理完再让数据层落库,反过来查询的时候原路返回。每一层都只跟紧邻的下一层打交道,不会出现 Controller 直接注入 Repository、Service 里拼 HTML 之类的事。这个规定看着死板,但实际写起来反而轻松,因为每写一个方法,你根本不用思考“这个方法该放哪”,位置是天然的。
2. 核心细节解析:层与层之间怎么配合
2.1 先定目录,再写代码
很多项目分层失败的真正原因,不是没有三层,而是目录结构没定清楚,最后层和模块全搅在一起。t3code 目录设计有个推荐套路:先按业务模块拆,再按技术层次分,而且每一层都用独立的后缀或包围名标清楚。
一个典型的后端项目结构长这样:
com/company/project ├── controller # 表现层 │ ├── OrderController.java │ └── request/ # 入参对象 │ └── response/ # 出参对象 ├── service # 业务层 │ ├── OrderService.java │ ├── impl/ │ └── dto/ # 层间传输对象 ├── repository # 数据访问层 │ ├── entity/ │ └── mapper/ └── common # 跨层通用件(异常、常量、工具类)很多人会纠结:service/impl这个目录要不要保留?我的经验是,如果 Service 只有一个实现,没必要抽接口,直接一个类就行,抽了反而是负担;只有当同一业务有多个实现策略,或者明确要为测试替换实现时,再考虑接口加实现。后面细讲。
模块维度上,建议按业务域组织,比如订单、用户、支付各自一个模块,模块内再分 controller/service/repository。这样当订单模块膨胀到上千个文件时,你能快速定位,不至于在一个巨大的 controller 目录里翻几百个文件。按层打包也不是完全不行,小项目里反而更直观,但项目一大就要靠文件名猜归属,体验很差。
还有个细节容易忽略:common 目录要克制。跨层通用的异常类、返回值包装类可以放,但不要什么都往里塞。我在项目里见过common/util里躺着十几个工具类,结果所有层都依赖它,任何工具类改动都导致全部模块重新编译,这不是分层,是制造耦合。
2.2 Entity、DTO、VO:三层各自的数据载体
t3code 里最容易吵起来的,就是“数据对象怎么定义”。我见过最省事的做法是直接在 Controller 里返回 Entity,让 ORM 框架把数据库表结构直接序列化成 JSON 给前端。这个做法早期很爽,后期很痛:表里的字段名暴露给前端,任何数据库结构调整都直接影响接口;某些大字段或内部状态也裸奔出去,安全性和可维护性都有问题。
所以 t3code 定了三个清晰的数据载体:
Entity是数据层专用的,跟表结构一一对应,一个字段对应一个列名,不掺任何业务含义。它应该只活在 Repository 和 Service 之间,最底线是不能直接传给 Controller 返回给前端。
DTO是业务层的数据传输载体。它描述一次业务交互中需要哪些数据,字段可能来自多张表,也可能是计算后的结果。它存在的意义是让业务层的入参和出参稳定,不随表结构变化。举个例子,下单接口需要的CreateOrderDTO,里面可能有userId、skuId、quantity、addressId,这跟底层订单表的字段并非一一对应,DTO 把这些业务含义明确下来。
VO是表现层的视图对象,专门用来定义接口返回给前端的结构。VO 可以按前端需要做字段裁剪,比如把 BigDecimal 金额格式化成字符串,把时间戳转成指定格式,或者把订单明细嵌套进去。简单说,Entity 是给数据库看的,DTO 是给业务看的,VO 是给前端看的。
很多项目死在转换代码的繁琐上。我的建议是:不要指望框架自动映射解决一切。像 BeanUtils 拷贝字段确实简单,但字段名一旦不一致,或者源对象和目标对象的字段类型有细微差别,就会产生运行时错误。在关键链路上,手写转换逻辑更可靠,哪怕多几行代码,至少编译期能发现问题;非关键路径再用映射工具提速。另外,层间转换放在哪一层也有讲究,我习惯放在 Service 的边界处,Controller 拿到的就是可直接返回的 VO,Service 负责把 Entity 组装成 VO 或把入参 DTO 转换成业务对象,这样数据层完全不知道外部长什么样。
2.3 依赖注入:让上层拿接口而不是找实现
三层架构里最常见的一个反问是:为什么 Controller 不直接 new 一个 Service?这就要回到依赖管理了。如果 Controller 直接new OrderService(),那么 Service 的一切内部依赖(比如它依赖的 Repository)都要由 Controller 来创建,这一层层的创建关系会像滚雪球一样越滚越大,最后所有类都在互相 new,没人分得清谁该负责谁的生命周期。
t3code 的做法是统一用依赖注入容器管理对象生命周期。Controller 只声明自己需要一个OrderService,Spring 之类的容器负责在启动时找到实现并塞进来;Service 只声明自己需要一个OrderRepository,容器再负责注入。这样每一层都只知道“我需要什么”,不关心“这个东西怎么来的”。写代码的人唯一要遵守的规则是:通过接口或类构造器接收依赖,不要自己去 new 依赖对象。
具体到实现方式,一定优先选构造器注入。它的好处很明显:对象被创建时依赖一定齐了,不存在“先 new 出来再慢慢 set 依赖”的半初始化状态;单元测试时直接 new 一个 Service,把假 Repository 传进去就行,不需要启动整个容器;还能直观看出一个类依赖了多少东西,如果构造器参数超过五六个,通常意味着这个类职责过重,该拆分。
还有一个经验之谈:不是每个 Service 都要先写接口再写实现。如果只有一个实现,而且没有可预见的第二种实现,那直接公开类、让容器注入类即可。强制给所有类制造接口,只会消耗额外的维护成本,让代码结构看起来“规范”但实际全是噪音。什么时候需要接口?当这个 Service 有多套实现策略(比如订单价格计算有普通用户价和会员价)、或者要方便测试替换真实依赖时,再抽接口,其余情况保持简单。
3. 实操过程:用 t3code 风格写一个订单模块
讲了这么多原则,不如直接看一个完整的订单模块怎么落地。下面的代码示例基于 Java 生态和 Spring Boot,但思路迁移到其他语言是一样的。
3.1 数据层:先跟表结构对齐
假设有一张订单主表:
CREATE TABLE `orders` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `user_id` BIGINT NOT NULL, `sku_id` BIGINT NOT NULL, `quantity` INT NOT NULL, `total_amount` DECIMAL(12, 2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL, `updated_at` DATETIME NOT NULL );比较讲究的做法是,Entity 跟表结构严格对齐,字段名保持数据库公共风格,不在这一层做任何业务命名上的美化。
public class OrderEntity { private Long id; private Long userId; private Long skuId; private Integer quantity; private BigDecimal totalAmount; private Integer status; private Integer version; private LocalDateTime createdAt; private LocalDateTime updatedAt; // getter / setter 省略 }Repository 只做基础数据操作,比如根据 ID 查一条、根据条件分页查、插入、更新。不要把一套“查订单且统计用户积分且返回前端展示名”的逻辑写在这里,那属于业务层组装的事情。
@Repository public class OrderRepository { // 这里使用 MyBatis-Plus 或 JdbcTemplate 均可,核心是方法只做一件事 public OrderEntity findById(Long id) { // 执行 SQL: select * from orders where id = ? } public OrderEntity findByLockVersion(Long id, Integer version) { // 执行 SQL: select * from orders where id = ? and version = ? } public int insert(OrderEntity entity) { // 执行 insert,返回影响行数 } public int updateWithVersion(OrderEntity entity) { // 执行 update ... where id = ? and version = ? } }这里有两个实操要点值得单独说。
第一点是乐观锁实现。表单里故意留了version字段,这是做并发控制最常见的手段。扣减库存时,不要只执行UPDATE stock SET quantity = quantity - 1 WHERE sku_id = ?,那行写法在并发下会丢更新。正确姿势是先查出当前 version,更新时在 SQL 的 where 条件里带上 version,同时把 version+1,如果影响行数为 0,说明数据被其他人改过了,业务层再决定重试还是报错。这笔账算下来不会亏,代价不过是一个字段、一条 where 条件,却能把最棘手的并发问题挡住一半。
第二点是批量操作的取舍。数据层里尽量避免在 for 循环里单条插入,那会产生大量小事务和网络往返。要么用批量插入 SQL,要么用事务里的分批提交。当然,这也要看数据库连接池够不够用,不能一概而论。我的习惯是,单次操作在 100 条以内且接口流量不大时,直接循环也没啥问题;一旦数据量上来,优先用批量接口。
3.2 业务层:把规则和服务放在这里
业务层是订单模块里代码量最大、也最需要小心设计的地方。先看一个完整的下单流程:
@Service public class OrderService { private final OrderRepository orderRepository; private final StockRepository stockRepository; private final UserRepository userRepository; public OrderService(OrderRepository orderRepository, StockRepository stockRepository, UserRepository userRepository) { this.orderRepository = orderRepository; this.stockRepository = stockRepository; this.userRepository = userRepository; } @Transactional public OrderVO createOrder(CreateOrderDTO dto) { // 1. 业务校验 UserEntity user = userRepository.findById(dto.getUserId()); if (user == null || user.getStatus() != 1) { throw new BizException("用户不可用"); } // 2. 扣减库存(带乐观锁) boolean deducted = stockRepository.deductStock(dto.getSkuId(), dto.getQuantity()); if (!deducted) { throw new BizException("库存不足"); } // 3. 构建订单 OrderEntity order = new OrderEntity(); order.setUserId(dto.getUserId()); order.setSkuId(dto.getSkuId()); order.setQuantity(dto.getQuantity()); order.setTotalAmount(calculateAmount(dto)); order.setStatus(0); order.setVersion(0); orderRepository.insert(order); // 4. 返回 VO return toVO(order); } private BigDecimal calculateAmount(CreateOrderDTO dto) { // 价格计算规则,可能涉及会员折扣、优惠券等 return BigDecimal.valueOf(100).multiply(BigDecimal.valueOf(dto.getQuantity())); } private OrderVO toVO(OrderEntity order) { OrderVO vo = new OrderVO(); vo.setOrderId(order.getId()); vo.setStatus(order.getStatus()); return vo; } }注意几个决定成败的细节。
事务注解要出现在业务层,而不是数据层或表现层。为什么?因为事务的本质是“多个数据操作要么全部成功、要么全部回滚”,而这个“多个操作”的编排逻辑是由业务层负责的。如果事务开在数据层,那一次下单涉及库存扣减和订单插入两个操作,就没办法组成一个原子操作;如果事务开在表现层,那一切 HTTP 请求都包在事务里,数据库连接会被长时间占用,同时 Controller 里的非业务代码也被强行纳入事务,完全没道理。所以 t3code 的规律是:事务和业务用例同生共死。
再就是业务层不要出现具体 SQL。有人会在 Service 里 JdbcTemplate 一把梭,查库存、扣库存、更新订单全写完了。这样做的唯一好处是少写几个类,坏处是数据库变更必须连带改业务代码,测试也更难做。把 SQL 收拢到 Repository 之后,业务层真正关注的只有业务规则和流程编排,代码读起来像在讲一个用例故事,而不是在看执行计划。
业务层还承担着一个很关键的任务:定义异常和业务状态。比如库存不足、用户被禁用、订单状态不允许取消,都不应该通过返回 null 或者用布尔值悄悄表达。我习惯在业务层抛出明确的业务异常,由表现层统一翻译成 HTTP 状态码和错误信息。这样业务层测试时可以直接断言抛出了什么异常,比断言一个魔法值可靠得多。
3.3 表现层:入口拿参、出口给响应
表现层代码的核心是“薄”。看一个 Controller 模板:
@RestController @RequestMapping("/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping public ApiResponse<OrderVO> create(@RequestBody @Valid CreateOrderRequest request) { CreateOrderDTO dto = new CreateOrderDTO(); dto.setUserId(request.getUserId()); dto.setSkuId(request.getSkuId()); dto.setQuantity(request.getQuantity()); OrderVO vo = orderService.createOrder(dto); return ApiResponse.success(vo); } @GetMapping("/{id}") public ApiResponse<OrderVO> getById(@PathVariable Long id) { return ApiResponse.success(orderService.getOrderById(id)); } }CreateOrderRequest是专门用来接收前端 JSON 参数的入参对象,上面带着校验注解,比如@NotNull、@Min(1)之类。这样做的好处是,校验失败会在进入业务层之前就拦住,业务层不需要重复判断参数是不是 null。不过也别指望注解校验能覆盖所有规则——像“库存不足”“商品已下架”这种依赖数据库状态的校验,必须留在业务层。一句话总结:能静态判断的校验放表现层,动态判断的业务规则放业务层。
响应体统一用ApiResponse包装,固定成code、message、data三段,再配合一个全局异常处理器把业务异常翻译成对应状态码。异常处理这块要强调:不要用异常控制业务正常流程。比如用户取消订单,如果取消成功,就正常返回;如果不允许取消,抛业务异常没问题。但不要把“执行成功与否”寄托在捕获某个特定异常上——比如用 try-catch 包裹stockRepository.deductStock后靠异常来判断库存不足,代码可读性极差。用返回值判断状态,比用异常跳转要清晰得多。
3.4 全链路:一次下单请求如何走完三层
串一遍整个调用链路,你就能直观感受到分层的价值。
前端 POST/orders,请求 JSON 先落到OrderController.create;Controller 从请求体里取出参数,手动组装成CreateOrderDTO,交给OrderService.createOrder;Service 先查用户状态,再调StockRepository.deductStock扣库存,扣成功就构建OrderEntity通过OrderRepository.insert落库;最后把OrderEntity转换成OrderVO返回;Controller 拿到OrderVO后包一层ApiResponse响应给前端。
这条链路上有几处“边界关卡”值得留意。Controller 和 Service 之间,DTO 的转换是双向下棋的地方,Controller 接收前端入参,Service 决定业务数据怎么流转。Service 和 Repository 之间,Entity 是唯一允许出现的对象,业务层不应该把 VO 往下传,数据层也不应该把数据库字段原样抛到上层。任何一个环节出现跨层对象穿越,比如直接把 Entity 返回给前端、或者 Repository 方法签名里出现CreateOrderRequest,都应该视为设计走样。
有人会问:这样多转几次对象,性能会不会变差?我的回答是,这种转换的开销在现代应用里几乎可以忽略,而它换来的解耦价值极高。真正需要担心性能的,是数据库的 N+1 查询、大事务、锁等待这些,而不是几个对象的字段拷贝。业务代码优先写清晰,等到压力测试真发现这里有问题,再针对性优化也不迟。
4. 常见问题与排查技巧实录
4.1 Entity 穿透三层,序列化炸在脸上
这是团队里最容易出现的问题。一开始为了省事,Controller 直接return orderRepository.findById(1),前端拿到订单表所有字段,包括version、updatedAt这些不该暴露的东西。后来数据库加了一个内部备注字段,前端联调时发现响应里多出一个字段,或者因为 Entity 里加了某个非数据库字段,导致 JSON 序列化死循环,接口直接 500。
这类问题的排查其实不难:只要一看到 Controller 的返回类型是 Entity,或者 Entity 类里出现了@JsonIgnore、@JsonProperty这类注解,就说明分层边界破了。纠正办法就是把返回类型改成 VO,并且在代码评审里把这条作为红线。我个人还会在 Entity 类注释里写一句:“本类禁止直接出现在 Controller 返回值中。”这个提示在团队协作里比想象中管用。
4.2 事务开在业务层,却控制不住数据层连接
有次排查一个线上故障:高峰期数据库连接被打满。一看日志,发现某个 Service 方法上有@Transactional,里面又循环调了几十次 Repository 逐个插入,导致一个请求占着数据库连接长达好几秒,流量一大,连接池瞬间耗尽。事务本身没错,错的是事务范围失控。
排查思路很简单:先看事务注解标在哪里,再看方法体内有没有网络请求、循环写操作、远程调用。如果事务方法里有调用其他系统的 HTTP 请求,那就更要命了——事务会一直攥着数据库连接等待远端响应。正确姿势是:事务只覆盖真正需要原子性的数据库操作,远程调用尽量放到事务提交之后再执行;批量写入要么分成合理的批次,要么改用真正的批量 SQL。
这类问题通常不在代码评审阶段暴露,得等线上性能指标出现异常才能发现。我建议项目里做一次系统性的“事务边界审查”,把每个@Transactional方法列出来,看方法里到底做了什么。这个表做出来后,你会对业务逻辑的健康程度一目了然。
4.3 异常层层包装,日志里全是噪音
刚开始建项目时,大家喜欢在每层 catch 一遍异常再重新抛个新异常:数据层抛DataAccessException,业务层 catch 后包装成BizException,Controller 又 catch 一遍记日志再抛ApiException。结果一条 SQL 超时,日志里出现三四条堆栈,真正的根因埋在最底下,排查效率极低。
我的建议是:分层确实可以定义不同的异常类型,但不要每层都做机械包装。数据层抛出技术异常原样上抛,业务层把技术异常转成业务异常时,一定要把原始异常作为 cause 传进去;表现层不要重复打印堆栈,统一交给全局异常处理器记录一次。这样一条异常链路从后到前只有一个出口、一次记录,日志干净得多。
排查时如果发现日志里同一个异常出现多次堆栈,基本就能断定存在多重包装。这时你需要做的不是补齐更多 catch 块,而是删掉中间层那些无意义的 catch。
4.4 排查速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| Controller 返回了数据库多余字段 | Entity 直接当响应对象 | 改成 VO,切断跨层返回 |
| 一个请求数据库连接被占很久 | 事务范围过大,包含远程调用或大批量循环写 | 缩小事务边界,远程调用移到事务外 |
| 日志出现重复堆栈 | 多层 catch 重复包装异常 | 清理不必要的 catch,保留全局异常出口 |
| 扣库存后数据不一致 | 乐观锁未实现或事务边界不对 | 检查 version 字段更新逻辑和事务覆盖范围 |
| Service 方法参数巨多 | 该类职责过重 | 拆分 Service 或引入 DTO 聚合参数 |
这张表是我在项目复盘时常用的检查清单,每次代码重构后都会过一遍。它不能解决所有问题,但能快速把大多数分层代码的“病灶”定位出来。
5. 复杂项目里还能怎么演化
5.1 加一条事件通道,解耦跨层通知
三层架构最常被诟病的一点是:业务层要发短信、发消息、写日志时,总免不了直接调用别的服务,导致业务层依赖越来越重。一个很自然的演化,就是引入领域事件。
具体做法是:业务层只负责把“订单已创建”这个事实发布成一个事件对象,由事件总线或消息队列异步消费。发送短信、同步搜索索引、触发优惠券计算,都变成事件的订阅方。这个做法的核心收益是,业务层的“下单用例”不再关心到底有多少后续动作,后续加一个“下单后赠送积分”,业务层不用改一行代码,只新增一个监听器即可。
这个演化属于 t3code 的自然延伸:分层解决的是纵向边界,事件解决的是横向解耦。但注意不要一上来就上 Kafka、RocketMQ,小项目用个进程内事件总线就够了。只有确定需要跨服务、高可靠消息时才引入消息中间件,否则那是给自己找运维负担。
5.2 业务复杂到一定程度,分层的下一站是什么
项目做到两三年,业务规则越来越多,你会感受到纯三层架构的一些吃力:订单状态流转散落在多个 Service 方法里;价格计算规则越来越长,Service 膨胀成上千行的“上帝类”。这时候可以考虑往领域驱动设计(DDD)的方向调整。
DDD 和三层架构并不冲突,它更像是把原来业务层继续细分:引入领域对象、领域服务、仓储接口。原来业务层里的“业务规则”下沉到领域对象里,业务层本身变成用例编排层。比如订单的“取消”操作,原来可能是 OrderService 里一个方法做一堆 if 判断,在 DDD 下可以在 Order 聚合根上定义一个cancel()方法,由 Order 自己维护状态转移规则。这套玩法需要一定的建模功底,但和 t3code 的核心理念一致——都是让规则和数据按边界归位。
如果你的项目出现了“一个 Service 方法超过两百行,且里面大量 if 判断订单状态”的情况,就该考虑往 DDD 方向重构了。不过我也要泼一盆冷水:不要为了 DDD 而 DDD,如果你的业务就是简单 CRUD,勉强引入聚合、值对象只会制造无谓复杂度。t3code 的三层是地基,DDD 是在地基之上按需加盖。
5.3 微服务拆分后的分层长什么样
把单体拆成微服务后,很多人误以为三层架构就没用了。实际恰恰相反,每个微服务内部几乎都还是三层结构,只是三层的职责边界发生了变化。
数据访问层彻底收归本服务,不再允许跨服务访问别人的表;表现层变成了给对方服务暴露的 OpenAPI 接口;业务层不仅要处理本服务内部规则,还要编排对其他服务的调用。这时候需要谨慎处理的是“分布式事务”问题,纯三层时代的一个事务,在微服务里变成了跨服务的多步操作,原来的@Transactional只管得了本地数据库。常见的补偿思路是本地消息表、事务消息、Saga 模式,这些都可以看作是 t3code 中“业务层编排”职责的外延。
有种更极端的做法叫 “bff” 模式,专门搞一层聚合层,对外提供大而全的接口,对内调用各个微服务。说白了,这一层本质上还是个表现层,只是在旧的 Controller 之上又套了一层“门面”。所以不用被“微服务”三个字吓住,分层的思想内核没变,变的只是边界的粒度和位置。
我自己把这些年做 t3code 的经验浓缩成一句话:分层的本质不是规定代码放哪个包,而是控制依赖的方向。只要依赖方向是稳定的、数据载体是清晰的、事务和异常边界是明确的,哪怕你的目录命名跟我的不完全一样,这个项目的底座就是稳的。真到了线上出了问题,你翻开日志能快速定位到层,改起代码来敢说“我只动这一块”,这就是 t3code 带给我最大的安全感。