Skip to content

领域模型基类 ​

com.ddk.core.domain 已经实现,本文既是它的设计说明,也是使用文档。 包里有 8 个类型,零框架依赖,配套 36 个单元测试。

DDK domain model base classes

一、先说清为什么要加,以及代价是什么 ​

DDK 的定位是「解决 DDD 概念抽象、难以落地的问题」。但在这套基类之前,它提供的全是通用工具,DDD 的部分只体现在 archetype 的目录结构上——你把包名叫 domain,里面放的仍然是贫血的 POJO。

java
// 改造前 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 就只剩目录结构。

但为了让代价可控,实现上遵守两条约束:

  1. 基类只提供机制,不强制流程。 不做 ORM 映射、不做仓储绑定、不依赖 Spring。
  2. 可以只用一部分。 只想用 ValueObject 就只用它,不必整套接受。

二、Identifier:给身份一个类型 ​

最容易被跳过、但收益最直接的一个。

java
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);
    }
}

用起来:

java
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:

java
// 用 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 就是同一个东西。

java
/**
 * 值对象标记接口。
 *
 * 实现者必须满足三个约束,基类无法强制,只能靠约定与 ArchUnit 规则检查:
 *   1. 不可变:所有字段 final,不提供 setter
 *   2. 按值相等:equals/hashCode 基于全部字段
 *   3. 自我校验:构造时拒绝非法状态,不存在「构造出来再检查」的中间态
 */
public interface ValueObject {
}

为什么是空接口而不是抽象类?因为 Java 21 的 record 已经天然满足前两条,用抽象类反而挡住了 record:

java
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 决定,而不是由字段值决定。用户改了昵称,还是同一个用户。

java
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:聚合边界与领域事件 ​

聚合根是聚合的唯一入口,也是领域事件的收集点。

java
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;
    }
}

一个完整的聚合根示例,重点看不变量保护和事件登记:

java
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 与发布时机 ​

java
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 不能继承类,所以两种写法都保留:

java
// 写法一: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 只定义接口,具体发布由基础设施层实现:

java
public interface DomainEventPublisher {
    void publish(DomainEvent event);
    default void publishAll(Collection<? extends DomainEvent> events) {
        events.forEach(this::publish);
    }
}

Spring 侧的实现目前放在四层骨架的 infrastructure/event 包里,等契约稳定后会收进 starter:

java
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(),调用方只需要:

java
@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 里拼条件。

java
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 或方法引用,不必每条都建一个类:

java
Specification<User> active = User::enabled;

价值在于让业务规则有名字、可组合、可单测:

java
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() 的用处是让动态组合不必特判空集合:

java
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 会在每次构建时检查:

java
@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 里:

java
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 的习惯一致:

java
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> 生成的实现是:

java
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 里丢失;身份不可覆盖
AggregateRootTestdomainEvents() 视图不可篡改;drain 取出的快照不受后续变更影响
SpecificationTestand 短路;any() / none() 可作为归约初值
DomainEventTestoccurredOn 在构造时固定且多次读取稳定
DomainPackagePurityTest整个包只依赖 JDK 与自身

这些用例的价值不在覆盖率数字,而在于它们锁住的正是最容易被「顺手优化」掉的取舍—— 比如有人看到 getClass().hashCode() 觉得哈希分布太差,改成 id.hashCode(),EntityTest 会立刻失败。

小结 ​

这套基类的核心只有三件事:给身份一个类型(Identifier)、区分按值相等和按身份相等(ValueObject / Entity)、给领域事件一个收集与发布的位置(AggregateRoot / DomainEvent)。

其余都是配套。设计上最需要小心的两个点:Entity.hashCode() 必须选择契约正确而非分布良好的实现,以及领域事件必须在事务提交后发布。

落地时最大的风险不是基类本身,而是团队把 AggregateRoot 当成「带 equals 的 POJO」继续写贫血模型。基类只能提供机制,不变量要写在实体方法里这件事,得靠评审。

相关阅读 ​

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权