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
2
3
4
5
6
7
8
9
10
Before:
Controller → LendRecordService (400+ lines, mixed validation + write + orchestration)

After:
Controller → LendRecordService (thin orchestration, ~150 lines)
├── BorrowEligibilityService (read-side validation)
│ └── BorrowEligibilityResult (immutable context)
├── LendRecordWriteService (write-side operations)
└── loadCurrentBorrowContext() + buildReturnResult()
└── CurrentBorrowContext (immutable context)

各层职责划分

层级 类名 职责 事务控制
读侧校验 BorrowEligibilityService 借阅前的资格检查 @Transactional(readOnly = true)
写侧操作 LendRecordWriteService 借阅/归还/续借的数据库写操作 无独立事务(由调用方控制)
编排层 LendRecordService 协调校验和写操作,构建返回结果 @Transactional(rollbackFor = Exception.class)

这里有几个关键的设计决策:

为什么 BorrowEligibilityService 用只读事务? 校验操作只涉及查询,标注 readOnly = true 可以让数据库进行优化(如不加写锁),同时也从语义上明确了这个方法的职责边界——它只做检查,不做修改。

为什么 LendRecordWriteService 不加事务注解? 事务应该由编排层统一控制。如果写操作服务自带事务,校验和写操作就不在同一个事务中执行,可能导致校验通过后、执行写操作前被其他请求修改数据,产生并发问题。

为什么引入不可变上下文对象? BorrowEligibilityResultCurrentBorrowContext 都是只有构造函数和 getter、没有 setter 的不可变对象。这确保了校验通过后的数据不会被意外修改,同时也作为方法间的”数据契约”,明确传递了每一层需要什么数据。

三层架构设计
图2:重构后的三层架构设计

核心实现:逐层拆解

第一层:BorrowEligibilityService — 读侧校验

这是重构的核心。我们将散落在 borrowBook()checkCanBorrow() 中的重复校验逻辑统一到一个 evaluate() 方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service
public class BorrowEligibilityService {

@Autowired
private BookMapper bookMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private LendRuleMapper lendRuleMapper;
@Autowired
private LendRecordMapper lendRecordMapper;
@Autowired
private ReservationMapper reservationMapper;
@Autowired
private BookWithUserMapper bookWithUserMapper;

/**
* 评估借阅资格 — 统一校验入口
* 成功返回 BorrowEligibilityResult(不可变),失败抛出 ServiceException
*/
@Transactional(readOnly = true)
public BorrowEligibilityResult evaluate(Long readerId, String isbn) {
Book book = loadBookOrThrow(isbn);
User user = loadUserOrThrow(readerId);
validateBookStatus(book);
validateReservationQueue(isbn, readerId);
LendRule rule = loadRuleOrThrow(user);
long count = countCurrentBorrowed(readerId);
ensureUnderLimit(count, rule);
ensureNotDuplicateBorrow(readerId, isbn);
return new BorrowEligibilityResult(book, user, rule, count, isbn, readerId);
}
}

evaluate() 方法的校验流程清晰明了,依次执行 8 个步骤:

  1. loadBookOrThrow() — 检查图书是否存在
  2. loadUserOrThrow() — 检查用户是否存在
  3. validateBookStatus() — 检查图书状态是否可借
  4. validateReservationQueue() — 检查预约队列,当前读者是否排在队首
  5. loadRuleOrThrow() — 加载借阅规则
  6. countCurrentBorrowed() — 查询当前借阅数
  7. ensureUnderLimit() — 检查是否超过借阅限额
  8. ensureNotDuplicateBorrow() — 检查是否重复借阅

每个步骤都是一个独立的 private 方法,方法名即文档,读起来就像自然语言描述。任何一步失败,都会抛出 ServiceException,调用方可以统一 try/catch 处理。

返回的 BorrowEligibilityResult 是一个不可变对象,封装了校验通过后的所有上下文数据:

