20.2 战术建模:实体、值对象、聚合与仓储
战术模式的目标是让业务不变量在模型中有唯一、可执行的归属。不是每张表对应一个 Entity,也不是所有关联对象都放进一个 Aggregate。
Entity 由身份和生命周期定义
本页使用 Java 21,各个 public 类型分别保存到同名文件。PlayerId 是项目提供的不可变玩家标识;报名事件的定义见下一课,业务异常也由项目定义。
实体即使属性变化,仍被视为同一个对象。身份必须稳定:
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 由值定义
值对象没有独立身份,适合表达带规则的度量和概念:
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;状态切换和数据库重建流程未包含在这个示例中。
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 面向聚合根
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 是项目类型:
import java.util.List;
public interface SeedingPolicy {
Bracket seed(List<QualifiedPlayer> players, RankingSnapshot ranking);
}例如种子排序可以按本次冻结的排名降序、玩家 ID 升序打破平手,再生成对阵。是否需要随机抽签是业务规则;若需要,保存抽签结果或随机种子,不能恢复时悄悄换一套对阵。该规则只依赖传入快照,取排名和安排事务由应用服务完成。
领域服务不等于所有业务逻辑的收容所。能够放进实体和值对象的行为应靠近状态;需要事务、仓储、消息和权限编排的是应用服务。
Factory 保护复杂创建
当创建聚合需要多步校验、策略或多个值对象时,Factory 可以表达“如何得到一个有效初始聚合”。简单构造不必机械增加 Factory。
聚合测试关注状态转换
Given 赛事开放且剩余 1 个名额
When 玩家 A 报名
Then 报名确认,剩余名额为 0,并产生 RegistrationConfirmed
Given 赛事已满
When 玩家 B 报名
Then 拒绝 TournamentFull,状态不改变,不产生成功事件这个 Given 可通过容量为 2、先报名一位玩家建立。随后验证新增确认、满员拒绝、重复拒绝、关闭状态与空参数,并确认失败前后的成员集合相同。并发版本与事务回滚还要在仓储集成测试中验证,不能只测内存对象就算完成。
下一课处理上下文之间的模型翻译、领域事件与集成事件,以及模型如何在生产反馈中演进。
参考资料
- Martin Fowler, Value Object
- Martin Fowler, DDD Aggregate
- Martin Fowler, Repository