DDD-04-战术设计-实体值对象与聚合
进入限界上下文内部,开始真正的建模。本篇讲三块基石:实体、值对象、聚合。其中聚合是 DDD 战术设计最核心的模式——它就是一致性的边界,也是事务的边界。而"聚合该画多大",是我见过最容易被拍脑袋决定、也最贵的一个决定。
一、先看看我们都写过的那段转账代码
先不谈概念。看一段你大概率维护过、或者至少见过的"转账"代码:
// 一个聚合里塞进了两个账户 public class AccountGroup { // ← 巨型聚合 private Account from; private Account to; public void transfer(BigDecimal amount) { from.deduct(amount); to.add(amount); } } @Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { AccountGroup group = repository.load(fromId, toId); // 一把锁锁住两个账户 group.transfer(amount); repository.save(group); }这段代码在"功能"上完全正确。但它在并发下会怎样?
设想此刻有四笔转账同时进来:A→C、B→D、A→E、B→F。注意 A 和 B 都在同一个AccountGroup里。于是这四笔本可以并行的转账,全被这把"覆盖整个聚合"的锁串行排队了——哪怕 A→C 和 B→D 操作的账户完全不重叠,也得一个一个来。
这不是我编出来的事故,是真实系统里会发生的现场:聚合越大,事务锁的范围越大,能并行的就越少。我把这个教训写成这一篇的标题,因为它比任何教科书定义都来得痛——它在压测那天一次性结清,而那恰恰是最贵的一种结账方式。
在动手修这个坑之前,我得先把"聚合"之前的两块基石讲清楚。因为后面所有边界判断,都建立在"实体"和"值对象"这两个词分得很清的前提下。这俩要是混了,聚合边界多半也画歪。
二、回到建模的第一刀:它"关心身份"还是"只关心值"
建模任何一个概念之前,我先问自己一个问题:业务在乎它是"哪一个",还是只在乎它"是什么值"?
- 100 元就是 100 元,和"是哪一张钞票"无关 → 这是值对象;
- 用户张三,即使改了昵称、换了手机号,他还是同一个人 → 这是实体。
这一刀切错了,后面聚合边界多半也画歪。所以先把它俩钉死。
实体(Entity):有身份证、有生命历程
实体是有唯一标识、在生命周期内连续存在、并且靠 ID 判断相等性的对象。
public class User { private UserId id; // 身份标识,永不变 private String nickname; // 昵称可以改 private String phone; // 手机号也可以改 @Override public boolean equals(Object o) { // 只要 id 相同就是同一个用户,即使其余字段全不同 return o instanceof User && ((User) o).id.equals(this.id); } // 行为:实体不是数据袋子 public void changePhone(String newPhone, SmsVerifier verifier) { if (!verifier.verify(newPhone)) { throw new DomainException("手机号验证失败"); } this.phone = newPhone; this.registerEvent(new UserPhoneChanged(id, newPhone)); } }注意两件事。第一,equals只比id——张三换了手机号,他还是张三。第二,实体不是数据袋子:changePhone把"验证 + 改值 + 发事件"收在同一个方法里,而不是散在外面的 Service。特征清单就四样:有 ID、有生命周期、状态可变、行为内聚。
值对象(Value Object):没有身份证、用完即弃
值对象没有唯一标识,靠属性值判断相等性,而且不可变。
public class Money { private final BigDecimal amount; // final:不可变 private final Currency currency; public Money(BigDecimal amount, Currency currency) { if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new DomainException("金额不能为负"); // 校验内聚在构造器,全系统统一生效 } this.amount = amount; this.currency = currency; } public Money add(Money other) { requireSameCurrency(other); // 返回新对象而不是修改自己 —— 不可变 return new Money(amount.add(other.amount), currency); } @Override public boolean equals(Object o) { // 金额和币种都相同,就是相等的钱 Money m = (Money) o; return amount.compareTo(m.amount) == 0 && currency == m.currency; } }Money没有 id,两个"100 元 + CNY"天生相等;它final不可变,add返回新对象而不是改自己。
值对象有三大好处,这也是我强烈建议"能用值对象就别用实体"的原因:
- 无生命周期管理成本:不需要 Repository、不需要并发锁,用完即弃;
- 天然线程安全:不可变,随便在多线程里传;
- 校验内聚:"金额不能为负"写在构造器里,全系统不可能出现负金额的
Money实例——这条规则你再也不用担心有人在别处 new 出一个负的来。
只要一个概念"只关心值、不关心个体身份",就优先建模成值对象:金额、地址、日期区间、经纬度、身份证号、手机号。经验法则:能用值对象就不要用实体——它便宜得多。
还有一个不太被提及的好处:值对象是通用语言最好的载体。Money、Address、DateRange这些类一旦出现,方法签名就从transfer(BigDecimal a, BigDecimal b)变成transfer(Money amount)——参数的含义、单位、合法性全都写在了类型里。新人读代码时不用再猜"这个 BigDecimal 是元还是分",编译器替你把关。我 review 代码时看到一串 BigDecimal 参数就知道,这里的业务语言还没有沉淀成模型。
💡我的设计笔记 · 一
实体和值对象的取舍,本质上是"这个东西要不要被单独追踪"的取舍。我在建模时默认从值对象出发:先假设它没有身份,除非业务真的需要"区分这两个长一样的",才把它升级成实体。
这一刀切对后面聚合边界有直接影响——值对象可以随意嵌套进聚合,没有独立的 Repository,也不会带来一致性负担;一旦你把一个本该是值对象的东西误建成实体,它就会要求一条独立的生命周期链路,徒增聚合的复杂度和锁的粒度。所以我宁愿在开头多问一句"它真的需要身份证吗",也不愿在后面为多余的实体付锁的代价。
三、聚合与聚合根:一致性边界 = 事务边界 ★
讲完两块基石,主角登场。
聚合 = 一组必须作为一个整体保持一致性的对象集合;聚合根 = 这个集合对外的唯一入口。
一个订单聚合长这样:
图里三层关系要记住:外部代码只能调用聚合根Order;内部的OrderItem直接访问会被画叉禁止;Product、User是别的上下文的聚合根,Order只存它们的ID,不存对象。
聚合设计三条铁律
这三条我写在每张设计评审的 checklist 上,顺序不能乱:
- 外部只能引用聚合根——不能绕过它直接操作内部对象。
order.getItems()可以返回集合,但修改必须通过聚合根的方法; - 聚合之间只通过 ID 引用,不持有对象引用。
Order里存ProductId,而不是Product对象; - 一个事务只修改一个聚合,跨聚合的协调交给领域事件(05、08 篇展开)。
为什么这么设计?因为聚合就是一致性边界 = 事务边界
把上面三条翻过来看,它们服务的只有一个目标:一个事务的范围,刚好等于一个聚合的范围。锁只在聚合内部加,跨聚合的协作交给事件(最终一致性)。
而一旦你违反它——把聚合画得过大——第一段那个转账的并发地狱就来了。这也是为什么"聚合该画多大"值得用一整篇来掰扯:它不是审美问题,是吞吐量和一致性的硬约束。
四、聚合画大了会怎样:回到转账
我们把第一段的代码抽象成一张图:
A和B被塞进同一个大聚合,意味着一次转账就要锁住这个聚合里的所有账户。于是:
- 转账
A→C拿到锁,锁的是"含 A、B 的整个聚合"; - 转账
B→D也要拿同一把锁,只能等A→C释放; - 哪怕两笔转账的账户毫无重叠,也得串行。
聚合越大 → 事务锁的范围越大 → 并发越差 → 越容易出现"改了 A 忘了 B"的一致性 bug。能拆小就拆小,跨聚合协作宁可走事件(牺牲一点实时性)也不要划大聚合(牺牲全部并发)。
如果你接手的就是这样一个大聚合,怎么退回来?我的做法分三步:第一步,列出这个聚合身上的所有不变量,逐条标注它真正需要的一致性强度(即时事务性 / 秒级最终一致即可);第二步,把"单对象自身的不变量"拆出去——比如"余额 ≥ 0"本来就是每个账户自己的事,AccountGroup里的 from、to 各自独立成聚合,转账时分别加载、分别校验;第三步,跨账户的传递性要求(钱不能凭空消失)改由领域事件串起来——扣款事务成功后发出MoneyDebited事件,入账侧消费事件完成加钱并保证幂等。三步走完,锁粒度从"整个组"降到"单个账户",并发立刻回来。代价是要处理事件的幂等、乱序和补偿——这部分正是 05 和 08 篇要展开的内容,天下的便宜没有白占的。
💡我的设计笔记 · 二
"聚合宁小勿大"是我交过学费才刻进肌肉记忆的话。早年我把"一次转账涉及的两个账户"做成一个聚合,理由是"转账要保证两边余额同时改动,强一致嘛"。听起来很对,结果压测时并发上不去,排查半天才发现是这把大锁把所有转账串行化了。
教训是:强一致的需求,要的是"一个聚合内强一致",不是"把所有相关对象都塞进一个聚合"。两个账户的余额各自有"余额 ≥ 0"的不变量,这本来就是两个独立聚合各自保证的事;跨账户的"这笔钱从 A 到 B 不能丢"靠领域事件 + 最终一致性兜底,实时性差几个毫秒,却换回了全部并发能力。聚合画大的代价,往往在压测那天才一次性结清——而这恰恰是最贵的一种结账方式。
五、怎么判断"哪些对象该进同一个聚合"
判据只有一个词:不变量(Invariant)——哪条业务规则必须时刻、事务性地成立,保护这条规则的对象就放进同一个聚合。
| 不变量 | 归属聚合 | 理由 |
|---|---|---|
| 订单总额 = 所有明细之和 | Order 聚合 | 明细变化必须即时反映在总额,同一事务 |
| 订单状态流转合法(已支付才能发货) | Order 聚合 | 状态机的自洽性 |
| 账户余额 ≥ 0 | Account 聚合 | 单账户自身的不变量 |
| 全平台库存不为负 | Stock 聚合 | 库存数属于自己的聚合,不属于订单 |
| "同一商品不能同时出现在两个进行中的秒杀" | 秒杀活动聚合 | 跨商品的一致性,归活动管 |
反过来用这条判据更关键:如果一条规则不需要"即时、事务性"地保证,就不要为它合并聚合——用事件 + 最终一致性兜底即可。合并聚合的诱惑永远是"省事",但代价是锁范围和无谓的强耦合。
我常用的一个反向检验是:问自己"如果我把这条规则降级成最终一致,业务会不会出错?"——比如"用户等级满 3 才能享受会员价",这是下单那一刻要强校验的,归订单聚合;而"用户累计消费达到某值升级等级",完全可以等异步任务算完再升级,它就不该被拉进任何一个交易聚合,否则每次下单都要锁用户的等级链路。不变量是聚合的"宪法",但不是所有规则都是宪法——分不清这两者,是聚合画大的根因。
六、完整代码:订单聚合
把前面所有原则落到一个能直接用的Order上:
public class Order { private OrderId id; private UserId userId; private List<OrderItem> items; private Money totalAmount; private Address address; private OrderStatus status; private final List<DomainEvent> events = new ArrayList<>(); // 待发布事件 // 静态工厂:创建即保证所有不变量成立 public static Order create(UserId userId, List<OrderItem> items, MemberGrade grade, Address address) { Order order = new Order(); order.id = OrderId.generate(); order.userId = userId; order.items = List.copyOf(items); // 防御性拷贝 order.address = Objects.requireNonNull(address); order.totalAmount = items.stream() .map(OrderItem::subtotal) .reduce(Money.zero(), Money::add); // 不变量 1:总额必须等于明细之和 if (!order.totalAmount.equals(sumOf(items))) { throw new DomainException("订单金额与明细不一致"); } // 不变量 2:限额规则(业务规则写在领域模型里,而非 Service) if (grade.below(MemberGrade.VIP) && order.totalAmount.greaterThan(Money.of(10000))) { throw new DomainException("非 VIP 单笔限额 10000 元"); } order.status = OrderStatus.CREATED; order.events.add(new OrderCreated(order.id, userId, order.totalAmount)); return order; } // 状态流转唯一入口,杜绝外部 setStatus public void pay(Money paid) { if (status != OrderStatus.CREATED) { throw new DomainException("当前状态不可支付: " + status); } if (!paid.equals(totalAmount)) { throw new DomainException("支付金额与应付金额不一致"); } this.status = OrderStatus.PAID; this.events.add(new OrderPaid(id, paid)); } public void ship() { if (status != OrderStatus.PAID) { throw new DomainException("未支付不可发货"); } this.status = OrderStatus.SHIPPED; this.events.add(new OrderShipped(id)); } public List<DomainEvent> pullDomainEvents() { List<DomainEvent> pulled = List.copyOf(events); events.clear(); return pulled; } }对比传统写法order.setStatus(2)这种"任何代码都能把订单改成任何状态"的口子,被彻底关掉了。状态机的合法流转,只存在于Order类内部。不变量在工厂里一次性校验,事件在方法里就地登记,外部永远走聚合根。
注意这里的items = List.copyOf(items)防御性拷贝——外部拿到的引用改了,不会影响聚合内部;事件也只在聚合内部产生,由应用服务统一 pull 出去。这些细节都是为了守住"聚合是唯一一致性边界"这句话,不让边界从内部被悄悄击穿。
七、实体 vs 值对象 vs 聚合根:速查表
| 维度 | 实体 | 值对象 | 聚合根 |
|---|---|---|---|
| 相等性 | 按 ID | 按属性值 | 按 ID |
| 可变性 | 可变 | 不可变 | 可变 |
| 有无独立生命周期 | 有(可独立存在) | 无(依附实体/聚合) | 有 |
| 是否有 Repository | 聚合内实体没有 | 没有 | 有(每聚合一个) |
| 事务一致性 | 由所属聚合保证 | — | 自己是边界 |
把这张表贴在工位上,每次有人想"把两个聚合合成一个"时,先看一眼"聚合之间只通过 ID 引用"那一条铁律还成不成立。这张表还有一个隐蔽的用法:反过来查冗余——如果你的代码里给一个值对象也建了 Repository、配了独立数据表,那多半是把它误建成了实体;如果一个"实体"从来不需要被单独查出来,那它可能只配做值对象。建模错误会在表结构上留疤,这张表就是照疤的镜子。
三条铁律我做成了一张收藏卡,方便你评审时直接对照:
八、本篇小结
本篇三件事:第一,用"关心身份还是关心值"区分实体与值对象,能用值对象就用值对象——它便宜、线程安全、校验内聚;第二,聚合 = 一致性边界 = 事务边界,用不变量决定哪些对象进同一个聚合;第三,聚合设计三条铁律(只引用根 / 只存 ID / 一事务一聚合),宁小勿大——我用一段真实的转账并发地狱说明:聚合画大的代价不在代码里,而在压测那天的吞吐量上一次性结清。
下一篇讲聚合之间到底怎么协作:领域事件、领域服务、仓储与工厂——它们把"一个事务只改一个聚合"这句狠话真正落地。
九、系列目录
| 篇 | 主题 | 一句话 |
|---|---|---|
| 00 | 系列导读 | 这条学习路线怎么走,三类人怎么读 |
| 01 | 为什么需要 DDD | 规则副本的代价与三条原则 |
| 02 | 战略设计(上) | 子域三分法、事件风暴、通用语言表 |
| 03 | 战略设计(下) | 限界上下文与六种上下文映射模式 |
| 04 | 战术设计(上) | 本文:实体、值对象、聚合与三条铁律 |
| 05 | 战术设计(下) | 领域服务、领域事件、仓储与工厂 |
| 06 | 架构模式 | 四层架构、六边形架构与依赖倒置 |
| 07 | 实战案例一 | 电商订单系统:聚合与事件驱动建模 |
| 08 | 实战案例二 | 银行转账:跨聚合的最终一致性 |
| 09 | 实战案例三 | 40 万行单体如何按上下文渐进拆分 |
| 10 | 落地与误区 | 六步路线图、七个高频坑、体检清单 |
下一篇:05 · 战术设计(下):领域服务、领域事件、仓储与工厂—— 当"一个事务只改一个聚合"之后,跨聚合的协作到底怎么落地。
关于作者
技术原点(YuandianIT)
- 985 计算机硕士,十年一线研发经历,先后在头部互联网公司做交易与履约,现于金融行业负责信贷核心、风控与账务系统的架构设计
- 累计面试近千人,带过 50+ 新人,长期负责技术团队的技术选型与架构评审
- 关注方向:复杂业务系统建模(DDD / 事件驱动 / 一致性设计)与AI 工程化落地
- 写作原则:只写自己动手做过的,只给能落地的结论,方案必须带代价
我信一句话:架构的优劣不在图上,在第三次需求变更时的手感上。
关于作者
技术原点(YuandianIT):985 计算机硕士,十年一线研发经历,先后在头部互联网公司做交易与履约,现于金融行业负责信贷核心、风控与账务系统的架构设计;累计面试近千人,带过 50+ 新人,长期负责技术团队的技术选型与架构评审。关注方向为复杂业务系统建模(DDD / 事件驱动 / 一致性设计)与 AI 工程化落地;写作原则:只写自己动手做过的,只给能落地的结论,方案必须带代价。
关于「技术原点」开源项目
技术原点是一个基于 Spring Boot 3 + Vue 3 前后端分离的自有技术平台,把「内容创作 + 付费专栏 + AI 辅助 + 资源交易」完整跑通——不是一个博客 Demo:
| 能力 | 说明 |
|---|---|
| 📝 文章系统 | Markdown 创作、分类标签、归档时间线、阅读量 / 点赞 / 收藏、关键词搜索 |
| 📚 Wiki 知识库 | 专栏化知识库、目录树导航、整册解锁 —— 本 DDD 系列会整理成册放在这里 |
| 🤖 AI 助手 | 接入智谱 / OpenAI / 通义千问 / DeepSeek 多模型,支持 SSE 流式追问 |
| 💰 付费阅读 | 四级阅读门禁与付费专栏,支持单篇与整册 |
| 🛒 社区商城 | 源码与服务商品、微信 Native 扫码支付、订单与下载鉴权 |
| ⚙️ 后台管理 | 内容与用户管理、sa-token 权限、操作日志、定时任务、Knife4j 接口文档 |
项目已开源,源码地址。
搜索「技术原点」,即可找到我的全部内容——DDD 系列文章与 Wiki 成册阅读都在技术原点平台。