1
2
3
4
5
6
7
8
9
10
public class BorrowEligibilityResult {
private final Book book;
private final User user;
private final LendRule rule;
private final long currentBorrowedCount;
private final String isbn;
private final Long readerId;

// 构造函数 + getter,无 setter(不可变)
}

第二层:LendRecordWriteService — 写侧操作

写操作服务封装了三种核心写操作,每个方法只负责纯写操作,不包含校验逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@Service
public class LendRecordWriteService {

/**
* 执行借阅写操作:
* 1. 插入 lend_record
* 2. 更新 book 状态为"已借出"
* 3. 创建 bookwithuser 关联
* 4. 履行预约(如果当前读者有预约)
*/
public LendRecord createBorrow(BorrowEligibilityResult eligibility,
LendRecord input, Date now) {
// 构建 LendRecord
LendRecord record = new LendRecord();
record.setIsbn(eligibility.getIsbn());
record.setReaderId(eligibility.getReaderId());
record.setBookName(input.getBookName());
record.setReaderName(input.getReaderName());
record.setLendDate(now);

// 计算应还日期 = 当前时间 + 借阅规则天数
LocalDateTime deadline = DateUtils.toLocalDateTime(now)
.plusDays(eligibility.getRule().getDays());
record.setDeadtime(Date.from(deadline
.atZone(ZoneId.systemDefault()).toInstant()));
record.setStatus("0"); // 未归还

// 插入 lend_record(带返回值检查)
int inserted = lendRecordMapper.insert(record);
if (inserted <= 0) {
throw new ServiceException("借阅记录创建失败");
}

// 更新 book 状态
Book book = eligibility.getBook();
book.setStatus("2"); // 已借出
bookMapper.updateById(book);

// 创建 bookwithuser 关联
BookWithUser bwu = new BookWithUser();
bwu.setIsbn(eligibility.getIsbn());
bwu.setReaderId(eligibility.getReaderId());
bwu.setBookName(record.getBookName());
bwu.setReaderName(record.getReaderName());
bwu.setLendDate(now);
bwu.setDeadtime(record.getDeadtime());
bwu.setProlong(0);
bookWithUserMapper.insert(bwu);

// 履行预约(如有)
fulfillReservationIfExists(
eligibility.getIsbn(), eligibility.getReaderId());

return record;
}

/**
* 执行归还写操作
*/
public void completeReturn(CurrentBorrowContext context, Date now) {
// 更新 lend_record
LendRecord record = context.getLendRecord();
record.setStatus("1"); // 已归还
record.setReturnDate(now);
lendRecordMapper.updateById(record);

// 更新 book 状态(null guard 保护自愈场景)
Book book = context.getBook();
if (book != null) {
book.setStatus("1"); // 可借
bookMapper.updateById(book);
}

// 删除 bookwithuser 关联
BookWithUser bwu = context.getBookWithUser();
if (bwu != null) {
bookWithUserMapper.deleteById(bwu.getId());
}
}

/**
* 执行续借写操作
*/
public void renewBorrow(CurrentBorrowContext context,
LocalDateTime newDeadline) {
// 更新 bookwithuser 的应还日期和续借次数
BookWithUser bwu = context.getBookWithUser();
bwu.setDeadtime(Date.from(newDeadline
.atZone(ZoneId.systemDefault()).toInstant()));
bwu.setProlong(bwu.getProlong() + 1);
bookWithUserMapper.updateById(bwu);

// 同步更新 lend_record
LendRecord record = context.getLendRecord();
record.setDeadtime(bwu.getDeadtime());
lendRecordMapper.updateById(record);
}
}

设计亮点在于:输入参数即上下文。每个方法接收不可变的上下文对象(BorrowEligibilityResultCurrentBorrowContext),所有必要数据都已预加载,方法内部不需要额外的数据库查询。这使得方法完全独立、可预测,也非常容易测试。

第三层:LendRecordService — 薄编排层

