1. T3 Code到底是什么,别把它当成多一个文件夹
很多团队一提到“T3 Code”——也就是三层架构(Three-Tier Architecture)下的编码实践,第一反应就是:Controller、Service、DAO各建一个包,目录分层就完事了。我在不少项目里见过这种理解,结果是代码确实分了三个文件夹,但业务逻辑还是糊成一团,Controller里塞了上千行,Service里全是SQL拼串,DAO层反倒成了摆设。这个现象太典型了,所以我想先花点篇幅说清楚T3 Code到底在解决什么问题。
三层架构的核心从来不是目录长什么样,而是一条依赖纪律。表现层只负责接收输入和返回结果,业务层只负责业务规则和流程编排,数据层只负责跟数据库打交道。每一层的代码只能依赖下一层,不能跨层调用、不能回头依赖、更不能把职责互相渗透。换句话说,这不是“多建几个包”的事,而是让团队在写每一行代码的时候都清楚地知道:这行代码属于哪个层,它该不该出现在这里。
我最早接触三层架构是七八年前做传统企业级项目的时候,那时候还没有Spring Boot,用的是Spring MVC加MyBatis,分层是强制性的。说实话,当时没觉得分层多厉害,反而觉得麻烦:一个简单的增删改查要写三层类,还要各写各的方法,代码量翻倍。但后来接手了一个没有分层的遗留系统,一个方法里从页面参数解析到JDBC连接全部干完,改一个字段要顺着调用链翻十几个地方,我才意识到,三层架构保护的不是写代码的人,而是后面维护代码的人,也包括三个月后的自己。
T3 Code适合谁参考?我觉得主要两类人。一类是刚入行、正在学Spring Boot或者其他Web框架的同学,你需要一个标准答案来建立代码边界感;另一类是团队里负责技术规范的人,你需要在“过度设计”和“一把梭”之间找一条可落地的线。这篇文章会围绕三层如何拆分、每层怎么写出花、事务和异常怎么处理、以及我踩过的坑来展开,尽量把实际项目里能碰到的问题都聊一遍。
2. 每一层该怎么拆,落代码前先过一遍脑
2.1 表现层:只做翻译,不做计算
表现层的核心职责是两件事:把用户的输入变成业务层能理解的对象,把业务层的结果变成用户能看的格式。我常跟团队说一句话:Controller里面不要出现if/else,不要出现任何业务判断,不要出现new一个业务对象然后手动set一堆字段这种操作。如果一段逻辑在Controller里没有三行以上,大概率是放错位置了。
实际操作中,Controller应该做的事情非常机械:
- 接收HTTP请求,解析参数,做基础的格式校验(比如必填项、长度限制、枚举合法性)
- 调用一个Service方法,传参尽量用单个DTO或BO对象,不要三五个离散参数满天飞
- 把Service返回的数据组装成VO或Response对象返回给前端
如果你发现Controller里开始出现“根据用户类型判断调哪个Service”、“计算某个金额再传给Service”这类逻辑,那就要警惕了。表现层一旦开始做业务决策,后续前端需求一变,改的就是Controller,而Controller是暴露给外部的门面,改动成本远高于内部层。
2.2 业务层:业务逻辑的家,但不是万能垃圾桶
业务层是T3 Code里最复杂的一层,也是团队分歧最大的地方。我说一个常见的现象:Service里所有方法都喜欢以save、update、delete命名,方法体里就是一堆数据存取。这种写法不是错,但它把业务层降级成了数据层的壳,业务规则全散落在Controller或者SQL里。
真正的业务层应该负责三块内容:
- 业务规则:比如下单时要校验库存、要计算优惠、要判断用户等级
- 流程编排:先调哪个仓储方法、后调哪个仓储方法、是否需要事务
- 事务边界:哪些操作必须同生共死,哪些操作允许部分失败
举个例子,下单这个操作。数据层只需要提供“查库存”“扣库存”“创建订单”“写入订单明细”这几个原子能力。业务层要做的是:先查库存够不够,够则扣减,再创建订单主表和明细表,最后返回订单号。这个编排过程如果放在Controller里,那多个入口(Web端、App端、批量脚本)各自实现一套,逻辑必然漂移;如果放在数据层里,那数据层就被迫理解“下单”这个业务概念,复用性也废了。
业务层的命名也是一个经验点。我一般不用save这种万能动词,而是用业务动词:createOrder、cancelOrder、updateShippingAddress。这样一眼就能看出这个方法是干嘛的,也好写单元测试。你想想,测试人员看到一个createOrder方法,很自然就能列出测试用例:库存不足、库存刚好、重复提交、优惠金额为负……但如果叫save,还得翻方法体才知道它在干什么。
2.3 数据层:老老实实跟数据库打交道
数据层的职责最简单也最容易跑偏:只做数据的读写,不做业务判断。Repository或DAO里面就应该是findById、selectByCondition、insert、updateStatus这类方法。
我在实践中的一个习惯是,数据层的方法命名尽量以SQL语义来定,而不是业务语义。比如不要叫checkInventoryAndDeduct,而是拆成selectStockForUpdate和deductStock。为什么?因为业务层可能需要“查库存”而不一定要“扣库存”,比如预校验场景。如果数据层把业务动作写死在方法里,业务层就被数据层的设计绑架了。
还有一个容易忽略的点:数据层的参数对象尽量使用专门的Query对象或DTO,不要直接把业务层的BO往Repository里传,更不要传实体类让SQL去猜。一来是解耦,二来是防止意外更新。我见过太多代码在Repository里updateById(entity),然后entity里一个不小心带了创建时间字段,把创建时间也给改了。用专门的更新字段对象就能从结构上避免这种低级事故。
3. 把一个订单模块跑通:完整实操记录
3.1 先定边界,再写代码
很多人写三层代码容易陷入“先建包后写类”的惯性,但正确的姿势是先定边界。我拿一个最典型的订单模块举例,这个模块在电商项目里几乎人人都会碰到。开始编码前,我会先写一份简单的边界清单,贴在IDE的TODO里,或者在接口文档里写清楚:
- 表现层接口:POST /api/orders,入参为下单请求体,出参为订单号和总金额
- 业务层动作:校验用户状态、校验库存、计算应付金额、创建订单、扣减库存、记录日志
- 数据层原子操作:查询商品库存、扣减库存、插入订单主表、插入订单明细表、插入操作日志表
这份清单不需要写得多详细,但能保证写代码时每个方法都知道自己该放在哪一层。等这些边界确定了,再开始建类、写方法,思路会顺畅很多。很多项目代码乱,根源不是不会分层,而是跳过了这个“定边界”的环节,直接开写,最后哪里顺手就写在哪里。
3.2 从用户点击到数据落库,一次完整请求是怎么穿过三层的
咱们顺着一次真实的请求走一遍。用户在前端点了“提交订单”,前端POST一个JSON到/api/orders,这个请求进入表现层。Controller先做一个基础校验:用户ID是否存在、商品列表是否为空、收货地址是否填写。注意,这里只做“格式和必填”校验,不做“库存够不够”这种业务校验,因为那属于业务层的事。
然后Controller调用订单Service的createOrder(CreateOrderRequest request)方法。业务层开始干活:
- 根据用户ID查询用户状态,如果被禁用直接抛异常
- 遍历商品列表,逐一从数据层查询库存,用数据库行锁或乐观锁保证一致性
- 逐个判断库存是否充足,不足则直接中断并返回提示
- 计算商品总价、优惠金额、应付金额
- 生成订单号,组装订单主表和明细表对象
- 调用数据层插入订单,再扣减库存
- 记录一条操作日志
事务就挂在业务层这个方法的@Transactional上。也就是说,从第一步到最后一步,任何异常都会导致前面所有数据操作回滚。这一步是三层架构里最简单的部分,但也是最关键的部分:事务边界只在业务层,表现层不开启事务,数据层不自己提交事务。
最后把订单号、应付金额和状态封装成VO返回给Controller,Controller包装成标准的JSON响应。整个流程下来,每一层都只干自己的事:Controller没碰库存,Service没拼SQL,Repository没写任何if/else。
3.3 各层之间传什么,DTO/VO/BO怎么区分
这是T3 Code实践中最让人纠结的细节,没有之一。我见过一个项目里DTO、VO、DO、BO四处乱飞,同样的字段在四个类里各写一遍,还经常漏字段。我的做法是,不同层之间传不同类型的对象,但不要让这个规则变成负担。
我常用的约定是这样的:
- 表现层接收前端参数,用DTO(比如
CreateOrderRequest),它代表“外界想让我做什么” - 表现层返回给前端的数据,用VO(比如
OrderVO),它代表“我做完之后你应该看到什么” - 业务层内部流转的数据,用BO(比如
OrderBO),它代表“业务视角下的订单全貌” - 数据层操作数据库,用实体类(比如
OrderDO)或Query对象
层与层之间的转换放在哪里?我推荐放在适配器里而不是业务方法内部。比如业务层的createOrder接收DTO还是BO?我的习惯是接收DTO,因为Controller直接传入,省一层转换;内部如果复杂,再转成BO。Repository接收BO还是DO?我习惯是Repository自己把BO拆分成DO或参数对象,不要让业务层去组装查询参数。
很多争议其实都是“对象怎么命名”的表面问题,本质问题是边界感。只要每个对象都有清晰的用途和转换时机,叫什么都行。但一旦你发现一个对象同时被前端、业务、数据三层使用,那就是危险的信号了。
3.4 代码示例:一张订单业务核心链路
这里我给出一个简化版的Service方法骨架,不是完整代码,但足够展示业务层的编排感。以Java为例:
@Transactional(rollbackFor = Exception.class) public CreateOrderResponse createOrder(CreateOrderRequest request) { // 1. 查询用户信息 UserDO user = userRepository.findById(request.getUserId()); if (user == null || user.getStatus() == UserStatus.DISABLED) { throw new BizException("用户不存在或已被禁用"); } // 2. 查询商品库存,并做扣减(此处用乐观锁或行锁保证并发安全) List<ItemQuery> items = request.getItems(); List<OrderItemBO> orderItems = new ArrayList<>(); BigDecimal totalAmount = BigDecimal.ZERO; for (ItemQuery itemQuery : items) { ProductStockDO stock = productRepository.findStockForUpdate( itemQuery.getSkuId()); if (stock.getAvailable() < itemQuery.getQuantity()) { throw new BizException("商品库存不足: " + itemQuery.getSkuId()); } productRepository.deductStock(itemQuery.getSkuId(), itemQuery.getQuantity()); OrderItemBO orderItem = new OrderItemBO(); orderItem.setSkuId(itemQuery.getSkuId()); orderItem.setQuantity(itemQuery.getQuantity()); orderItem.setPrice(stock.getPrice()); orderItems.add(orderItem); totalAmount = totalAmount.add(stock.getPrice() .multiply(BigDecimal.valueOf(itemQuery.getQuantity()))); } // 3. 计算优惠并生成订单 BigDecimal discount = calculateDiscount(user, totalAmount); BigDecimal payableAmount = totalAmount.subtract(discount); String orderNo = generateOrderNo(); OrderDO order = new OrderDO(); order.setOrderNo(orderNo); order.setUserId(user.getId()); order.setTotalAmount(totalAmount); order.setPayableAmount(payableAmount); order.setStatus(OrderStatus.CREATED); orderRepository.insert(order); orderItemRepository.batchInsert(order.getId(), orderItems); // 4. 记录日志并返回 operationLogRepository.log(user.getId(), "createOrder", orderNo); CreateOrderResponse response = new CreateOrderResponse(); response.setOrderNo(orderNo); response.setPayableAmount(payableAmount); return response; }注意看这个结构:每一块都和职责一一对应,没有Controller的影子,也没有SQL拼接。这个方法的每一步你都可以单独测试,也方便在中间加日志和监控。实际项目里还会有价格计算服务、优惠策略服务,但编排的核心思想就是这样。
4. 我踩过的坑,和排查问题的几条野路子
4.1 最常见的坑:业务逻辑往Controller里塞
我见过一个真实案例。团队里有个同事为了“快速上线”,把所有校验都写在Controller里,Service里只是一个空壳方法,直接调用Repository。刚开始确实快,因为少了参数传递和对象转换。但后来需求变了:同样的下单接口,App端要加一个“新人立减”活动,Web端要加一个“企业采购”校验。由于业务逻辑都在Controller里,两个入口只能各写各的,同一个下单规则出现了两套实现。再后来逻辑对不上了,前端A告诉用户“可以下单”,前端B却报“库存不足”,搞得产品经理以为系统有bug。
处理办法只有一个:把Controller里的业务代码全部搬到Service,Controller瘦身成真正的“翻译官”。搬的过程不算难,但要有纪律——以后但凡有人想在Controller里加业务逻辑,Code Review就要打回去重写。
4.2 事务到底该放在哪一层
这又是一个高频踩坑点。很多人写了一个Service方法,里面调用了两个Repository方法,但忘记加@Transactional,结果第一个插入成功、第二个插入失败,数据库里留下了半个订单。更隐蔽的是,有人在Controller上加了@Transactional,虽然也能回滚,但Controller变成事务入口点之后,日志、监控、异常处理全都乱了套,而且Controller如果有多个方法,一不小心就会把所有接口都包进事务,性能直接崩。
正确理解是:事务是业务层的属性,因为只有业务层才知道哪些操作是一个“完整业务动作”。数据层的每个原子操作默认自动提交,表现层完全不感知事务。当你使用Spring的声明式事务时,只要在业务方法上标注@Transactional,Spring会帮你把连接绑定到线程上,数据层多个操作共用同一个事务。
提示:
@Transactional默认只回滚运行时异常,如果是checked exception,要显式指定rollbackFor = Exception.class,否则你会看到数据半提交的幽灵问题。这是团队新人最容易掉进去的坑之一。
4.3 排查“三层代码不好调”的几个实用技巧
很多人说分层之后代码难调——一个请求要跨越三个类,打日志都费劲。我自己的排查习惯是这样的:
第一,在业务层的每个关键编排节点打日志,包括入参、中间计算结果、出参。这比在Controller和Repository里都打日志更有效,因为业务层才是整个流程的决策中心。第二,把“请求唯一ID”贯穿三层,从Controller入口生成一个traceId,打印日志时带上它,这样一次请求的所有日志都能串起来。第三,遇到数据一致性问题时,优先看事务边界是否挂对了方法,而不是盯着SQL看。
我还有一个野路子:先在Repository里做一次SQL直查,确认数据对不对,再反推业务层逻辑是否出错。这个方法听起来简单,但能快速区分“数据本身有问题”和“业务规则算错了”,省去大量翻日志的时间。
4.4 一张速查表:三层代码最常见问题清单
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Repository里出现if/else或业务判断 | 数据层被业务污染 | 把判断上移到业务层 |
| Controller里直接操作多个Service | 编排逻辑散落在表现层 | 在Service里新增编排方法 |
| 改了字段后前端报错或数据错乱 | 多层共用同一个实体对象 | 引入VO/DTO/BO并做转换 |
| 事务不生效,部分写了部分没写 | 事务边界放错位置或异常类型未覆盖 | 检查@Transactional位置和rollbackFor |
| 一个接口改动导致另一个入口数据异常 | 多入口各写一套业务规则 | 统一收敛到业务层 |
| 单元测试难写,Mock太多 | Service依赖了具体实现类或SQL | 面向接口编程,依赖抽象 |
5. 什么情况下T3 Code不再适用
聊了半天三层架构的好处,我也想聊聊它不适用的场景。不是所有项目都需要严格的三层代码,很多轻量级项目用三层反而是负担。
第一种情况是纯CRUD管理后台。如果只是简单的表格增删改查,没有复杂的业务规则和流程编排,硬拆三层会多出大量无意义的转换代码。这种项目用Controller直接操作Repository,甚至直接用MyBatis-Plus的Service接口,反而更省事。第二种情况是脚本任务和定时任务,它们本身没有表现层,直接从任务方法进入业务层即可,没必要为了“三层对称”硬造一个Controller。第三种情况是简单的读多写少查询服务,直接走Repository查数据返回即可,套三层只会增加延迟和代码量。
我个人的判断标准是:如果一段业务逻辑未来会被多个入口复用,或者有超过三步的流程编排,那它就该有一个独立的业务层方法。如果只是一次性的数据读取,就不要为了分层而分层。T3 Code是一种手段,不是一个必须遵从的宗教教条。
工具链上,我建议配合单元测试来做分层守护:每层都能独立测试,业务层用Mock数据层来测试规则,数据层用真实数据库做集成测试。分层不是为了让代码看起来高级,而是为了降低维护成本。你能在不需要启动Web容器的情况下,把一个业务异常用例写出来并跑通,那分层就真正有了价值。这也是我判断“T3 Code落地得好不好”的最直接标准。