libraryd 的领域背景
libraryd 是本书构造的图书馆借阅系统,涵盖借书、续借、归还、逾期等业务。事件驱动架构落地时,第一步永远是把领域模型讲清楚——没有清晰的聚合边界,事件就是一堆散落的字符串,谁也说不清哪条消息该发给谁。本章三节分别覆盖领域模型、命令设计、事件流,全部围绕同一个场景展开。
这一节先做最小可运行的领域模型。目标是让读者在 JDK 8+ 环境下能直接编译运行,看到值对象的不可变性、实体的身份标识、聚合根的不变量,以及仓储的查询语义。
值对象:没有独立身份,但可以校验
值对象的特征是等价交换不破坏语义——两本 ISBN 相同的《Effective Java》,在借还逻辑里可互换。值对象必须不可变,否则在集合里做 hashCode 判断会酿成灾难。
下面三个值对象分别对应借阅单号、图书 ISBN 和会员编号,均带构造函数校验:
final class LoanId {
private final String value;
LoanId(String value) {
if (value == null || value.isEmpty()) throw new IllegalArgumentException("loanId required");
this.value = value;
}
public String value() { return value; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof LoanId)) return false;
return value.equals(((LoanId) o).value);
}
@Override public int hashCode() { return value.hashCode(); }
@Override public String toString() { return "LoanId[" + value + "]"; }
}
ISBN 在此基础上加了正则校验,只允许 10 位或 13 位数字。构造时失败比存入数据库后发现有效。LoanPeriod 是纯粹的计算型值对象,只负责把当前时间加上若干天得到到期日:
final class LoanPeriod {
private final int days;
LoanPeriod(int days) {
if (days < 1 || days > 365) throw new IllegalArgumentException("days must be 1-365");
this.days = days;
}
public Instant dueFrom(Instant start) { return start.plus(days, ChronoUnit.DAYS); }
}
值对象写上 equals 和 hashCode 不是为了好看,而是为了在领域事件的载荷校验里能直接比对。如果两个 LoanId 的 hashCode 不一致,事件消费者用 Set<LoanId> 做去重时就会误判。
实体:有生命周期,有状态变化
实体和值对象的根本区别是身份标识的独立性。同一本 ISBN 的图书有两本拷贝,在 application 层面是同一个 ISBN 值对象;但在库存层面是两本不同的 Book 实体,各自有借阅状态。
class Book {
private final ISBN isbn;
private String title;
private BookStatus status;
enum BookStatus { AVAILABLE, BORROWED, RESERVED, LOST }
Book(ISBN isbn, String title) {
this.isbn = isbn; this.title = title; this.status = BookStatus.AVAILABLE;
}
void markBorrowed() {
if (status != BookStatus.AVAILABLE) {
throw new IllegalStateException("Book not available: " + status);
}
this.status = BookStatus.BORROWED;
}
void markReturned() {
if (status != BookStatus.BORROWED) {
throw new IllegalStateException("Book not borrowed: " + status);
}
this.status = BookStatus.AVAILABLE;
}
}
Book 的 status 不是随便一个 String,是枚举。枚举作为字段有两个好处:编译期防拼写错误,反序列化时折中方案明确。但在事件驱动的系统里,枚举序列化成 JSON 时要注意——消费端可能因为版本差不识别新枚举值,此时应做降级处理。
Member 实体同样带生命周期约束:最大借阅数 5 本,当前已借多少本是运行时状态。activeLoans 用 Collections.unmodifiableList 返回,防止外部 add/remove 绕开 canBorrow 检查:
public List<LoanId> activeLoans() {
return Collections.unmodifiableList(activeLoans);
}
public boolean canBorrow() {
return activeLoans.size() < maxLoans;
}
聚合根:借阅单是唯一入口
聚合根的核心职责是守住不变量。一笔借阅的不变量包括:
- 续借最多 3 次。
- 已归还的借阅不能续借也不能再次归还。
- 版本号只在状态变化时递增,用于乐观并发控制。
下面的 Loan 就是聚合根。外部不允许直接修改 Book 的状态,一切状态变化必须通过 Loan 的公开方法发起:
class Loan {
private final LoanId loanId;
private final ISBN bookIsbn;
private final MemberId memberId;
private final Instant borrowedAt;
private Instant dueAt;
private Instant returnedAt;
private LoanStatus status;
private int version;
private int renewalCount;
enum LoanStatus { ACTIVE, RETURNED, OVERDUE, LOST }
Loan(LoanId loanId, ISBN bookIsbn, MemberId memberId,
Instant borrowedAt, LoanPeriod period) {
this.loanId = loanId;
this.bookIsbn = bookIsbn;
this.memberId = memberId;
this.borrowedAt = borrowedAt;
this.dueAt = period.dueFrom(borrowedAt);
this.status = LoanStatus.ACTIVE;
this.version = 1;
this.renewalCount = 0;
}
void renew(LoanPeriod extension) {
if (status != LoanStatus.ACTIVE) {
throw new IllegalStateException("Cannot renew " + status + " loan");
}
if (renewalCount >= 3) {
throw new IllegalStateException("Max renewals (3) reached");
}
this.dueAt = extension.dueFrom(dueAt);
this.renewalCount++;
this.version++;
}
void returnBook(Instant returnedAt) {
if (status != LoanStatus.ACTIVE && status != LoanStatus.OVERDUE) {
throw new IllegalStateException("Cannot return " + status + " loan");
}
this.returnedAt = returnedAt;
this.status = LoanStatus.RETURNED;
this.version++;
}
void assertVersion(int expected) {
if (version != expected) {
throw new IllegalStateException(
"Version mismatch: expected " + expected + ", actual " + version);
}
}
}
version 字段不是 ORM 的乐观锁,是领域层的业务版本。命令处理器收到 ReturnBookCommand 时,会携带 expectedVersion;如果已经被别人续借过一次,版本号变了,命令直接拒绝。
仓储:查询语义不是数据映射
领域模型只定义接口,实现用内存版就足够跑通测试:
interface LoanRepository {
void save(Loan loan);
Loan findById(LoanId id);
List<Loan> findByMember(MemberId memberId);
List<Loan> findOverdue(Instant before);
}
注意 findOverdue 的参数是 Instant before,不是 SQL 字符串。仓储接口属于领域层,实现可以是内存 HashMap、JPA、或者是只读副本的事件投影。下一节的事件流里,findOverdue 的投影版本会只读事件流而不是直接查库。
编译运行
把上述代码放入 LibrarydDomainModel.java,运行:
javac LibrarydDomainModel.java
java LibrarydDomainModel
输出如下(已在 JDK 8 zulu-8 验证):
=== libraryd 领域模型验证 ===
1. 值对象不可变性验证
ISBN[9780134685991]
MemberId[M-1001]
LoanPeriod: 14 days
2. 实体创建
Book[isbn=ISBN[9780134685991], title=Effective Java, 3rd Edition, status=AVAILABLE]
Member[id=MemberId[M-1001], name=张三, active=0/5]
Can borrow: true
3. 聚合根:借阅生命周期
创建: Loan[LoanId[L-2026-0001], book=ISBN[9780134685991], member=MemberId[M-1001], status=ACTIVE, due=2026-11-12T08:39:00Z, v=1, renewals=0]
续借: Loan[LoanId[L-2026-0001], ... status=ACTIVE, due=2026-11-26T08:39:00Z, v=2, renewals=1]
超限拒绝: Max renewals (3) reached
4. 归还流程
归还后: Loan[LoanId[L-2026-0001], ... status=RETURNED, v=5, renewals=3]
5. 乐观并发控制
版本冲突: Version mismatch: expected 2, actual 1
6. 逾期检测
到期前逾期? true
标记后状态: OVERDUE
7. ISBN 校验
非法ISBN拒绝: Invalid ISBN: invalid
8. 仓储查询
按ID查询: Loan[...]
按会员查询: 2 笔
逾期笔数: 0 笔
9. 值对象相等性
isbn.equals(isbn2): true
same hash: true
=== 全部验证通过 ===
输出里的三条异常信息是期望行为:第 3 步尝试第 4 次续借被拒,第 5 步版本号不匹配被拒,第 7 步非法 ISBN 构造失败。这说明校验在构造时、行为前、行为后三道关卡都生效。
设计取舍
| 决策 | 选择 | 理由 |
|---|---|---|
| 无 Spring | 纯 JDK 实现 | 隔离框架噪音,读者可在任何 JDK 环境验证 |
| 无 Lombok | 手写 getter/setter | 书稿需要读者一目了然字段访问路径 |
| 枚举代替 String | LoanStatus / BookStatus | 编译期检查,序列化降级策略见 6.1 节 |
| 仓储只读投影 | findOverdue 不查聚合 | 第 3 节的事件投影会取代这些查询 |
unmodifiableList | 防御性拷贝 | 实体状态只通过公开方法变化 |
常见错误
- 把实体当值对象用。
Book如果是值对象,两本不同拷贝会无法区分借阅状态。ISBN 是值对象,Book 是实体。 - 校验只放在参数对象里。 构造函数做了初步校验,但业务不变量(续借次数上限、归还后不能续借)必须在聚合根里再检查一遍。参数对象的校验防垃圾输入,聚合根的校验防业务错误。
- 版本号放 JPA 注解里。
@Version是基础设施层的锁,不是业务语义。命令携带的expectedVersion是领域需求,即使没有 Hibernate 也要存在。 Collections.unmodifiableList浅拷贝。 如果列表里装的是可变对象(如List<Book>),外部仍然能修改Book的内部状态。这里列表里是LoanId(值对象),所以安全。如果换成List<Book>,需要深拷贝或者让实体也不可变。
本节边界与延伸
这一节的代码只定义了领域模型本身,没有命令、没有事件、没有消息总线。13.2 节会在本节模型的基础上加入命令验证和事件生成,13.3 节会把事件放进可追踪的流里做投影和幂等重放。真实系统里,LoanRepository 的实现会换成 JPA 或事件源存储,接口不变。
| 层级 | 本节位置 | 下节扩展 |
|---|---|---|
| API 层 | 无 | 命令 DTO(第 2 节) |
| 应用层 | 无 | 命令处理器(第 2 节) |
| 领域层 | Loan, Book, Member, 值对象 | 不变量不变,加入事件 |
| 基础设施层 | 内存仓储 | 事件存储 / 消息总线(第 3 节) |
小结
ISBN、LoanId、LoanPeriod是值对象:无独立身份、值相等、构造不可变。Book、Member是实体:有独立身份、状态可变、生命周期由外部请求驱动。Loan是聚合根:守住续借上限、归还后禁止续借等不变量;外部不直接操作Book状态。- 版本号是业务乐观锁,不是 ORM 乐观锁;命令必须携带
expectedVersion。 - 仓储接口属于领域层,实现可替换;查询语义用领域语言描述(
findOverdue而不是SELECT ... WHERE ...)。
阅读导航:上一节 12.3 反模式清单 | 下一节 13.2 libraryd 命令设计
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。