news 2026/10/24 2:22:38

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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返回新对象而不是改自己。

值对象有三大好处,这也是我强烈建议"能用值对象就别用实体"的原因:

  1. 无生命周期管理成本:不需要 Repository、不需要并发锁,用完即弃;
  2. 天然线程安全:不可变,随便在多线程里传;
  3. 校验内聚:"金额不能为负"写在构造器里,全系统不可能出现负金额的Money实例——这条规则你再也不用担心有人在别处 new 出一个负的来。

只要一个概念"只关心值、不关心个体身份",就优先建模成值对象:金额、地址、日期区间、经纬度、身份证号、手机号。经验法则:能用值对象就不要用实体——它便宜得多。

还有一个不太被提及的好处:值对象是通用语言最好的载体。Money、Address、DateRange这些类一旦出现,方法签名就从transfer(BigDecimal a, BigDecimal b)变成transfer(Money amount)——参数的含义、单位、合法性全都写在了类型里。新人读代码时不用再猜"这个 BigDecimal 是元还是分",编译器替你把关。我 review 代码时看到一串 BigDecimal 参数就知道,这里的业务语言还没有沉淀成模型。

💡我的设计笔记 · 一

实体和值对象的取舍,本质上是"这个东西要不要被单独追踪"的取舍。我在建模时默认从值对象出发:先假设它没有身份,除非业务真的需要"区分这两个长一样的",才把它升级成实体。

这一刀切对后面聚合边界有直接影响——值对象可以随意嵌套进聚合,没有独立的 Repository,也不会带来一致性负担;一旦你把一个本该是值对象的东西误建成实体,它就会要求一条独立的生命周期链路,徒增聚合的复杂度和锁的粒度。所以我宁愿在开头多问一句"它真的需要身份证吗",也不愿在后面为多余的实体付锁的代价。

三、聚合与聚合根:一致性边界 = 事务边界 ★

讲完两块基石,主角登场。

聚合 = 一组必须作为一个整体保持一致性的对象集合;聚合根 = 这个集合对外的唯一入口。

一个订单聚合长这样:

图里三层关系要记住:外部代码只能调用聚合根Order;内部的OrderItem直接访问会被画叉禁止;Product、User是别的上下文的聚合根,Order只存它们的ID,不存对象。

聚合设计三条铁律

这三条我写在每张设计评审的 checklist 上,顺序不能乱:

  1. 外部只能引用聚合根——不能绕过它直接操作内部对象。order.getItems()可以返回集合,但修改必须通过聚合根的方法;
  2. 聚合之间只通过 ID 引用,不持有对象引用。Order里存ProductId,而不是Product对象;
  3. 一个事务只修改一个聚合,跨聚合的协调交给领域事件(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 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 成册阅读都在技术原点平台。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:22:33

2026年AI岗位激增14倍,传统程序员如何抓住最后转型窗口期?

2026年AI岗位激增14倍&#xff0c;传统程序员入门级岗位断崖式下跌。就业市场分化严重&#xff0c;AI工程师薪资远超传统程序员。转型窗口期就在现在&#xff0c;学历门槛尚可&#xff0c;企业还在抢人。程序员需根据自身情况选择智能体开发、AI应用开发或算法工程师等方向&…

作者头像 李华
网站建设 2026/10/24 2:22:22

力扣刷题小集

LeetCode.347前k个高频元素思路&#xff08;1&#xff09;统计频次&#xff1a;用unordered_map遍历nums原数组&#xff0c;key存数组元素值&#xff0c;value存该元素出现次数。&#xff08;2&#xff09;维护大小为 k 的小顶堆(为什么选择小顶堆而不是大顶堆如下图)&#xff…

作者头像 李华
网站建设 2026/10/24 2:22:03

前端转全栈不再难!2026年必备技能,轻松拿回技术评审主动权

文章探讨了前端在技术评审中常感到的无力感&#xff0c;以及如何通过转型全栈来克服这种困境。文章指出&#xff0c;2026年的全栈开发门槛已大幅降低&#xff0c;得益于平台自动化和AI辅助&#xff0c;前端只需专注核心技能如HTTP、后端逻辑、数据库和接口设计&#xff0c;八到…

作者头像 李华
网站建设 2026/10/24 2:21:57

小白/程序员必看:收藏这份Agent评测指南,解锁大模型行动力!

本文探讨了传统大模型评测方法在Agent评估中的局限性&#xff0c;强调了评估Agent需关注行动过程而非仅看结果。美团Agent评测体系提出&#xff0c;应评估由模型、Prompt、工具等组成的复杂系统&#xff0c;通过轨迹评估和结果评估两类问题&#xff0c;确保Agent稳定上线。文章…

作者头像 李华
网站建设 2026/10/24 2:20:58

Zend联合创始人Zeev离职,背后真相揭秘

昨日的消息显示, 联合创始人Zeev已经在推特上宣布说自己已经离职了, 原因是他在工作长达20年之后离开了公司。Zeev 他在推特这个平台上面用来做自我介绍的话说, 他是叫做 PHP 的联席架构师的职位的人, 他也是 Zend 这个公司的联合创始人, 这两个头衔里面有个细节要注意, 原文写…

作者头像 李华