跳到内容

第 6 卷 / 第 20 章 / 第 2 课

20.2 战术建模:实体、值对象、聚合与仓储 ​

战术模式的目标是让业务不变量在模型中有唯一、可执行的归属。不是每张表对应一个 Entity,也不是所有关联对象都放进一个 Aggregate。

Entity 由身份和生命周期定义 ​

本页使用 Java 21,各个 public 类型分别保存到同名文件。PlayerId 是项目提供的不可变玩家标识;报名事件的定义见下一课,业务异常也由项目定义。

实体即使属性变化,仍被视为同一个对象。身份必须稳定:

java
import java.util.Objects;
import java.util.UUID;

public record TournamentId(UUID value) {
    public TournamentId {
        Objects.requireNonNull(value);
    }
}

不要用所有可变字段生成实体的 equals/hashCode,否则对象进入集合后修改字段会破坏查找。实体可以按同一类型和稳定身份定义相等性,也可以在受控工作单元内使用对象引用。本页 Tournament 保留默认引用相等,TournamentId 则是按 UUID 比较的值对象,不能把 ID 类型本身误当成实体。

Value Object 由值定义 ​

值对象没有独立身份,适合表达带规则的度量和概念:

java
public record Capacity(int value) {
    public Capacity {
        if (value < 2 || value > 1024) {
            throw new IllegalArgumentException("capacity must be 2..1024");
        }
    }

    public boolean canFit(int confirmedEntries) {
        if (confirmedEntries < 0) {
            throw new IllegalArgumentException("confirmed count must be nonnegative");
        }
        return confirmedEntries < value;
    }
}

2 到 1024 是本例赛事容量规则,不是通用 DDD 限制。canFit 回答还能否再加入一人,并拒绝负数计数。

值对象应优先不可变,创建时就满足约束。它比到处传裸 int 更能保留单位和业务含义。

Aggregate 是一致性边界 ​

Aggregate 是一组在一次事务中维护一致性的对象,Aggregate Root 是外部修改它们的唯一入口。下面只展示新赛事的创建与报名,TournamentStatus 取 OPEN 或 CLOSED;状态切换和数据库重建流程未包含在这个示例中。

java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public final class Tournament {
    private final TournamentId id;
    private final Capacity capacity;
    private final TournamentStatus status;
    private final Set<PlayerId> confirmed = new HashSet<>();

    public Tournament(TournamentId id, Capacity capacity, TournamentStatus status) {
        this.id = Objects.requireNonNull(id);
        this.capacity = Objects.requireNonNull(capacity);
        this.status = Objects.requireNonNull(status);
    }

    public Set<PlayerId> confirmedPlayers() {
        return Set.copyOf(confirmed);
    }

    public RegistrationConfirmed register(PlayerId playerId) {
        Objects.requireNonNull(playerId);
        if (status != TournamentStatus.OPEN) {
            throw new RegistrationClosed(id);
        }
        if (confirmed.contains(playerId)) {
            throw new PlayerAlreadyRegistered(playerId);
        }
        if (!capacity.canFit(confirmed.size())) {
            throw new TournamentFull(id);
        }

        RegistrationConfirmed event =
            new RegistrationConfirmed(id, playerId, confirmed.size() + 1);
        confirmed.add(playerId);
        return event;
    }
}

“人数不超过容量”和“同一玩家不重复确认”由同一个根维护;集合访问器返回不可变快照。事件先构造、再修改集合,避免普通校验异常留下半次报名;这不承诺从 JVM 崩溃或内存耗尽中恢复。返回事件也不等于已经提交或发布。

这个可变对象供单个工作单元使用,不保证线程安全。两个请求各自加载一个副本时,根内检查都会通过,仍需仓储的原子版本检查或锁。重复玩家在领域层被拒绝;若 HTTP 重试要求返回原成功结果,应在请求幂等层处理。

聚合要尽量小 ​

把整个赛事、所有比赛、玩家、奖励和支付都放进一个聚合,会导致:

  • 每次操作加载巨大对象图;
  • 并发命令争用同一版本;
  • 事务和锁范围过大;
  • 无法独立扩展和演进。

聚合之间优先通过 ID 引用,并在应用层或领域服务中协调。跨聚合规则通常接受最终一致、预留或 Saga;若某不变量确实必须立即原子成立,应重新审视边界或数据所有权。

聚合边界由不变量和并发决定,不由 UI 页面或 ORM 级联方便程度决定。

Repository 面向聚合根 ​

java
import java.util.Optional;

public interface TournamentRepository {
    record Loaded(Tournament aggregate, long version) {}

    Optional<Loaded> findById(TournamentId id);
    void save(Tournament tournament, long expectedVersion);
}

加载结果携带版本,调用者把它原样传给 save。仓储应在同一事务中校验版本并保存聚合内部全部变更,冲突时失败;不能保存一半成员再报告冲突。版本控制保护持久化并发,本身不会让一个共享 Java 对象变得线程安全。

仓储提供类似集合的领域接口,隐藏查询和持久化细节。它通常按聚合根定义,不为聚合内部每个实体提供可绕过根的仓储。

复杂报表和列表查询不必强行加载聚合,可以使用专门的查询模型。写模型保护规则,读模型服务检索。

Domain Service 放置无自然归属的规则 ​

一个业务决策若涉及多个概念,却不自然属于某个实体或值对象,可以使用无状态领域服务。下面声明接口,不冒充已实现的种子算法;Bracket、QualifiedPlayer 和 RankingSnapshot 是项目类型:

java
import java.util.List;

public interface SeedingPolicy {
    Bracket seed(List<QualifiedPlayer> players, RankingSnapshot ranking);
}

例如种子排序可以按本次冻结的排名降序、玩家 ID 升序打破平手,再生成对阵。是否需要随机抽签是业务规则;若需要,保存抽签结果或随机种子,不能恢复时悄悄换一套对阵。该规则只依赖传入快照,取排名和安排事务由应用服务完成。

领域服务不等于所有业务逻辑的收容所。能够放进实体和值对象的行为应靠近状态;需要事务、仓储、消息和权限编排的是应用服务。

Factory 保护复杂创建 ​

当创建聚合需要多步校验、策略或多个值对象时,Factory 可以表达“如何得到一个有效初始聚合”。简单构造不必机械增加 Factory。

聚合测试关注状态转换 ​

text
Given 赛事开放且剩余 1 个名额
When 玩家 A 报名
Then 报名确认,剩余名额为 0,并产生 RegistrationConfirmed

Given 赛事已满
When 玩家 B 报名
Then 拒绝 TournamentFull,状态不改变,不产生成功事件

这个 Given 可通过容量为 2、先报名一位玩家建立。随后验证新增确认、满员拒绝、重复拒绝、关闭状态与空参数,并确认失败前后的成员集合相同。并发版本与事务回滚还要在仓储集成测试中验证,不能只测内存对象就算完成。

下一课处理上下文之间的模型翻译、领域事件与集成事件,以及模型如何在生产反馈中演进。

参考资料 ​

Built with VitePress | Software Systems Atlas