Spring Boot 借阅流程重构实战:从400行上帝类到三层架构
在项目迭代过程中,”上帝类”是几乎所有后端开发者都会遇到的经典反模式。本文记录了一次图书管理系统中 LendRecordService 的完整重构过程:如何将一个 400+ 行、混合校验/查询/写操作/编排的上帝类,拆分为遵循 CQRS 思想的三层架构,最终降至 ~150 行的薄编排层。
前言
图书管理系统的借阅流程是整个系统的核心业务之一,涉及借阅、归还、续借三大操作。随着功能不断叠加,负责这部分逻辑的 LendRecordService 逐渐膨胀到了 400+ 行,承担了校验、查询、写操作、编排等多重职责。每次新增需求,开发者都不得不在这个庞大的类中小心翼翼地修改,生怕牵一发而动全身。
本文将完整分享这次重构的思考过程、实施方案、代码细节以及代码审查中发现的问题,希望能为遇到类似场景的开发者提供参考。
问题背景:一个”上帝类”的诞生
什么是上帝类?
上帝类(God Class) 是面向对象设计中的经典反模式:一个类承担了过多的职责,知道得太多,做得也太多。它就像团队里那个什么都管的同事——虽然短期内看起来效率很高,但随着业务增长,维护成本会呈指数级上升。
在图书管理系统中,LendRecordService 就是这样一个典型的上帝类:
| 职责 | 说明 |
|---|---|
| 借阅资格校验 | 检查图书状态、预约队列、用户限额、重复借阅 |
| 写操作 | 插入 lend_record、更新 book 状态、创建 bookwithuser、履行预约 |
| 上下文加载 | 归还/续借时加载 LendRecord + BookWithUser + Book + User + LendRule |
| 自愈机制 | LendRecord 缺失时补建记录 |
| 结果构建 | 组装返回给前端的 Map |
五大代码异味
经过仔细分析,我们识别出了 5 个关键的 Code Smells:
GOD-01:上帝类(严重程度:高)
单一服务类 400+ 行,混合了校验、查询、写操作、编排等多重职责。这直接导致可维护性和可测试性大幅下降。任何一个小改动都需要理解整个类的逻辑。
DUP-01:校验逻辑重复(严重程度:高)
borrowBook() 和 checkCanBorrow() 中存在借阅资格校验逻辑的重复,而且两处逻辑不一致。borrowBook() 中的校验包含 5 个步骤,而 checkCanBorrow() 中的校验只有 4 个步骤,预约队列和限额的校验逻辑存在差异。这种不一致可能导致 checkCanBorrow() 返回”可借阅”,但 borrowBook() 执行时被另一处校验拦截。
DUP-02:上下文加载冗长重复(严重程度:中)
归还和续借流程中,需要加载 LendRecord + BookWithUser + Book + User + LendRule 五个对象。这段上下文加载代码在 returnBook() 和 renewBook() 中几乎完全相同,冗长且重复。
TEST-01:写操作难以测试(严重程度:高)
写操作(插入 lend_record、更新 book 状态等)直接嵌入在 borrowBook() 方法中,无法在不触发完整业务流程的情况下单独测试。这意味着要测试”借阅记录插入是否正确”,就不得不走完整个借阅流程,构造大量前置条件。
HEAL-01:自愈逻辑缺乏封装(严重程度:中)
当 LendRecord 缺失但 BookWithUser 存在时,系统有一套自愈逻辑来补建记录。但这段逻辑散落在业务方法中,缺乏独立封装,难以理解和维护。

图1:重构前的架构问题分析
重构方案:三层架构 + CQRS
设计思路
经过分析,我们决定采用命令查询职责分离(CQRS)的思想,将 LendRecordService 拆分为三层:
1 | Before: |
各层职责划分
| 层级 | 类名 | 职责 | 事务控制 |
|---|---|---|---|
| 读侧校验 | BorrowEligibilityService |
借阅前的资格检查 | @Transactional(readOnly = true) |
| 写侧操作 | LendRecordWriteService |
借阅/归还/续借的数据库写操作 | 无独立事务(由调用方控制) |
| 编排层 | LendRecordService |
协调校验和写操作,构建返回结果 | @Transactional(rollbackFor = Exception.class) |
这里有几个关键的设计决策:
为什么 BorrowEligibilityService 用只读事务? 校验操作只涉及查询,标注 readOnly = true 可以让数据库进行优化(如不加写锁),同时也从语义上明确了这个方法的职责边界——它只做检查,不做修改。
为什么 LendRecordWriteService 不加事务注解? 事务应该由编排层统一控制。如果写操作服务自带事务,校验和写操作就不在同一个事务中执行,可能导致校验通过后、执行写操作前被其他请求修改数据,产生并发问题。
为什么引入不可变上下文对象? BorrowEligibilityResult 和 CurrentBorrowContext 都是只有构造函数和 getter、没有 setter 的不可变对象。这确保了校验通过后的数据不会被意外修改,同时也作为方法间的”数据契约”,明确传递了每一层需要什么数据。

