DDD 领域驱动设计(04)· 战术设计(上):实体、值对象与聚合

发布时间:2026/10/24 2:22:40
DDD 领域驱动设计(04)· 战术设计(上):实体、值对象与聚合 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 聚合状态机的自洽性账户余额 ≥ 0Account 聚合单账户自身的不变量全平台库存不为负Stock 聚合库存数属于自己的聚合不属于订单同一商品不能同时出现在两个进行中的秒杀秒杀活动聚合跨商品的一致性归活动管反过来用这条判据更关键如果一条规则不需要即时、事务性地保证就不要为它合并聚合——用事件 最终一致性兜底即可。合并聚合的诱惑永远是省事但代价是锁范围和无谓的强耦合。我常用的一个反向检验是问自己如果我把这条规则降级成最终一致业务会不会出错——比如用户等级满 3 才能享受会员价这是下单那一刻要强校验的归订单聚合而用户累计消费达到某值升级等级完全可以等异步任务算完再升级它就不该被拉进任何一个交易聚合否则每次下单都要锁用户的等级链路。不变量是聚合的宪法但不是所有规则都是宪法——分不清这两者是聚合画大的根因。六、完整代码订单聚合把前面所有原则落到一个能直接用的Order上public class Order { private OrderId id; private UserId userId; private ListOrderItem items; private Money totalAmount; private Address address; private OrderStatus status; private final ListDomainEvent events new ArrayList(); // 待发布事件 // 静态工厂创建即保证所有不变量成立 public static Order create(UserId userId, ListOrderItem 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 ListDomainEvent pullDomainEvents() { ListDomainEvent 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 · 战术设计下领域服务、领域事件、仓储与工厂—— 当一个事务只改一个聚合之后跨聚合的协作到底怎么落地。关于作者技术原点YuandianIT985 计算机硕士十年一线研发经历先后在头部互联网公司做交易与履约现于金融行业负责信贷核心、风控与账务系统的架构设计累计面试近千人带过 50 新人长期负责技术团队的技术选型与架构评审关注方向复杂业务系统建模DDD / 事件驱动 / 一致性设计与AI 工程化落地写作原则只写自己动手做过的只给能落地的结论方案必须带代价我信一句话架构的优劣不在图上在第三次需求变更时的手感上。关于作者技术原点YuandianIT985 计算机硕士十年一线研发经历先后在头部互联网公司做交易与履约现于金融行业负责信贷核心、风控与账务系统的架构设计累计面试近千人带过 50 新人长期负责技术团队的技术选型与架构评审。关注方向为复杂业务系统建模DDD / 事件驱动 / 一致性设计与 AI 工程化落地写作原则只写自己动手做过的只给能落地的结论方案必须带代价。关于「技术原点」开源项目技术原点是一个基于 Spring Boot 3 Vue 3 前后端分离的自有技术平台把「内容创作 付费专栏 AI 辅助 资源交易」完整跑通——不是一个博客 Demo能力说明 文章系统Markdown 创作、分类标签、归档时间线、阅读量 / 点赞 / 收藏、关键词搜索 Wiki 知识库专栏化知识库、目录树导航、整册解锁 —— 本 DDD 系列会整理成册放在这里 AI 助手接入智谱 / OpenAI / 通义千问 / DeepSeek 多模型支持 SSE 流式追问 付费阅读四级阅读门禁与付费专栏支持单篇与整册 社区商城源码与服务商品、微信 Native 扫码支付、订单与下载鉴权⚙️ 后台管理内容与用户管理、sa-token 权限、操作日志、定时任务、Knife4j 接口文档项目已开源源码地址。搜索「技术原点」即可找到我的全部内容——DDD 系列文章与 Wiki 成册阅读都在技术原点平台。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询