《Java 消息中间件·事件驱动架构》13.1 libraryd 领域模型

libraryd 借阅系统核心领域模型实战:纯 JDK 实现聚合根 Loan、实体 Book/Member 与值对象 ISBN/LoanPeriod,验证构造校验、不可变性、相等性、不变量约束与乐观并发版本控制机制,涵盖内存仓储查询与值对象等价性,零 Spring 依赖。

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书稿需要读者一目了然字段访问路径
枚举代替 StringLoanStatus / BookStatus编译期检查,序列化降级策略见 6.1 节
仓储只读投影findOverdue 不查聚合第 3 节的事件投影会取代这些查询
unmodifiableList防御性拷贝实体状态只通过公开方法变化

常见错误

  1. 把实体当值对象用。 Book 如果是值对象,两本不同拷贝会无法区分借阅状态。ISBN 是值对象,Book 是实体。
  2. 校验只放在参数对象里。 构造函数做了初步校验,但业务不变量(续借次数上限、归还后不能续借)必须在聚合根里再检查一遍。参数对象的校验防垃圾输入,聚合根的校验防业务错误。
  3. 版本号放 JPA 注解里。 @Version 是基础设施层的锁,不是业务语义。命令携带的 expectedVersion 是领域需求,即使没有 Hibernate 也要存在。
  4. 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 命令设计

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收