CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
领域驱动设计:实体、值对象、聚合与限界上下文
这一课你将能做到
区分实体(Entity)、值对象(Value Object)、聚合(Aggregate),并在判题平台里各举一例
用限界上下文(Bounded Context)把"用户管理"和"判题执行"拆成两个独立模型
用领域事件(Domain Event)表达"提交已判题"这一业务事实
为什么要学
DDD 不是新框架,是"怎么把业务翻译成代码"的方法论。判题平台里,CfUser 和 CfSubmission 看起来都是"数据表",但前者有生命周期(注册→VIP→过期),后者有业务规则(提交→判题→记录)。DDD 帮你把这些差异显式化,让代码结构 = 业务结构。
先建立图像
实体(Entity) 值对象(Value Object)
───────────── ─────────────────
有唯一标识 无标识,值相等即相等
有生命周期 不可变
例:CfUser(id=123) 例:Email("a@b.com")
例:Submission(id=456) 例:Score(85)
聚合(Aggregate) 限界上下文(Bounded Context)
───────────── ─────────────────────
一组相关对象的边界 模型的语义边界
聚合根控制访问 不同上下文,同一词不同含义
例:Submission + JudgeResult 例:用户上下文 vs 判题上下文
跟着做
步骤 1:实体 vs 值对象
// 实体:有唯一标识,有生命周期
public class CfUser {
private final UserId id; // 唯一标识
private Nickname nickname;
private Email email;
private VipStatus vipStatus;
private LocalDateTime registerAt;
public void upgradeToVip(LocalDateTime expireAt) {
this.vipStatus = VipStatus.active(expireAt);
}
}
// 值对象:无标识,值相等即相等,不可变
public record Email(String value) {
public Email {
if (!value.contains("@")) throw new IllegalArgumentException("非法邮箱");
}
}
public record Score(int value) {
public Score {
if (value < 0 || value > 100) throw new IllegalArgumentException("分数越界");
}
}
UserId 是实体标识,Email 和 Score 是值对象。两个 Email("a@b.com") 相等,但两个 UserId(123) 可能对应不同用户。
步骤 2:聚合与聚合根
聚合是一组"要么一起改,要么都不改"的对象。聚合根是聚合的唯一入口:
// 聚合根:Submission
public class Submission {
private final SubmissionId id; // 聚合根标识
private final UserId userId;
private final LessonId lessonId;
private CodeSnippet code;
private JudgeResult judgeResult; // 聚合内部对象
private SubmissionStatus status;
// 外部只能通过聚合根操作内部对象
public void assignJudgeResult(JudgeResult result) {
this.judgeResult = result;
this.status = result.isAccepted() ? ACCEPTED : REJECTED;
// 产生领域事件
DomainEvents.raise(new SubmissionJudged(this.id, result));
}
}
// 外部代码不能直接操作 JudgeResult,必须通过 Submission
submission.assignJudgeResult(result); // 对
// submission.getJudgeResult().setScore(100); // 错:绕过聚合根
聚合保证业务规则不被绕过:改 JudgeResult 必须走 Submission 的方法。
步骤 3:限界上下文——同一词,不同含义 用户上下文 判题上下文
────────── ──────────
CfUser JudgeUser
- nickname - userId
- email - currentSubmissionCount
- vipStatus - rateLimitQuota
- registerAt - lastSubmitAt
同一个"用户",但模型完全不同
// 用户上下文
package com.coderoadmap.user;
public class CfUser {
private UserId id;
private Nickname nickname;
private Email email;
private VipStatus vipStatus;
}
// 判题上下文
package com.coderoadmap.judge;
public class JudgeUser {
private UserId userId;
private int currentSubmissionCount;
private RateLimitQuota rateLimitQuota;
public boolean canSubmit() {
return currentSubmissionCount < rateLimitQuota.maxPerMinute();
}
}
两个上下文通过 UserId 关联,但模型独立演化。用户上下文改昵称不影响判题上下文。
步骤 4:领域事件——业务事实的显式表达 public record SubmissionJudged(SubmissionId submissionId, JudgeResult result) implements DomainEvent {}
@Service
public class SubmissionService {
private final SubmissionRepository repository;
private final DomainEventPublisher eventPublisher;
public void judge(SubmissionId id, JudgeResult result) {
Submission submission = repository.findById(id).orElseThrow();
submission.assignJudgeResult(result); // 聚合内产生事件
repository.save(submission);
// 发布事件,让其他上下文响应
eventPublisher.publish(new SubmissionJudged(id, result));
}
}
// 判题上下文监听
@Component
public class PracticeRecordUpdater {
@EventListener
public void onSubmissionJudged(SubmissionJudged event) {
// 更新 cf_practice_record
}
}
领域事件让"提交已判题"成为显式的业务事实,其他上下文可以订阅响应。
讲清楚要点
实体 :有唯一标识,有生命周期,可追踪变化。CfUser、Submission 是实体。
值对象 :无标识,值相等即相等,不可变。Email、Score、JudgeResult 是值对象。
聚合 :一组相关对象的边界,聚合根是唯一入口。Submission 是聚合根,JudgeResult 是聚合内部对象。
限界上下文 :模型的语义边界,同一词在不同上下文含义不同。用户上下文 vs 判题上下文。
领域事件 :业务事实的显式表达,让跨上下文协作解耦。
你可能会懵的地方
实体和值对象怎么区分?
问"这个对象需要追踪历史变化吗?"需要→实体;不需要,只看值→值对象。CfUser 要追踪注册时间、VIP 变更,是实体;Email 改了就是新对象,是值对象。
聚合根怎么选?
问"外部操作这个对象时,是否需要保护内部规则?"需要→聚合根。Submission 保护"判题结果不能乱改",是聚合根;JudgeResult 只是数据,不是聚合根。
限界上下文会不会导致重复代码?
会有一点,但换来的是模型独立演化。用户上下文改昵称不影响判题上下文,比"一个 User 类被 10 个模块共用"好维护。
领域事件和消息队列什么关系?
领域事件是"业务事实",消息队列是"传输机制"。领域事件可以本地发布(Spring Event),也可以发到 MQ(Kafka/RabbitMQ)。前者用于进程内解耦,后者用于跨服务解耦。
DDD 是不是过度设计?
对简单 CRUD 是;对复杂业务(如判题平台的多上下文协作)不是。判断标准:业务规则是否复杂到需要显式建模?
错法对照 错法 问题 正解 所有类都当实体,到处 @Entity 值对象也带 id,冗余 值对象用 record,无 id 外部直接改 judgeResult.score 绕过聚合根,规则失效 通过 submission.assignJudgeResult() 一个 User 类被所有模块共用 模型臃肿,改昵称影响判题 限界上下文:用户上下文 + 判题上下文 业务规则写在 Service 里 领域对象贫血,规则散落 规则内聚在聚合根方法里 领域事件直接调其他 Service 紧耦合 发布事件,监听者各自响应
卡点急救
不知道实体还是值对象 :先写个类,问"我需要 equals 按 id 还是按值?"按 id→实体;按值→值对象。
聚合根边界不清 :画个"对象关系图",看哪些对象"要么一起改,要么都不改",圈起来就是聚合。
限界上下文拆不动 :先找"同一词不同含义"的地方(如"用户"在用户管理和判题中的差异),从那里拆。
面试常问
实体和值对象的区别?
答:实体有唯一标识,生命周期可追踪;值对象无标识,值相等即相等,不可变。判题平台里 CfUser 是实体(id 追踪),Email 是值对象(值相等即相等)。
聚合根的作用?
答:聚合根是聚合的唯一入口,保护业务规则不被绕过。外部要改 JudgeResult,必须通过 Submission.assignJudgeResult(),不能直接 setScore。
限界上下文怎么落地?
答:按业务域分包(如 user、judge),同一词在不同上下文用不同模型(如 CfUser vs JudgeUser),通过 id 关联,通过事件协作。
自测三问
JudgeResult 应该是实体还是值对象?为什么?
判题平台里,Submission 和 JudgeResult 谁是聚合根?为什么?
用户上下文和判题上下文都有"用户"概念,应该怎么设计模型?
小结一句话 DDD 让代码结构 = 业务结构:实体管身份,值对象管数据,聚合管边界,限界上下文管语义,领域事件管协作。
延伸