领域模型基类
com.ddk.core.domain已经实现,本文既是它的设计说明,也是使用文档。 包里有 8 个类型,零框架依赖,配套 36 个单元测试。
一、先说清为什么要加,以及代价是什么
DDK 的定位是「解决 DDD 概念抽象、难以落地的问题」。但在这套基类之前,它提供的全是通用工具,DDD 的部分只体现在 archetype 的目录结构上——你把包名叫 domain,里面放的仍然是贫血的 POJO。
// 改造前 archetype 里的「领域实体」
@Getter
@ToString
public class User {
private Long id;
@NonNull private String username;
@NonNull private Gender gender;
// ... 只有字段,没有行为,没有不变量,没有身份语义
}这个 User 和一个 DTO 没有区别。它不知道自己的身份是什么(equals 用的是对象引用)、不保护任何不变量、也无法表达「用户被禁用」这种领域事件。
加基类能解决这些,代价必须提前讲明:
代价一:侵入性。 业务实体要 extends AggregateRoot<UserId>。Java 单继承,这个位置被占掉了。 代价二:ID 类型从 Long 变成 UserId。 类型安全换来的是每个边界都要转换。 代价三:学习成本。 团队需要理解实体与值对象的区别,否则基类会被用成「带 equals 的 POJO」。
我的判断是值得:没有身份语义和不变量保护的「领域模型」,本质上只是换了包名的 DTO,脚手架如果不提供这层,DDD 就只剩目录结构。
但为了让代价可控,实现上遵守两条约束:
- 基类只提供机制,不强制流程。 不做 ORM 映射、不做仓储绑定、不依赖 Spring。
- 可以只用一部分。 只想用
ValueObject就只用它,不必整套接受。
二、Identifier:给身份一个类型
最容易被跳过、但收益最直接的一个。
public abstract class Identifier<T extends Serializable> implements ValueObject, Serializable {
private final T value;
protected Identifier(T value) {
if (value == null) {
throw new IllegalArgumentException(getClass().getSimpleName() + " 的值不能为 null");
}
this.value = value;
}
public T value() {
return value;
}
@Override
public final boolean equals(Object o) {
// 注意:必须比较具体类型,否则 UserId(1) 会等于 OrderId(1)
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
return value.equals(((Identifier<?>) o).value);
}
@Override
public final int hashCode() {
return value.hashCode();
}
@Override
public String toString() {
return String.valueOf(value);
}
}用起来:
public final class UserId extends Identifier<Long> {
private UserId(Long value) {
super(value);
if (value <= 0) {
throw new IllegalArgumentException("UserId 必须为正数,实际为 " + value);
}
}
public static UserId of(Long value) {
return new UserId(value);
}
}它挡住的是这类 bug:
// 用 Long 的时候,这段代码能编译、能运行、能上线
void transfer(Long fromUserId, Long toAccountId) { ... }
transfer(accountId, userId); // 参数传反了,编译器无话可说
// 用 Identifier 的时候
void transfer(UserId from, AccountId to) { ... }
transfer(accountId, userId); // 编译期报错getClass() != o.getClass() 这一行是关键。如果用 instanceof Identifier,UserId.of(1L).equals(OrderId.of(1L)) 会返回 true,那就白做了。
三、ValueObject:按值相等
值对象的定义就是「没有身份,只有值」。两个金额相同、币种相同的 Money 就是同一个东西。
/**
* 值对象标记接口。
*
* 实现者必须满足三个约束,基类无法强制,只能靠约定与 ArchUnit 规则检查:
* 1. 不可变:所有字段 final,不提供 setter
* 2. 按值相等:equals/hashCode 基于全部字段
* 3. 自我校验:构造时拒绝非法状态,不存在「构造出来再检查」的中间态
*/
public interface ValueObject {
}为什么是空接口而不是抽象类?因为 Java 21 的 record 已经天然满足前两条,用抽象类反而挡住了 record:
public record Money(BigDecimal amount, Currency currency) implements ValueObject {
public Money {
// record 的紧凑构造器:自我校验放这里
if (amount == null || currency == null) {
throw new IllegalArgumentException("金额和币种都不能为空");
}
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("金额精度超出币种允许范围");
}
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency); // 返回新对象,不修改自身
}
public Money multiply(int quantity) {
return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("币种不一致:" + currency + " vs " + other.currency);
}
}
}推荐用 record 实现值对象,Identifier 之所以是抽象类,是因为它需要统一那个「比较具体类型」的 equals 逻辑。
四、Entity:按身份相等
实体的相等性由 ID 决定,而不是由字段值决定。用户改了昵称,还是同一个用户。
public abstract class Entity<ID extends Identifier<?>> {
/**
* 实体标识。
*
* 为什么不是 final:很多实体在持久化之前没有 ID(数据库自增主键)。
* 强制 final 会导致必须提前生成 ID,那是另一种设计取向(UUID/雪花),
* 这里不替使用者做这个决定。
*/
protected ID id;
protected Entity() {
}
protected Entity(ID id) {
this.id = id;
}
public ID id() {
return id;
}
/** 是否尚未持久化 */
public boolean isNew() {
return id == null;
}
/**
* 由持久化适配层在写入后回填标识。
*
* protected 而不是 public:回填是基础设施的职责,只允许子类在自己的包内
* 开放给对应的仓储实现,避免业务代码随意改身份。
* 标识一旦确定就不可变,重复赋值直接抛 IllegalStateException。
*/
protected void assignId(ID id) {
if (this.id != null) {
throw new IllegalStateException(
getClass().getSimpleName() + " 的标识已是 " + this.id + ",不允许重新赋值");
}
if (id == null) {
throw new IllegalArgumentException("回填的标识不能为 null");
}
this.id = id;
}
@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Entity<?> other = (Entity<?>) o;
// ID 为 null 时退化为引用相等:两个都没落库的实体不应该被判定为同一个
return id != null && id.equals(other.id);
}
@Override
public final int hashCode() {
// 注意:不能用 id.hashCode()。实体入 HashSet 后如果被赋了 ID,
// hashCode 会变化,导致再也找不到它。用类型的 hashCode 是稳定的选择,
// 代价是同类实体全部落在同一个桶里——实体本来就不该大量放进 HashSet。
return getClass().hashCode();
}
}hashCode 这个取舍值得单独说。三种做法:
| 做法 | 问题 |
|---|---|
id.hashCode() | ID 从 null 变成有值时 hashCode 变化,破坏 HashSet/HashMap 契约 |
Objects.hash(所有字段) | 实体是可变的,改任何字段都会变,问题更严重 |
getClass().hashCode() | 哈希分布退化,但契约正确 |
选第三个。契约正确性优先于性能,而且大量实体放进哈希集合本身就是设计问题。
五、AggregateRoot:聚合边界与领域事件
聚合根是聚合的唯一入口,也是领域事件的收集点。
public abstract class AggregateRoot<ID extends Identifier<?>> extends Entity<ID> {
/**
* 未发布的领域事件。
*
* transient + 非序列化:事件是「本次操作产生的待办」,不属于聚合状态,
* 不应该被持久化,也不应该跟着聚合被缓存。
*/
private transient final List<DomainEvent> domainEvents = new ArrayList<>();
protected AggregateRoot() {
super();
}
protected AggregateRoot(ID id) {
super(id);
}
/** 由子类在状态变更后调用,登记一个领域事件 */
protected void registerEvent(DomainEvent event) {
Objects.requireNonNull(event, "领域事件不能为 null");
domainEvents.add(event);
}
/** 供基础设施层读取,返回不可变视图,防止外部篡改 */
public List<DomainEvent> domainEvents() {
return Collections.unmodifiableList(domainEvents);
}
/** 是否有待发布的事件。让基础设施层省掉一次列表拷贝 */
public boolean hasDomainEvents() {
return !domainEvents.isEmpty();
}
/**
* 取出全部待发布事件并清空。这是推荐给基础设施层的用法。
*
* 「复制 + 清空」是一步完成的,避免「读取 → 发布 → 清空」过程中
* 新登记的事件被误清。
*/
public List<DomainEvent> drainDomainEvents() {
List<DomainEvent> snapshot = List.copyOf(domainEvents);
domainEvents.clear();
return snapshot;
}
/** 通常应该用 drainDomainEvents() 而不是它 */
public void clearEvents() {
domainEvents.clear();
}
/**
* 乐观锁版本号。
* 放在聚合根而不是实体上:并发控制的单位是聚合,不是聚合内的单个实体。
*/
private Long version;
public Long version() {
return version;
}
/** 由基础设施层在加载聚合时回填 */
protected void assignVersion(Long version) {
this.version = version;
}
}一个完整的聚合根示例,重点看不变量保护和事件登记:
public class Order extends AggregateRoot<OrderId> {
private final CustomerId customerId;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status;
private Money totalAmount;
/** 工厂方法而不是公开构造器:创建也是领域行为,也要保护不变量 */
public static Order place(CustomerId customerId, List<OrderLine> lines) {
if (lines == null || lines.isEmpty()) {
throw new BusinessException(OrderError.EMPTY_ORDER);
}
Order order = new Order(customerId);
lines.forEach(order::addLine);
order.status = OrderStatus.PENDING_PAYMENT;
order.registerEvent(new OrderPlacedEvent(order.customerId, order.totalAmount));
return order;
}
private Order(CustomerId customerId) {
this.customerId = Objects.requireNonNull(customerId);
this.totalAmount = Money.zero(Currency.getInstance("CNY"));
}
/** 状态机约束写在实体里,而不是散落在 Service 中 */
public void cancel(String reason) {
if (!status.cancellable()) {
throw new BusinessException(OrderError.NOT_CANCELLABLE, status);
}
this.status = OrderStatus.CANCELLED;
registerEvent(new OrderCancelledEvent(id(), reason));
}
public void markPaid(PaymentId paymentId) {
if (status != OrderStatus.PENDING_PAYMENT) {
// 幂等:已支付的订单重复回调不报错,也不重复发事件
if (status == OrderStatus.PAID) {
return;
}
throw new BusinessException(OrderError.INVALID_STATUS_FOR_PAYMENT, status);
}
this.status = OrderStatus.PAID;
registerEvent(new OrderPaidEvent(id(), paymentId, totalAmount));
}
/** 只允许通过聚合根修改内部实体,这是「聚合边界」的实际含义 */
private void addLine(OrderLine line) {
lines.add(line);
this.totalAmount = totalAmount.plus(line.subtotal());
}
/** 对外暴露不可变视图,防止绕过聚合根直接改 lines */
public List<OrderLine> lines() {
return Collections.unmodifiableList(lines);
}
}六、DomainEvent 与发布时机
public interface DomainEvent {
/** 事件发生时刻,由实现者在构造时固定,不是发布时刻 */
Instant occurredOn();
/** 事件类型标识,用于日志、审计与消息路由 */
default String eventType() {
return getClass().getSimpleName();
}
}
/** 提供 occurredOn 的默认实现,避免每个事件都重复写 */
public abstract class AbstractDomainEvent implements DomainEvent {
private final Instant occurredOn = Instant.now();
@Override
public Instant occurredOn() {
return occurredOn;
}
}具体事件用 record 会更简洁,但 record 不能继承类,所以两种写法都保留:
// 写法一:record + 显式字段
public record OrderPaidEvent(OrderId orderId, PaymentId paymentId,
Money amount, Instant occurredOn) implements DomainEvent {
public OrderPaidEvent(OrderId orderId, PaymentId paymentId, Money amount) {
this(orderId, paymentId, amount, Instant.now());
}
}
// 写法二:继承 AbstractDomainEvent
public class OrderCancelledEvent extends AbstractDomainEvent {
private final OrderId orderId;
private final String reason;
// ...
}发布时机:必须在事务提交后
这是最容易做错的地方。事件如果在事务内发布,订阅方可能读到还未提交的数据,或者事务回滚了但事件已经发出去。
ddk-core 只定义接口,具体发布由基础设施层实现:
public interface DomainEventPublisher {
void publish(DomainEvent event);
default void publishAll(Collection<? extends DomainEvent> events) {
events.forEach(this::publish);
}
}Spring 侧的实现目前放在四层骨架的 infrastructure/event 包里,等契约稳定后会收进 starter:
public class SpringDomainEventPublisher implements DomainEventPublisher {
private final ApplicationEventPublisher delegate;
@Override
public void publish(DomainEvent event) {
delegate.publishEvent(event);
}
}
// 订阅方用 @TransactionalEventListener 而不是 @EventListener
@Component
public class OrderPaidHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderPaidEvent event) {
// 只有事务提交成功才会执行到这里
notificationService.notifyPaid(event.orderId());
}
}配套需要一个「从聚合根收集事件并发布」的钩子,放在仓储保存成功之后。 「先复制再清空」这一步已经收进 AggregateRoot.drainDomainEvents(),调用方只需要:
@Override
public User save(User user) {
UserPO po = mapperProvider().lookup(User.class, UserPO.class).map(user);
getBaseMapper().insert(po); // MyBatis-Plus 回填 po.id
user.onPersisted(UserId.of(po.getId())); // 回填标识,此时才登记注册事件
if (user.hasDomainEvents()) {
eventPublisher.publishAll(user.drainDomainEvents());
}
return user;
}这里有一个自增主键方案绕不开的代价:注册事件要带上用户 ID,而工厂方法 User.register(...) 执行时 ID 还不存在,所以事件只能等到 onPersisted 时才登记。想在工厂方法里就发事件,得改用 UUID / 雪花提前生成标识——那是另一种设计取向,DDK 不替使用者做这个决定。
七、Specification:把查询条件表达成领域概念
GenericRepository 目前只有 find(id) 和 page(pageQuery),复杂查询无处安放,最后必然泄漏到 Service 里拼条件。
public interface Specification<T> {
boolean isSatisfiedBy(T candidate);
default Specification<T> and(Specification<T> other) {
return candidate -> this.isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate);
}
default Specification<T> or(Specification<T> other) {
return candidate -> this.isSatisfiedBy(candidate) || other.isSatisfiedBy(candidate);
}
default Specification<T> not() {
return candidate -> !this.isSatisfiedBy(candidate);
}
/** 恒真规格,作为 and 归约的初始值 */
static <T> Specification<T> any() {
return candidate -> true;
}
/** 恒假规格,作为 or 归约的初始值 */
static <T> Specification<T> none() {
return candidate -> false;
}
}它标了 @FunctionalInterface,所以简单规则可以直接写成 lambda 或方法引用,不必每条都建一个类:
Specification<User> active = User::enabled;价值在于让业务规则有名字、可组合、可单测:
public class ActiveUser implements Specification<User> {
@Override
public boolean isSatisfiedBy(User user) {
return user.status() == UserStatus.ACTIVE;
}
}
public class RegisteredWithin implements Specification<User> {
private final Duration duration;
@Override
public boolean isSatisfiedBy(User user) {
return user.registeredAt().isAfter(Instant.now().minus(duration));
}
}
// 组合出「新注册的活跃用户」,这个概念现在有了名字
Specification<User> newActiveUser =
new ActiveUser().and(new RegisteredWithin(Duration.ofDays(7)));any() 与 none() 的用处是让动态组合不必特判空集合:
Specification<User> all = specs.stream().reduce(Specification.any(), Specification::and);要诚实说明它的边界:这是内存判定的 Specification,不会翻译成 SQL。想让它下推到数据库,需要在 ddk-mybatis 里加一层 SqlSpecification,把规格转成 Query。那是另一个话题,目前不做——内存版本用在「已加载的聚合上校验规则」这个场景,不要拿它去过滤全表。
八、包结构与依赖约束
ddk-core/src/main/java/com/ddk/core/
├── domain/
│ ├── Identifier.java
│ ├── ValueObject.java
│ ├── Entity.java
│ ├── AggregateRoot.java
│ ├── DomainEvent.java
│ ├── AbstractDomainEvent.java
│ ├── DomainEventPublisher.java
│ ├── Specification.java
│ └── package-info.java
├── exception/
├── response/
├── page/
├── repository/
└── mapper/硬约束:com.ddk.core.domain 不得依赖 Spring、MyBatis、Jackson 或任何框架。领域模型必须能在没有容器的情况下被单元测试。
这条约束写在注释里只是声明,写成测试才是约束。DomainPackagePurityTest 会在每次构建时检查:
@Test
@DisplayName("只依赖 JDK 与自身")
void dependsOnlyOnJdkAndItself() {
classes()
.should().onlyDependOnClassesThat()
.resideInAnyPackage("com.ddk.core.domain..", "java..")
.because("领域基类不引入任何第三方依赖,下游可以放心继承")
.check(domainClasses);
}注意这条比「不依赖框架」更严:连 Lombok、Hutool、MapStruct、Jakarta Validation 都不允许——领域基类是要被下游继承的,多一个依赖就是多一份传染。
给下游项目用的版本在 ddk-archguard-starter 里:
CommonArchRules.DOMAIN_MUST_NOT_DEPEND_ON_FRAMEWORKS.check(classes);
CommonArchRules.DOMAIN_MUST_NOT_DEPEND_ON_OUTER_LAYERS.check(classes);九、与现有代码的衔接
引入领域模型后,三处配套改动已经完成,另有一处还没做。
1. GenericRepository 的泛型顺序(已改)
原来是 GenericRepository<ID, E>,而 javadoc 里的 @param 顺序写的是 <E> 在前——声明和文档不一致。现在统一为 <E, ID>,与 Spring Data 的习惯一致:
public interface GenericRepository<E, ID> { ... }这是破坏性变更。因为项目还没发布过(版本一直是 1.0.0-SNAPSHOT,无 git tag),现在改的成本最低。
需要说明一处与原设计的偏离:这里没有按原计划把签名收紧成 <E extends AggregateRoot<ID>, ID extends Identifier<?>>。理由是本文第一节定下的第二条约束——「可以只用一部分」。把仓储绑死在聚合根上,会让还没引入领域模型的项目无法使用 GenericRepository,与整套基类「按需采用」的取向冲突。需要聚合语义的项目,可以自己定义一个更窄的子接口。
2. Entity 与 PO 的映射必须显式(已改)
原来 MapperProvider 在找不到 @EnhancedMapper 时会退回 DefaultMapper,而 MapStruct 为 ObjectMapper<Object, Object> 生成的实现是:
public Object map(Object source) {
if (source == null) return null;
Object object = new Object(); // 数据全丢
return object;
}于是 GenericRepositoryImpl.create() 会把这个空 Object checkcast 成 P,运行时抛 ClassCastException。当时 archetype 里的 UserRepositoryImpl 正好没有注册任何 @EnhancedMapper,所以示例本身就是坏的。
引入领域模型后,Entity 和 PO 的结构差异更大(值对象要拆成多列、Identifier 要取出 value()、聚合根根本没有 setter),自动映射更不可能猜对。所以规则改为:必须为每个 Entity↔PO 方向显式提供映射器,找不到就抛 MissingMapperException。 DefaultMapper 已删除。详见对象映射文档。
3. 领域事件的发布位置(已定)
DomainEventPublisher 的 Spring 实现目前在四层骨架的 infrastructure/event 包里,等契约稳定后会收进 starter。仓储在保存成功后调用 drainDomainEvents() 把事件交给它,真正的投递由订阅方的 @TransactionalEventListener(AFTER_COMMIT) 推迟到事务提交后。
4. 乐观锁版本号还没在通用仓储里打通(未做)
AggregateRoot.version() 需要映射到 PO 的 @Version 字段,由 MyBatis-Plus 的乐观锁插件处理。目前这一步只在四层骨架的手写转换器里完成,GenericRepositoryImpl 本身不感知版本号——它甚至不要求 E 是聚合根(见上面第 1 点)。这条在路线图的第 3 阶段。
十、不做什么
明确划出边界,避免范围蔓延:
- 不做事件溯源。 事件用于集成与解耦,不用于重建状态。
- 不做仓储的自动 Specification 翻译。 内存判定够用了,SQL 下推等有真实需求再说。
- 不做聚合的自动脏检查。 显式调用
update()保存,不搞 JPA 那套持久化上下文。 - 不提供
DomainService基类。 领域服务是无状态的普通类,没有需要复用的机制。
十一、测试覆盖
36 个单元测试,重点覆盖三类容易写错的行为:
| 测试 | 验证什么 |
|---|---|
IdentifierTest | 跨类型标识不相等;null 值构造期被拒;可安全作为 Map key |
EntityTest | 回填 ID 前后 hashCode 不变,实体不会在 HashSet 里丢失;身份不可覆盖 |
AggregateRootTest | domainEvents() 视图不可篡改;drain 取出的快照不受后续变更影响 |
SpecificationTest | and 短路;any() / none() 可作为归约初值 |
DomainEventTest | occurredOn 在构造时固定且多次读取稳定 |
DomainPackagePurityTest | 整个包只依赖 JDK 与自身 |
这些用例的价值不在覆盖率数字,而在于它们锁住的正是最容易被「顺手优化」掉的取舍—— 比如有人看到 getClass().hashCode() 觉得哈希分布太差,改成 id.hashCode(),EntityTest 会立刻失败。
小结
这套基类的核心只有三件事:给身份一个类型(Identifier)、区分按值相等和按身份相等(ValueObject / Entity)、给领域事件一个收集与发布的位置(AggregateRoot / DomainEvent)。
其余都是配套。设计上最需要小心的两个点:Entity.hashCode() 必须选择契约正确而非分布良好的实现,以及领域事件必须在事务提交后发布。
落地时最大的风险不是基类本身,而是团队把 AggregateRoot 当成「带 equals 的 POJO」继续写贫血模型。基类只能提供机制,不变量要写在实体方法里这件事,得靠评审。