从‘面条代码’到清晰领域:我是如何用DDD思想改造一个老旧图书馆管理系统的
从‘面条代码’到清晰领域:我是如何用DDD思想改造一个老旧图书馆管理系统的
第一次打开那个图书馆管理系统的代码库时,我仿佛看到了一碗煮过头的意大利面——各种业务逻辑像面条一样纠缠在一起,Book类里塞满了借阅规则,User类里硬塞了罚款计算,而一个巨大的LibraryManager类试图控制一切。这就是典型的"面条代码"(Spaghetti Code),业务逻辑像酱料一样被随意泼洒在各个角落。作为接手这个系统的第三任开发者,我面临一个抉择:是继续在混乱中缝缝补补,还是用领域驱动设计(DDD)的思想来一场彻底的重构?
1. 破译遗留系统的领域密码
面对一个运行了8年的老系统,直接重写是最危险的选择。我决定先花两周时间进行"领域考古",通过三个关键动作理解现有系统的业务本质:
- 事件风暴工作坊:邀请图书馆管理员、系统前维护者和业务专家,用便签纸还原所有业务流程
- 代码探针:在关键类插入日志,记录实际运行时的方法调用链
- 数据库快照分析:统计高频查询表和最活跃的数据关系
通过这种立体侦查,我绘制出了系统真实的业务地图。原来这个看似简单的图书馆系统隐藏着三个核心领域:
| 领域 | 核心业务规则 | 原系统实现问题 |
|---|---|---|
| 借阅管理 | 借期30天,可续借1次 | 规则硬编码在UI层 |
| 罚款计算 | 逾期每天0.5元,最高封顶50元 | 分散在5个不同类中 |
| 库存管理 | ISBN唯一性校验,副本管理 | 与用户账户耦合 |
这个分析验证了我的直觉——系统混乱不是因为代码写得差,而是因为领域模型被完全忽视了。就像把图书馆的图书分类法、借阅规则和财务系统混在一个抽屉里,自然难以维护。
2. 限界上下文的渐进式剥离
直接大刀阔斧地重写风险太大,我采用"外科手术式"的重构策略,用防腐层(Anti-Corruption Layer)隔离新旧代码。首先从最核心的借阅领域入手:
// 新建的借阅限界上下文核心聚合 public class BookLoan { private BookId bookId; private PatronId patronId; private LoanPeriod loanPeriod; private LoanStatus status; public BookLoan(BookId bookId, PatronId patronId) { this.bookId = bookId; this.patronId = patronId; this.loanPeriod = LoanPeriod.standard(); this.status = LoanStatus.ACTIVE; } public void renew() { if (this.status != LoanStatus.ACTIVE) { throw new IllegalLoanOperationException("Only active loans can be renewed"); } this.loanPeriod = this.loanPeriod.renew(); } }这个过程中遇到的最大挑战是处理与老系统的双向同步。我引入领域事件来解耦:
// 领域事件发布 public class BookLoanedEvent { private final BookId bookId; private final PatronId patronId; private final LocalDate loanDate; // 事件数据... } // 在聚合方法中发布事件 public class BookLoan { // ... public static BookLoan create(BookId bookId, PatronId patronId) { BookLoan loan = new BookLoan(bookId, patronId); DomainEventPublisher.publish(new BookLoanedEvent(bookId, patronId, LocalDate.now())); return loan; } }通过6周的渐进式改造,最终形成了清晰的上下文映射:
[借阅上下文] --(发布事件)--> [罚款上下文] [借阅上下文] --(RPC调用)--> [库存上下文]3. 聚合根的重设计与一致性边界
原系统最大的问题是将Library作为上帝类,所有操作都通过它进行。通过分析业务不变式(Invariants),我重新划定了聚合边界:
借阅聚合:
- 根实体:
BookLoan - 不变式:同一本书不能同时被借出两次
- 设计要点:
- 将
dueDate计算封装为值对象LoanPeriod - 引入
LoanStatus状态模式处理生命周期
- 将
// 值对象示例:借期策略 public class LoanPeriod { private final LocalDate startDate; private final LocalDate dueDate; private LoanPeriod(LocalDate startDate, LocalDate dueDate) { this.startDate = startDate; this.dueDate = dueDate; } public static LoanPeriod standard() { LocalDate now = LocalDate.now(); return new LoanPeriod(now, now.plusDays(30)); } public LoanPeriod renew() { return new LoanPeriod(this.startDate, this.dueDate.plusDays(30)); } }罚款聚合:
- 根实体:
OverdueFine - 不变式:罚款金额不超过上限
- 设计要点:
- 将计算规则封装在领域服务
FineCalculator中 - 使用策略模式支持不同的罚款规则
- 将计算规则封装在领域服务
// 罚款计算策略接口 public interface FineCalculationStrategy { BigDecimal calculate(LocalDate dueDate, LocalDate returnDate); } // 实现类 public class StandardFineCalculation implements FineCalculationStrategy { private static final BigDecimal DAILY_RATE = new BigDecimal("0.5"); private static final BigDecimal MAX_FINE = new BigDecimal("50"); @Override public BigDecimal calculate(LocalDate dueDate, LocalDate returnDate) { long daysOverdue = ChronoUnit.DAYS.between(dueDate, returnDate); if (daysOverdue <= 0) return BigDecimal.ZERO; BigDecimal calculated = DAILY_RATE.multiply(new BigDecimal(daysOverdue)); return calculated.compareTo(MAX_FINE) > 0 ? MAX_FINE : calculated; } }4. 重构后的架构与性能权衡
新架构采用六边形架构,核心领域完全独立于基础设施:
src/ ├── domain/ │ ├── lending/ # 借阅上下文 │ ├── fines/ # 罚款上下文 │ └── inventory/ # 库存上下文 ├── application/ │ ├── commands/ # CQRS命令 │ └── queries/ # 查询服务 └── infrastructure/ ├── persistence/ # 数据库适配器 └── events/ # 消息总线实现这种改造带来了显著的性能优化空间。例如在查询方面,从原来的多重JOIN查询:
-- 旧系统查询用户借阅情况 SELECT * FROM users u JOIN loans l ON u.id = l.user_id JOIN books b ON l.book_id = b.id WHERE u.id = ?转变为CQRS模式下的专用查询模型:
// 新系统的专门查询服务 public interface LoanQueryService { List<ActiveLoanDto> findActiveLoansByPatron(PatronId patronId); } // 实现类使用DTO投影 @Repository public class JpaLoanQueryService implements LoanQueryService { @Override public List<ActiveLoanDto> findActiveLoansByPatron(PatronId patronId) { return entityManager.createQuery( "SELECT new com.library.dto.ActiveLoanDto(...) " + "FROM BookLoan loan WHERE loan.patronId = :patronId", ActiveLoanDto.class) .setParameter("patronId", patronId) .getResultList(); } }5. 踩坑指南:DDD重构中的经验教训
这次重构让我收获了三个关键认知:
领域专家沟通:图书馆的"续借"业务规则实际比表面复杂,包括:
- 新书前7天不可续借
- 预约队列中的书不可续借
- 已有逾期记录限制续借次数
版本共存策略:采用双运行模式过渡,通过路由层将请求导向新旧系统:
public class LoanServiceRouter { private final OldLoanService oldService; private final NewLoanService newService; public LoanResult loanBook(BookId bookId, PatronId patronId) { if (featureToggle.isNewSystemEnabled(patronId)) { return newService.loanBook(bookId, patronId); } else { return oldService.loanBook(bookId, patronId); } } }- 测试保障体系:建立领域层的单元测试金字塔:
- 基础:聚合根不变式测试(100%覆盖)
- 中间:领域服务测试(模拟依赖)
- 顶层:限界上下文集成测试(测试上下文交互)
// 聚合根测试示例 @Test void should_prevent_double_loan() { BookId bookId = new BookId("123"); PatronId patron1 = new PatronId("user1"); PatronId patron2 = new PatronId("user2"); BookLoan firstLoan = BookLoan.create(bookId, patron1); assertThrows(DomainException.class, () -> { BookLoan secondLoan = BookLoan.create(bookId, patron2); }); }回头看这次重构,最大的收获不是代码变得多整洁,而是终于能自信地向新成员解释系统如何反映真实的图书馆业务。当代码结构与业务语言一致时,系统仿佛获得了自我解释的能力。那些曾经隐藏在if-else丛林中的业务规则,现在通过显式的领域对象和限界上下文变得清晰可见。
