资讯详情

T3 Code三层架构实践:从职责划分到事务边界的管理指南

📅 2026/10/7 4:18:39 | 华诺云谱 👁 阅读
T3 Code三层架构实践:从职责划分到事务边界的管理指南
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返回给ControllerController包装成标准的JSON响应。整个流程下来每一层都只干自己的事Controller没碰库存Service没拼SQLRepository没写任何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. 查询商品库存并做扣减此处用乐观锁或行锁保证并发安全 ListItemQuery items request.getItems(); ListOrderItemBO 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里的业务代码全部搬到ServiceController瘦身成真正的“翻译官”。搬的过程不算难但要有纪律——以后但凡有人想在Controller里加业务逻辑Code Review就要打回去重写。4.2 事务到底该放在哪一层这又是一个高频踩坑点。很多人写了一个Service方法里面调用了两个Repository方法但忘记加Transactional结果第一个插入成功、第二个插入失败数据库里留下了半个订单。更隐蔽的是有人在Controller上加了Transactional虽然也能回滚但Controller变成事务入口点之后日志、监控、异常处理全都乱了套而且Controller如果有多个方法一不小心就会把所有接口都包进事务性能直接崩。正确理解是事务是业务层的属性因为只有业务层才知道哪些操作是一个“完整业务动作”。数据层的每个原子操作默认自动提交表现层完全不感知事务。当你使用Spring的声明式事务时只要在业务方法上标注TransactionalSpring会帮你把连接绑定到线程上数据层多个操作共用同一个事务。提示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落地得好不好”的最直接标准。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