重构后的 LendRecordService 从 400+ 行瘦身为 ~150 行的薄编排层,不再包含任何业务逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Service
public class LendRecordService {

@Autowired
private BorrowEligibilityService borrowEligibilityService;

@Autowired
private LendRecordWriteService lendRecordWriteService;

/**
* 借阅图书 — 编排层
* 1. 校验借阅资格
* 2. 执行借阅写操作
* 3. 构建返回结果
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> borrowBook(LendRecord lendRecord) {
// 入参校验
if (lendRecord == null || lendRecord.getReaderId() == null
|| lendRecord.getIsbn() == null) {
return Result.error("参数不完整").toMap();
}

// 1. 校验借阅资格(读侧)
BorrowEligibilityResult eligibility;
try {
eligibility = borrowEligibilityService.evaluate(
lendRecord.getReaderId(), lendRecord.getIsbn());
} catch (ServiceException e) {
return Result.error(e.getMessage()).toMap();
}

// 2. 执行借阅写操作(写侧)
Date now = new Date();
LendRecord created = lendRecordWriteService.createBorrow(
eligibility, lendRecord, now);

// 3. 构建返回结果
Map<String, Object> result = new HashMap<>();
result.put("code", "0");
result.put("msg", "借阅成功");
result.put("data", created);
return result;
}
}

对比重构前后的 borrowBook() 方法:

维度 Before After
方法行数 ~80 行(校验 + 写 + 构建结果) ~20 行(编排 + 构建结果)
校验逻辑 内联重复 统一委托给 BorrowEligibilityService
写操作 内联 封装在 LendRecordWriteService
可测试性 只能集成测试 每层可独立 mock 测试

API 兼容:checkCanBorrow 的优雅处理

重构中一个值得注意的细节是 checkCanBorrow() 方法的处理。原始方法返回 {canBorrow: false, reason: "..."} 格式的 Map,而 evaluate() 失败时抛异常。为了保持前端 API 兼容,我们在编排层做了一个简单的适配:

1
2
3
4
5
6
7
8
9
10
11
12
13
public Map<String, Object> checkCanBorrow(Long readerId, String isbn) {
try {
borrowEligibilityService.evaluate(readerId, isbn);
Map<String, Object> result = new HashMap<>();
result.put("canBorrow", true);
return result;
} catch (ServiceException e) {
Map<String, Object> result = new HashMap<>();
result.put("canBorrow", false);
result.put("reason", e.getMessage());
return result;
}
}

通过 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
2
3
4
5
6
7
8
// Before(遗漏)
lendRecordMapper.insert(record);

// After(修复)
int inserted = lendRecordMapper.insert(record);
if (inserted <= 0) {
throw new ServiceException("借阅记录创建失败");
}

启示:原方法中的返回值检查是防御性代码,搬运时容易被遗漏。

Issue #3:null guard 丢失(中等)

问题:原始 returnBook() 中有 if (book != null) 保护,提取到 completeReturn() 时遗漏。在自愈场景中 book 可能为 null,会导致 NPE。

1
2
3
4
5
6
7
8
9
10
// Before(遗漏)
Book book = context.getBook();
book.setStatus("1"); // NPE if book is null!

// After(修复)
Book book = context.getBook();
if (book != null) {
book.setStatus("1");
bookMapper.updateById(book);
}

启示:原方法中的 if/null 检查都有其存在的原因(通常是为了处理边界情况),搬运时一个都不能少。

Issue #4:未使用参数(低)

问题loadCurrentBorrowContext() 接受一个 boolean includeBook 参数,但从未使用。

修复:移除未使用的参数。

Issue #5:重复查询(中等)

问题:自愈路径中先通过 bookWithUserMapper 查到 BookWithUser,然后又用相同条件重新查了一次。

修复:复用第一次查询的结果。

Issue #6:重复私有方法(低)

问题BorrowEligibilityServiceLendRecordWriteService 各有一个完全相同的 convertToLocalDateTime() 方法。

修复:提取到共享的 DateUtils 工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DateUtils {

private DateUtils() {
// 工具类禁止实例化
}

/**
* Date → LocalDateTime
* 通过 Timestamp 桥接,避免时区丢失
*/
public static LocalDateTime toLocalDateTime(Date date) {
if (date == null) {
return null;
}
return new Timestamp(date.getTime()).toLocalDateTime();
}
}

