跳到内容

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

进入领域建模室后,阿花发现数据库表已经替业务做了决定:谁负责维护不变量,代码里反而无人回答。

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

Entity 由身份和生命周期定义

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

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

不要用所有可变字段生成实体的 equals/hashCode,否则对象进入集合后修改字段会破坏查找。实体相等性通常基于同一类型和稳定身份。

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) {
        return confirmedEntries < value;
    }
}

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

Aggregate 是一致性边界

Aggregate 是一组在一次事务中维护一致性的对象,Aggregate Root 是外部修改它们的唯一入口。

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

    public RegistrationConfirmed register(PlayerId 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);
        }

        confirmed.add(playerId);
        return new RegistrationConfirmed(id, playerId, confirmed.size());
    }
}

“人数不超过容量”和“同一玩家不重复确认”由同一个根维护。外部不能取得可变集合后直接 add

聚合要尽量小

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

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

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

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

Repository 面向聚合根

java
public interface TournamentRepository {
    Optional<Tournament> findById(TournamentId id);
    void save(Tournament tournament, long expectedVersion);
}

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

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

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

一个业务决策若涉及多个概念,却不自然属于某个实体或值对象,可以使用无状态领域服务:

java
public final class SeedingPolicy {
    public Bracket seed(List<QualifiedPlayer> players, RankingSnapshot ranking) {
        // 领域算法,不访问 HTTP 或数据库
    }
}

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

Factory 保护复杂创建

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

聚合测试关注状态转换

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

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

测试应覆盖不变量、幂等/重复命令、并发版本和失败原子性,而不只覆盖 getter。

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

参考资料

Built with VitePress | Software Systems Atlas