图2:重构后的三层架构设计
核心实现:逐层拆解
第一层:BorrowEligibilityService — 读侧校验
这是重构的核心。我们将散落在 borrowBook() 和 checkCanBorrow() 中的重复校验逻辑统一到一个 evaluate() 方法中。
1 |
|
evaluate() 方法的校验流程清晰明了,依次执行 8 个步骤:
- loadBookOrThrow() — 检查图书是否存在
- loadUserOrThrow() — 检查用户是否存在
- validateBookStatus() — 检查图书状态是否可借
- validateReservationQueue() — 检查预约队列,当前读者是否排在队首
- loadRuleOrThrow() — 加载借阅规则
- countCurrentBorrowed() — 查询当前借阅数
- ensureUnderLimit() — 检查是否超过借阅限额
- ensureNotDuplicateBorrow() — 检查是否重复借阅
每个步骤都是一个独立的 private 方法,方法名即文档,读起来就像自然语言描述。任何一步失败,都会抛出 ServiceException,调用方可以统一 try/catch 处理。
返回的 BorrowEligibilityResult 是一个不可变对象,封装了校验通过后的所有上下文数据:
1 | public class BorrowEligibilityResult { |
第二层:LendRecordWriteService — 写侧操作
写操作服务封装了三种核心写操作,每个方法只负责纯写操作,不包含校验逻辑:
1 |
|
设计亮点在于:输入参数即上下文。每个方法接收不可变的上下文对象(BorrowEligibilityResult 或 CurrentBorrowContext),所有必要数据都已预加载,方法内部不需要额外的数据库查询。这使得方法完全独立、可预测,也非常容易测试。
第三层:LendRecordService — 薄编排层
重构后的 LendRecordService 从 400+ 行瘦身为 ~150 行的薄编排层,不再包含任何业务逻辑:
1 |
|
对比重构前后的 borrowBook() 方法:
| 维度 | Before | After |
|---|---|---|
| 方法行数 | ~80 行(校验 + 写 + 构建结果) | ~20 行(编排 + 构建结果) |
| 校验逻辑 | 内联重复 | 统一委托给 BorrowEligibilityService |
| 写操作 | 内联 | 封装在 LendRecordWriteService |
| 可测试性 | 只能集成测试 | 每层可独立 mock 测试 |
API 兼容:checkCanBorrow 的优雅处理
重构中一个值得注意的细节是 checkCanBorrow() 方法的处理。原始方法返回 {canBorrow: false, reason: "..."} 格式的 Map,而 evaluate() 失败时抛异常。为了保持前端 API 兼容,我们在编排层做了一个简单的适配:
1 | public Map<String, Object> checkCanBorrow(Long readerId, String isbn) { |
通过 try/catch 将异常转换为原始格式,实现了内部实现的统一与外部接口的兼容。

图3:重构前后校验逻辑对比
代码审查:6 个问题的启示
重构完成后,我们进行了代码审查,发现了 6 个问题。这些问题极具代表性,几乎是所有提取重构都会遇到的坑:
Issue #1:API 合约断裂(严重)
问题:checkCanBorrow() 原本返回 {canBorrow: false, reason: "..."},重构后 evaluate() 失败时抛异常,前端会收到 500 错误而非预期的 JSON。
修复:添加 try/catch 将异常转换为原始 Map 格式。
启示:重构内部实现时,必须确保公共 API 的返回格式不变。这是提取重构中最容易忽视的问题——我们关注了代码结构,却忘了关注契约。
Issue #2:insert 返回值检查丢失(中等)
问题:原始代码在 lendRecordMapper.insert() 后检查 inserted <= 0,提取时遗漏了这个检查。
1 | // Before(遗漏) |
启示:原方法中的返回值检查是防御性代码,搬运时容易被遗漏。
Issue #3:null guard 丢失(中等)
问题:原始 returnBook() 中有 if (book != null) 保护,提取到 completeReturn() 时遗漏。在自愈场景中 book 可能为 null,会导致 NPE。
1 | // Before(遗漏) |
启示:原方法中的 if/null 检查都有其存在的原因(通常是为了处理边界情况),搬运时一个都不能少。
Issue #4:未使用参数(低)
问题:loadCurrentBorrowContext() 接受一个 boolean includeBook 参数,但从未使用。
修复:移除未使用的参数。
Issue #5:重复查询(中等)
问题:自愈路径中先通过 bookWithUserMapper 查到 BookWithUser,然后又用相同条件重新查了一次。
修复:复用第一次查询的结果。
Issue #6:重复私有方法(低)
问题:BorrowEligibilityService 和 LendRecordWriteService 各有一个完全相同的 convertToLocalDateTime() 方法。
修复:提取到共享的 DateUtils 工具类:
1 | public class DateUtils { |
选择 Timestamp 桥接而非 Date.toInstant() 的原因是:Timestamp.toLocalDateTime() 直接使用系统默认时区,而 Date.toInstant() 使用 UTC 时区,在转换时容易忘记加 ZoneId 导致 8 小时偏差。
代码审查修复汇总
| # | 问题 | 严重度 | 修复方式 |
|---|---|---|---|
| 1 | API 合约断裂 | 严重 | try/catch 转换异常为 Map |
| 2 | insert 返回值检查丢失 | 中等 | 恢复 inserted <= 0 检查 |
| 3 | null guard 丢失 | 中等 | 恢复 if (book != null) |
| 4 | 未使用参数 | 低 | 移除 boolean 参数 |
| 5 | 重复查询 | 中等 | 复用已有查询结果 |
| 6 | 重复私有方法 | 低 | 提取到 DateUtils |

图4:代码审查中发现的 6 个典型问题
实施过程:四个阶段,九次提交
整个重构分为四个阶段,在 代码瘦身 分支上完成(基线 3415d11):
Phase 1:提取校验层(Commits: 0851071 → 4a04c56)
创建 BorrowEligibilityResult 不可变对象,提取 evaluate() 方法,让 checkCanBorrow() 复用校验逻辑。这一阶段解决了 DUP-01(校验逻辑重复)的问题。
Phase 2:提取写操作层(Commits: 75a9acf → 69a859a)
将借阅写操作从 borrowBook() 中提取到 LendRecordWriteService.createBorrow()。borrowBook() 从 ~80 行降为 ~20 行,只剩下编排逻辑和结果构建。
Phase 3:归还/续借重构(Commits: 69a859a → f109846)
提取 completeReturn() 和 renewBorrow() 写操作方法,创建 CurrentBorrowContext 统一上下文加载,解决了 DUP-02(上下文加载重复)的问题。
Phase 4:代码审查修复(Commits: bb568e9 → 3be9c2b)
修复代码审查中发现的 6 个问题,提取 DateUtils 工具类。
1 | 0851071 refactor: add lending context types |
测试验证
重构后的测试覆盖大幅提升:
| 测试类 | 用例数 | 覆盖场景 |
|---|---|---|
BorrowEligibilityServiceTest |
4 | 正常通过、图书不可借、超过限额、重复借阅 |
LendRecordWriteServiceTest |
4 | 借阅创建、归还完成、续借成功、自愈补建 |
LendRecordServiceTest |
15 | 完整编排流程:借阅/归还/续借 + 边界条件 |
总计新增 23 个测试用例,全量 86 个测试全部通过(mvn test → BUILD SUCCESS)。
关键回归验证项:
- 前端控制器文件未修改(无 controller drift)
checkCanBorrow()返回格式保持{canBorrow, reason}borrowBook()、returnBook()、renewBook()返回格式不变- 数据库表结构和 SQL 未修改
经验总结
什么时候该重构?
当一个服务类超过 300 行,或者你发现修改一个功能需要理解整个类的逻辑时,就该考虑重构了。在我们的案例中,400+ 行的 LendRecordService 已经是明显的信号。
提取重构的核心步骤
- 识别职责边界:分析当前类承担了哪些职责,每个职责的输入和输出是什么
- 创建上下文对象:定义不可变的数据载体,明确方法间的数据契约
- 逐层提取:先提取校验层(读侧),再提取写操作层(写侧),最后瘦身为编排层
- 保持 API 兼容:确保公共方法的返回格式不变,内部实现变更不影响调用方
- 代码审查:重点关注原方法中的防御性代码是否完整搬运
提取重构最常见的坑
根据这次代码审查的经验,提取重构最容易出的问题有三个:
边界条件丢失:原方法中的 null 检查、返回值检查、异常处理等防御性代码,在搬运过程中容易被遗漏。代码审查时必须逐行对比原方法和提取后的方法,确认每个 if/try/catch 都存在。
API 合约断裂:重构内部实现时,公共方法的返回格式可能在不经意间发生变化。特别是异常处理策略的变更(如从返回错误 Map 改为抛异常),需要额外注意调用方的期望。
重复代码引入:看似提取到了新类,但如果新类之间又产生了重复代码(如我们的 convertToLocalDateTime()),就说明提取还不够彻底,需要进一步抽象共享工具。
CQRS 在日常开发中的应用
CQRS 不只是微服务架构中的大杀器,在日常的单体应用重构中同样适用。核心思想很简单:把读操作和写操作分开。读操作用只读事务,写操作不带事务(由编排层统一控制),编排层只负责协调。这种分层方式让每一层都变得简单、可测试、可独立演进。
结语
这次重构将一个 400+ 行的上帝类拆分为 ~150 行的编排层 + 两个专职服务类,新增 23 个测试用例,解决了校验逻辑不一致、写操作难以测试等核心问题。整个过程通过 4 个阶段、9 次提交完成,代码审查发现并修复了 6 个问题。重构的关键不是写出”完美的代码”,而是通过持续的小步改进,让代码库保持健康和可维护。