选择 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
2
3
4
5
6
7
8
9
0851071 refactor: add lending context types
d0e4d1e refactor: extract borrow eligibility service
4a04c56 refactor: reuse eligibility logic in checkCanBorrow
75a9acf refactor: extract borrow write path
493aef0 refactor: orchestrate borrow flow via helper services
69a859a refactor: add return and renew write helpers
f109846 refactor: add current borrow context loader
bb568e9 fix: code review fixes for borrowing flow cleanup
3be9c2b simplify: extract DateUtils, remove duplicate query and unnecessary comments

测试验证

重构后的测试覆盖大幅提升:

测试类 用例数 覆盖场景
BorrowEligibilityServiceTest 4 正常通过、图书不可借、超过限额、重复借阅
LendRecordWriteServiceTest 4 借阅创建、归还完成、续借成功、自愈补建
LendRecordServiceTest 15 完整编排流程:借阅/归还/续借 + 边界条件

总计新增 23 个测试用例,全量 86 个测试全部通过(mvn test → BUILD SUCCESS)。

关键回归验证项:

  • 前端控制器文件未修改(无 controller drift)
  • checkCanBorrow() 返回格式保持 {canBorrow, reason}
  • borrowBook()returnBook()renewBook() 返回格式不变
  • 数据库表结构和 SQL 未修改

经验总结

什么时候该重构?

当一个服务类超过 300 行,或者你发现修改一个功能需要理解整个类的逻辑时,就该考虑重构了。在我们的案例中,400+ 行的 LendRecordService 已经是明显的信号。

提取重构的核心步骤

  1. 识别职责边界:分析当前类承担了哪些职责,每个职责的输入和输出是什么
  2. 创建上下文对象:定义不可变的数据载体,明确方法间的数据契约
  3. 逐层提取:先提取校验层(读侧),再提取写操作层(写侧),最后瘦身为编排层
  4. 保持 API 兼容:确保公共方法的返回格式不变,内部实现变更不影响调用方
  5. 代码审查:重点关注原方法中的防御性代码是否完整搬运

提取重构最常见的坑

根据这次代码审查的经验,提取重构最容易出的问题有三个:

边界条件丢失:原方法中的 null 检查、返回值检查、异常处理等防御性代码,在搬运过程中容易被遗漏。代码审查时必须逐行对比原方法和提取后的方法,确认每个 if/try/catch 都存在。

API 合约断裂:重构内部实现时,公共方法的返回格式可能在不经意间发生变化。特别是异常处理策略的变更(如从返回错误 Map 改为抛异常),需要额外注意调用方的期望。

重复代码引入:看似提取到了新类,但如果新类之间又产生了重复代码(如我们的 convertToLocalDateTime()),就说明提取还不够彻底,需要进一步抽象共享工具。

CQRS 在日常开发中的应用

CQRS 不只是微服务架构中的大杀器,在日常的单体应用重构中同样适用。核心思想很简单:把读操作和写操作分开。读操作用只读事务,写操作不带事务(由编排层统一控制),编排层只负责协调。这种分层方式让每一层都变得简单、可测试、可独立演进。

结语

这次重构将一个 400+ 行的上帝类拆分为 ~150 行的编排层 + 两个专职服务类,新增 23 个测试用例,解决了校验逻辑不一致、写操作难以测试等核心问题。整个过程通过 4 个阶段、9 次提交完成,代码审查发现并修复了 6 个问题。重构的关键不是写出”完美的代码”,而是通过持续的小步改进,让代码库保持健康和可维护。