ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

领域驱动设计DDD入门:通用语言、限界上下文与聚合根实践

领域驱动设计DDD入门:通用语言、限界上下文与聚合根实践

领域驱动设计(DDD)这套方法论,我在三个不同规模的项目里试过,前两次都做成了“分层包结构 + 一堆只有 getter/setter 的实体类”的空壳,第三次才勉强摸到点门道。第一次是照着网上的分层图抄目录,domain 包里塞的全是数据库映射对象;第二次是团队喊口号要“统一语言”,结果术语表写完就锁进文档库,没人再看。真正让我回头补基础的,是一次订单重构:一个三千多行的服务类里,退款规则、优惠叠加、库存扣减全缠在一起,改一个促销策略要回归测试两天。后来我们把限界上下文重新划了一遍,才明白问题不在于代码写得丑,而在于我们从来没把业务概念想清楚过。这篇笔记只谈基础理论和概念,不涉及具体框架选型,面向的是那些写过几年业务代码、被复杂规则反复折磨、想搞清楚 DDD 到底在说什么的同学。概念这东西看着虚,但它决定了你后面每一个包、每一层、每一个类的摆放位置。

1. 先搞清楚 DDD 想解决的是什么问题

1.1 业务复杂度是怎么一步步失控的

大部分系统不是一开始就烂的。第一个版本通常很干净:一张订单表,一个 OrderService,下单、支付、发货三个方法,逻辑直白到闭着眼都能写。问题出在第二个版本——运营要加阶梯折扣,第三个版本——财务要区分含税金额和不含税金额,第四个版本——售后要支持部分退款且退款要按分摊后的优惠重算。每一次需求都不算大,但每一次都在原来的方法里插一段 if。半年后你打开这个文件,会发现里面有十七个布尔参数和六个嵌套判断,注释写着“此处逻辑较复杂,勿动”。

这种失控的本质不是代码风格问题,而是业务规则的增长方式和技术实现的组织方式不匹配。业务概念是有层次的:订单是一个整体,订单项是它的一部分,金额是一个有精度和币种的值,退款是一个改变了订单状态的事件。但当这些概念全部被拍平成数据库表和 SQL 之后,层次就消失了。你在代码里看到的不是“订单”,而是“order 表的一行数据外加三个关联查询”。

DDD 把这个现象叫做模型与实现脱节。它的核心主张很朴素:既然业务本身是有结构的,代码结构就应该长成业务的样子,而不是长成数据库的样子。这句话听起来像口号,但落到实处的差别巨大:当你需要回答“一个已发货订单能不能部分退款”这个问题时,前者你能在 Order 类里找到对应方法一眼看明白,后者你得翻三个 service 加两张关联表。

我个人的判断标准是:如果一个业务规则,你需要向三个以上不熟悉这块代码的同事解释才能说清,那它大概率值得被建模成一个显式的对象或方法,而不是散在流程里的判断分支。

1.2 DDD 能解决什么,不能解决什么

这里必须泼一盆冷水。DDD 的定位非常明确,它处理的是业务领域的复杂度,不是技术复杂度。很多人学完之后拿着聚合、领域事件往所有项目上套,结果一个只有五个接口的后台管理模块被拆成了十二个工程,团队怨声载道。这不是 DDD 的问题,是用错了地方。

简单判断一下适用性:

场景特征是否适合 DDD原因
业务规则多、变化频繁、有大量不变量约束适合模型能承载规则,改动集中在一处
多个团队协作,同一术语在不同团队含义不同适合限界上下文能给出明确的边界
系统生命周期长,需持续演进三五年适合模型可维护性带来的收益随时间放大
以数据录入、报表查询为主的后台不适合本质是数据搬运,建模成本远超收益
接口数量少、规则简单、生命周期短不适合分层和抽象反而增加理解成本
性能敏感的高并发写入通道部分适合战术模式要谨慎,聚合设计需让步于吞吐

还有一类常被误解的情况:有人以为引入 DDD 就能自动获得“高内聚低耦合”。实际上聚合划错、上下文边界划错,耦合只会变得更隐蔽——因为跨聚合的调用被包装成了领域事件,表面上没有直接依赖,实际上一致性全乱了。工具本身不解决问题,想清楚才解决。

1.3 学习顺序:先战略,后战术,最后才是代码结构

我踩过的最大的坑就是顺序搞反了。第一反应永远是“目录怎么建、包怎么分、要不要引入某某框架”,这些都是战术层的细枝末节。真正决定成败的是战略设计:这个系统里有几个业务领域?每个领域的边界在哪?团队之间怎么协作?

一个更靠谱的学习路径是这样的:先弄懂通用语言和限界上下文这两个概念,因为它们决定了后面所有东西的归属;再理解子域划分,搞清楚哪块业务值得投入最强的人力;然后才进入战术层,学实体、值对象、聚合、领域事件这些构件;最后才是落地形态——分层、模块划分、仓储实现。

注意:不要在第一个项目上就全面推行 DDD。找一个规则最密集、最让人头疼的子域先做试点,跑通了再推广。全面铺开的结果通常是所有模块都套上了 DDD 的壳,但没有任何一个模块真正用上了 DDD 的核。

另外一个经验:概念不是考核项。有些团队热衷于在评审会上追问“这到底是实体还是值对象”,把建模变成了学术辩论。判断标准应该很务实——这个对象的身份标识重要吗?如果两个对象的属性完全一样,业务上是否认为它们是同一个东西?答案清楚,分类就清楚,争不出结果说明业务本身没想清楚,该去问业务方而不是查书。

2. 战略设计:边界比代码重要得多

2.1 通用语言:从一张术语表开始

通用语言(Ubiquitous Language)是整个 DDD 里最不技术、也最容易被跳过的一环。它的意思很直白:业务方、产品、开发、测试,在讨论同一个业务概念时,用的是同一个词,而且这个词在代码里的命名和它的业务含义严格一致。

反例到处都是。业务方说“订单要关闭”,代码里对应的状态是status = 4;运营说“这单要作废”,其实指的是同一个动作;新人接手时看到invalid、closed、canceled三个字段,得花半天才能搞明白它们各自的语义。这种词义漂移在系统小的时候只是别扭,系统大了就是事故源头——你永远不确定某个逻辑到底该改哪一处。

我们当时的做法是建一张术语表,格式很土但很有用:

业务术语精确定义对应代码命名明确排除的含义
订单关闭用户主动取消且未支付成功close()/CLOSED不等于发货完成
订单完成已签收且售后期结束complete()/COMPLETED不等于已支付
金额最小货币单位的整数,含币种Money不用浮点数表示
优惠分摊按订单项金额比例拆分优惠额allocateDiscount()不涉及退款计算

这张表的价值不在于写得多漂亮,而在于它逼着团队把含糊的地方说清楚。填表过程中我们发现,“已支付”这个词在两个模块里含义完全不同:支付模块指“支付渠道回调成功”,订单模块指“本地账单状态已确认”。这两个含义在某些边界情况下会不一致,而之前的代码默认它们永远相等。这个发现直接影响了后面限界上下文的划分。

实操建议:术语表不要一次写全,从最容易混淆的十个词开始,每次需求评审遇到新歧义就补一条。术语进代码也很重要,方法名、类名、状态枚举尽量用表里的词,别用拼音缩写和flag1、type2这种命名。

2.2 限界上下文:同一个词在不同地方含义不同

限界上下文(Bounded Context)是 DDD 里最核心的一个概念,我的理解是:一个词、一个模型、一套规则能够保持一致生效的边界。跨出这个边界,同一个词可以有完全不同的含义,而且这种不同是合理的、不需要统一的。

最经典的例子是“商品”。在商品中心,商品是一个有标题、详情、图片、类目的展示对象;在库存系统,商品是一个 SKU 加若干库存数量记录;在交易系统,商品是一个快照,包含下单那一刻的价格和名称;在财务系统,商品是一个税率和成本核算科目。这四个“商品”不可能统一成一个模型——硬要统一的结果就是做出一个几十个字段、谁都不敢改的超级对象。

所以限界上下文不是“微服务的划分”,也不是“团队的组织架构”,它是模型适用范围的声明。在一个上下文内部,模型必须严格一致;跨上下文通信时,各自用各自的模型,中间做转换。

判断边界的一个实用方法:看语言是否还一致。如果两个团队在讨论同一件事时,对同一个词的理解需要不断澄清,那这里大概率就是一条边界。另一个信号是变化频率不一致——价格规则一周改三次,商品详情一个月改一次,它们不该待在同一个上下文里互相牵制。

注意:限界上下文和部署单元是两件事。一个上下文可以是一个模块、一个服务,也可以是一个进程内的独立包。不要一上来就把每个上下文拆成独立服务,分布式带来的运维成本会吃掉建模带来的收益。我见过太多项目,边界还没想清楚就先拆成了八个服务,最后每个服务里都是贫血模型加跨服务事务。

2.3 上下文映射:边界之间怎么合作

划完边界,下一个问题是边界之间怎么打交道。DDD 总结了几种典型关系,实际项目里最常用的是这几种:

关系类型通俗解释适用场景风险
合作关系两个上下文互相依赖,协同演进业务强关联、团队沟通顺畅一方变更需同步另一方
共享内核抽取公共模型供双方共用概念确实高度一致内核变更牵连所有使用方
客户-供应商下游依赖上游,上游需兼顾下游诉求上游有明确接口承诺下游排期受制于人
遵奉者下游完全照搬上游模型上游强势或标准化程度高下游模型被污染,失去自主性
防腐层下游自己做一层转换隔离上游上游模型不稳定或语义差异大增加一层维护成本
各自独立无集成关系业务无交集可能重复建设

我印象最深的是“遵奉者”这个模式踩过的坑。早期为了省事,我们直接拿外部计费系统的返回结构当自己的领域模型用,字段名、状态码全部照搬。结果对方一次版本升级把状态码从枚举改成了字符串,我们整个退款流程挂了两天。后来补了一层转换,虽然多写了百来行代码,但对方的任何改动都被挡在防腐层之外,我们内部模型一次没动过。

这里有个判断原则:上游模型只应该出现在转换层,不应该出现在你的领域层。如果你的 Order 类里出现了外部系统的字段命名习惯,那基本可以确定防腐层漏了。

2.4 子域划分:把最强的人放在最值钱的地方

子域(Subdomain)和限界上下文经常被混为一谈,其实它们是两个视角:子域是问题空间的划分,讲的是业务本身可以分成哪几块;限界上下文是解决方案空间的划分,讲的是我们的模型边界在哪。理想情况下两者一一对应,实际项目里经常是一个子域拆成多个上下文,或者多个子域合并在一个上下文里。

子域分三类,投入策略完全不同:

  • 核心域:业务竞争力的来源,规则最复杂、最有差异化价值。这里必须用最好的建模、最清晰的分层,人员配置也要最强。
  • 支撑域:业务必需但不产生差异化价值,比如权限、审批流、消息通知。可以适当简化,用现成方案,不必追求模型的完美。
  • 通用域:行业通用能力,比如账号体系、支付通道、短信发送。能买就买,能复用就复用,别自己造。

我见过最典型的资源错配,是把最强的两个工程师放去做权限系统的细粒度建模,而核心的定价引擎由刚入职的同事维护。判断一个子域是不是核心域,问三个问题就行:这块业务的规则改得频繁吗?改错了会直接影响收入或用户体验吗?竞争对手做这块和我们做这块有区别吗?三个都是“是”,那它就是核心域。

3. 战术设计:把模型真正写进代码

3.1 实体与值对象:靠身份判断,不靠有没有 ID

这两个概念的区分标准,说穿了就一句话:业务上关心它“是谁”,还是只关心它“是什么”。

实体(Entity)有身份标识,即使所有属性都变了,它仍然是同一个东西。订单号不变,订单改了收货地址、加了商品,它还是那个订单。用户改了昵称、换了手机号,还是那个用户。判断依据是身份,不是属性集合。

值对象(Value Object)没有身份,只由属性值定义,两个属性完全相同的值对象在业务上就是同一个东西,可以互换。金额、地址、时间段、颜色、坐标,都是值对象。你不需要知道“这个 100 元”是哪一个 100 元,你只关心它是不是 100 元。

值对象有两个硬性要求,很多人只做了第一个:

第一,不可变。创建之后不能修改,要变就整体替换。这条保证了值对象可以放心共享、传递、缓存,不用担心中间被谁偷偷改了。

第二,自带行为。这一点最容易被忽略。很多人把 Money 写成只有 amount 和 currency 两个字段的类,加减乘除全在 service 里算,那它就是个数据袋子,不叫值对象。真正的值对象应该自己管好自己的运算规则。

// 值对象示例:金额。用最小货币单位的整数存储,避免浮点误差 public final class Money { private final long cents; // 以分为单位 private final String currency; // 币种 private Money(long cents, String currency) { if (currency == null || currency.isEmpty()) { throw new IllegalArgumentException("币种不能为空"); } this.cents = cents; this.currency = currency; } public static Money of(long cents, String currency) { return new Money(cents, currency); } public Money plus(Money other) { requireSameCurrency(other); return new Money(this.cents + other.cents, this.currency); } public Money multiply(int quantity) { if (quantity < 0) { throw new IllegalArgumentException("数量不能为负"); } return new Money(this.cents * quantity, this.currency); } // 按比例分摊,用于优惠金额拆分 public Money allocate(long ratioNumerator, long ratioDenominator) { if (ratioDenominator == 0) { throw new IllegalArgumentException("分母不能为零"); } return new Money(cents * ratioNumerator / ratioDenominator, currency); } private void requireSameCurrency(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("币种不一致,不能直接运算"); } } public long getCents() { return cents; } public String getCurrency() { return currency; } }

这段代码里没有 setter,所有运算返回新实例,币种校验内建在运算里。看起来比直接操作 long 麻烦,但它挡住了一类真实事故:不同币种的金额被直接相加,或者金额被当成 double 参与计算产生 0.01 的误差累积。

注意:金额千万别用 double 或 float。用最小货币单位的整数(分),或者用十进制定点数。这个坑我在两个项目里都见过,一次是优惠计算后总价少了一分钱,一次是分摊退款时四项加起来比总额多了两分,排查了半天。

3.2 聚合与聚合根:一致性的最小边界

聚合(Aggregate)是 DDD 里最难也最容易被做错的部分。它的定义是:一组相关对象的集合,作为一个整体被访问和修改,并保证内部数据的一致性。聚合根是这组对象的入口,外部只能通过聚合根来操作里面的东西。

聚合存在的意义不是为了分目录,而是为了划定事务边界。一条基本原则:一次事务只修改一个聚合。这条规则背后是深思熟虑的——如果一次事务要改多个聚合,那么聚合之间的边界就没起作用,一致性只能靠数据库事务强行兜底,一旦上分布式就彻底崩盘。

设计聚合我有四条经验,按重要性排序:

第一,聚合要小。小到什么程度?只包含必须与根保持强一致的对象。订单和订单项必须在一起,因为订单总额要和明细对得上;订单和用户不该在一起,因为用户改昵称不应该锁住订单。很多人一开始会把“订单 + 用户 + 商品 + 物流”做成一个大聚合,结果就是任何一次下单都要锁住商品,并发直接掉到地板上。

第二,跨聚合只引用 ID,不引用对象。Order 里存userId,不要存整个User对象。存对象引用意味着你可能在订单事务里去改用户,边界就破了。

第三,聚合根负责保护不变量。比如“订单总额等于各明细小计之和”,这个规则必须由 Order 自己保证,任何改动明细的方法都要重新计算总额,不能指望调用方记得调。

第四,不要为了复用而拆小。有些团队矫枉过正,把订单项也做成独立聚合,每次改明细都要发事件同步总额,结果一致性问题比原来还多。聚合的粒度应该由不变量决定,不由复用性决定。

// 聚合根示例:订单。明细只能通过订单的方法修改 public class Order { private final OrderId id; private final long userId; private final List<OrderItem> items; private OrderStatus status; private Money totalAmount; // 私有构造,强制走工厂方法 private Order(OrderId id, long userId, List<OrderItem> items) { if (items == null || items.isEmpty()) { throw new IllegalArgumentException("订单至少需要一个商品"); } this.id = id; this.userId = userId; this.items = new ArrayList<>(items); this.status = OrderStatus.CREATED; this.totalAmount = calculateTotal(); } public static Order create(long userId, List<OrderItem> items) { return new Order(OrderId.generate(), userId, items); } // 修改明细时同步重算总额,不变量由聚合根自己守 public void addItem(OrderItem item) { assertModifiable(); this.items.add(item); this.totalAmount = calculateTotal(); } public void removeItem(OrderItemId itemId) { assertModifiable(); boolean removed = items.removeIf(i -> i.getId().equals(itemId)); if (!removed) { throw new IllegalStateException("订单项不存在"); } if (items.isEmpty()) { throw new IllegalStateException("订单不能清空所有商品"); } this.totalAmount = calculateTotal(); } public void close() { if (status != OrderStatus.CREATED) { throw new IllegalStateException("只有待支付订单可以关闭"); } this.status = OrderStatus.CLOSED; } private Money calculateTotal() { return items.stream() .map(OrderItem::subtotal) .reduce(Money.of(0, "CNY"), Money::plus); } private void assertModifiable() { if (status != OrderStatus.CREATED) { throw new IllegalStateException("当前状态不允许修改订单"); } } public OrderId getId() { return id; } public Money getTotalAmount() { return totalAmount; } public List<OrderItem> getItems() { return Collections.unmodifiableList(items); } }

注意getItems()返回的是不可变视图。这一步很关键,否则调用方可以直接拿到 list 往里加元素,绕过总额重算,聚合根就形同虚设。这类细节在评审时最容易被放过,出问题时又最难查。

3.3 领域服务、工厂、仓储:三个容易错位的角色

领域服务处理那些不属于任何单一实体的业务行为。典型例子是转账:钱从 A 账户到 B 账户,这个动作不属于 A 也不属于 B,硬塞进任何一个实体都会让那个实体承担不该有的职责。这时候建一个TransferService,把两个账户作为参数传进去,在服务里协调。

判断标准很简单:如果一个操作需要协调多个实体,或者它本质上是领域概念(“转账”是业务语言里的动词),就放领域服务。如果它只是编排流程(查数据、调服务、存结果),那属于应用服务,别混。

工厂解决复杂对象的创建问题。当构造一个聚合需要一堆参数、需要校验、需要组装内部对象时,把创建逻辑收拢到工厂方法里,避免调用方东拼西凑出一个半成品。上面代码里的Order.create(...)就是一个轻量工厂。如果创建逻辑更复杂(比如要根据不同的订单类型组装不同的明细结构),可以单独建工厂类。

仓储是最容易滥用的一环。它只针对聚合根,一个聚合对应一个仓储,接口定义在领域层,实现在基础设施层。这条设计的意图是把持久化细节挡在领域模型之外——领域层不该知道数据存的是关系库还是文档库。

// 仓储接口定义在领域层,只有聚合根才有仓储 public interface OrderRepository { Optional<Order> findById(OrderId id); void save(Order order); List<Order> findByUserId(long userId); }

有个细节值得说:仓储的语义是“集合”,不是“DAO”。集合的语义是你放进去一个对象,下次能按标识取出来,中间发生了什么你不用管。所以仓储方法里不该出现updateOrderStatus、selectByCondition这种带 SQL 味道的命名。我见过仓储接口里挂三十个查询方法的,那其实就是 DAO 换了个名字,领域层照样被查询需求绑死。

3.4 领域事件:跨聚合协作的正经做法

当一个操作需要引起其他聚合或外部系统的反应时,用领域事件。命名用过去式,表示“已经发生的事实”:OrderClosed、PaymentReceived、DiscountAllocated。这个约定不是形式主义,它强迫你从“命令”思维切换到“事实”思维——命令可以被拒绝,事实不能被拒绝。

发布时机有个关键点:事件应该在业务操作的事务内被记录,但投递可以延后。如果直接在业务方法里同步调用监听方,那等于把聚合又耦合回去了,还容易把事务拉长。更稳妥的做法是先把事件存进业务表(通常叫发件箱),由独立的投递任务异步处理,失败可以重试。

// 在聚合根内记录事件 public void close() { if (status != OrderStatus.CREATED) { throw new IllegalStateException("只有待支付订单可以关闭"); } this.status = OrderStatus.CLOSED; this.events.add(new OrderClosed(this.id, Instant.now())); } public List<DomainEvent> pullEvents() { List<DomainEvent> copy = new ArrayList<>(events); events.clear(); return copy; }

这样做的好处是:事件的产生和业务状态的变更在同一个事务里,绝不出现“状态改了但事件丢了”或者“事件发了但状态回滚了”的情况。代价是需要一张额外的事件表和一个投递器,换来的是最终一致性可控可查。

注意:领域事件不等于消息队列。事件是领域层的概念,消息队列是传输手段。有些团队一上来就在实体里注入消息生产者,结果领域模型直接依赖了中间件客户端,单元测试都没法跑。

3.5 防腐层:别让外部模型污染你的领域

防腐层(Anti-Corruption Layer)的作用是在你的领域模型和外部系统之间建一道转换墙。外部系统的数据结构、状态码、命名习惯,通通在这一层被翻译成你自己的语言。

为什么必须要有这一层?因为外部系统的模型是别人设计的,服务于别人的业务,你没法控制它的演进。直接拿来用,短期省事,长期就是被别人的变更拖着走。前面提到的计费系统状态码改字段那个坑,就是省了这层转换。

实现上很简单,一个转换类,输入是外部 DTO,输出是你自己的领域对象:

public class ExternalBillingTranslator { public PaymentRecord toDomain(ExternalBillingResponse response) { // 外部状态码在这里被翻译成内部语义,外部变动只影响这一处 PaymentStatus status = switch (response.getStatusCode()) { case "S" -> PaymentStatus.SUCCESS; case "P" -> PaymentStatus.PENDING; case "F" -> PaymentStatus.FAILED; default -> PaymentStatus.UNKNOWN; }; return new PaymentRecord( response.getTradeNo(), Money.of(response.getAmountInCents(), response.getCurrency()), status ); } }

关键约束是:外部 DTO 只能出现在转换层和基础设施层,绝不能出现在领域层和应用层的接口上。这条如果有代码扫描能卡住最好,靠人工评审太容易漏。

4. 一个小型订单域的完整建模过程

4.1 事件风暴的产出物到底长什么样

概念讲完,落到实际操作。我们这个订单域的重建是从一场事件风暴开始的。形式很简单:一面墙、三种颜色的便利贴。橙色写“事件”(已经发生的事),蓝色写“命令”(人发起的动作),黄色写“角色”。规则是不讨论技术、不讨论表结构,只描述业务事实。

两小时下来墙上是这样的:

角色命令事件关键规则
买家提交订单订单已创建至少一个商品,库存需校验
买家支付订单支付已成功金额需与订单总额一致
系统超时关单订单已关闭创建后 30 分钟未支付
买家申请退款退款已申请仅已支付订单可退
客服审核退款退款已通过退款金额不超过实付
系统执行退款退款已完成调用支付通道,回写状态
仓库发货订单已发货仅已支付订单可发

墙贴完之后,聚合自然就浮现出来了:订单是一个,退款是另一个,支付记录是第三个。判断依据是这些事件之间有没有必须立刻一致的关系——“订单已创建”和“支付已成功”之间可以有几秒延迟,那它们就不是一个聚合;“订单总额”和“订单明细”必须对得上,那它们就是一个聚合。

这个过程最值钱的地方不是产出物,而是它暴露了团队认知的不一致。贴到“退款金额”时,运营和开发吵了二十分钟:运营认为退款应该退“实付金额减去已使用优惠”,开发认为应该退“订单项金额按比例分摊”。最后发现两个说法在大部分情况下等价,但存在优惠券部分使用的情况会不一致。这种分歧如果不在建模阶段暴露,就会在线上以“用户投诉退款金额不对”的形式暴露。

4.2 聚合设计的取舍与代码骨架

结合事件风暴的结果,订单聚合的最终设计是这样的:

  • Order(聚合根):持有明细、总额、状态,负责状态流转和总额一致性
  • OrderItem(聚合内实体):持有商品快照、单价、数量,提供小计计算
  • Money(值对象):被 Order 和 OrderItem 共享
  • OrderId(值对象):包装标识,避免把裸字符串到处传

这里有个取舍值得说:商品快照要不要做成独立聚合。一开始我们想让它独立,理由是商品信息可能后续会更新,快照需要单独维护。但很快发现,快照在下单那一刻就固定了,永远不再变,它没有独立的生命周期,业务上也不会有人单独查某个快照。所以它就是一个值对象,塞在 OrderItem 里。

另一个取舍是状态机的实现。我们试过用状态模式做一堆状态类,结果类数量爆炸且难以阅读;最后还是用枚举加显式判断,规则全部集中在聚合根的方法里,反而清楚。

public record OrderItem(OrderItemId id, long skuId, String skuName, Money unitPrice, int quantity) { public OrderItem { if (quantity <= 0) { throw new IllegalArgumentException("数量必须大于零"); } } public Money subtotal() { return unitPrice.multiply(quantity); } // 按下单瞬间的价格快照计算分摊比例,用整数运算避免精度问题 public long allocationWeight() { return subtotal().getCents(); } }

金额精度的处理要单独说一下。假设订单总额 100 元(10000 分),三个商品小计分别是 3333、3333、3334 分,优惠 1000 分要按比例分摊。如果用浮点算比例再四舍五入,三项加起来很可能是 999 或 1001。正确做法是用整数运算,并且最后一项用“总额减前几项”兜底:

步骤计算方式结果(分)
第 1 项1000 × 3333 / 10000 向下取整333
第 2 项1000 × 3333 / 10000 向下取整333
第 3 项1000 − 333 − 333334
合计校验1000(一致)

这个“末项兜底”的写法看着土,但它能保证分摊总额永远等于优惠总额,不会因为舍入产生一分钱的账对不上。我们线上曾经因为这个问题出现过对账差异,金额很小但排查成本极高,因为差异分散在几十万笔订单里。

4.3 仓储接口与实现分离的具体做法

仓储的写法前面给过接口,这里说实现层要注意的两件事。

第一,恢复重建要保证不变量。从数据库读出来组装聚合时,如果走公开构造方法,会触发一遍业务校验(比如“订单至少一个商品”),历史数据如果有脏数据就会直接抛异常导致整个查询挂掉。稳妥的做法是提供一个专门用于重建的工厂方法,明确区分“新建”和“恢复”两种语义。

第二,不要暴露 ORM 对象。有些实现直接返回框架的实体对象,调用方顺手就能改字段,聚合根的封装就白做了。正确做法是在基础设施层完成转换,出仓储的一定是领域对象。

public class JdbcOrderRepository implements OrderRepository { private final JdbcTemplate jdbc; @Override public Optional<Order> findById(OrderId id) { List<Map<String, Object>> rows = jdbc.queryForList( "select * from orders where id = ?", id.value()); if (rows.isEmpty()) { return Optional.empty(); } Map<String, Object> row = rows.get(0); List<OrderItem> items = loadItems(id); // 走重建方法,跳过新建校验 return Optional.of(Order.restore( id, ((Number) row.get("user_id")).longValue(), items, OrderStatus.valueOf((String) row.get("status")) )); } @Override public void save(Order order) { // 先删后插或者按版本号做乐观锁,取决于并发要求 // 这里省略具体 SQL } }

乐观锁这块顺带提一句:聚合根上带一个版本号字段,更新时带上版本条件,更新行数为零就抛并发冲突。这比数据库行锁轻量,也比不处理并发要安全。

4.4 应用服务怎么编排才不越界

应用服务是很多人写歪的地方。它的职责是编排:接收请求、加载聚合、调用领域方法、保存聚合、发布事件、返回结果。它不该包含业务规则。

判断一段逻辑该放哪里的简单方法:如果这段逻辑描述的是“业务上必须成立的事”(比如订单总额必须等于明细之和),放领域层;如果描述的是“这个流程按顺序做这几步”,放应用服务。

public class OrderApplicationService { private final OrderRepository orderRepository; private final DomainEventPublisher eventPublisher; @Transactional public void closeOrder(String orderId) { Order order = orderRepository.findById(OrderId.of(orderId)) .orElseThrow(() -> new IllegalArgumentException("订单不存在")); order.close(); // 业务规则在聚合根里 orderRepository.save(order); // 持久化 eventPublisher.publish(order.pullEvents()); // 事件投递 } }

这段代码短得像没写东西,但它恰恰说明了分层到位的样子:应用服务只负责取、调、存、发,所有判断都在 Order 内部。反过来,如果你看到应用服务里有大段 if-else 在判断状态是否允许操作,那就是业务逻辑泄漏了,应该收回聚合根。

5. 踩过的坑与常见问题排查

5.1 贫血模型悄悄回潮

这是最普遍的退化现象。项目初期大家还老老实实把方法写在实体里,半年后新人接手,为了“快速实现”,开始在 service 里直接操作实体的 setter,实体慢慢退化成数据容器。等你发现的时候,业务规则又散回各个 service 了。

排查方法很直接:随便挑一个核心实体,看看它有没有除了 getter/setter 之外的方法。如果全是访问器,那基本可以判定是贫血模型。另一个信号是 service 类的方法数量远超实体类的方法数量。

治理手段有两个,一硬一软。硬的是把 setter 去掉,字段改成 final,只留业务方法;软的是在代码评审时明确一条规则:任何对实体状态的修改都必须通过实体自己的方法。前者效果立竿见影,后者需要长期坚持。我们当时是先做了硬的,编译报错逼着所有人改,阵痛两周但彻底止住了。

5.2 聚合切得过大或过小

聚合过大是最常见的错误,症状是并发上不去、锁冲突多、一次保存要写七八张表。判断标准:如果你发现两个对象之间的“强一致”要求其实只是“最终一致也可以”,那它们就该拆开。

聚合过小则更隐蔽,症状是跨聚合的一致性靠事件补偿,补偿逻辑越写越多,最后事件链长得没人能看懂全貌。判断标准:如果一个业务操作需要修改两个聚合且失败要一起回滚,那它们大概率该合并。

我的经验值是:一个聚合内的实体数量通常不超过五个,聚合根的公开方法不超过十五个。超过这个量级就要停下来重新审视边界,别硬撑。

5.3 事务边界与跨聚合一致性

“一次事务只改一个聚合”这条规则,实际落地时会遇到很多“但是”。比如下单要扣库存,订单和库存在两个聚合里,怎么办?

答案是:接受最终一致性,但要设计好补偿。下单时先创建订单(本地事务),然后发事件让库存服务扣减,扣减失败就发事件取消订单。这个流程比一个大事务复杂,但它是可扩展的,而且每一步都有明确的状态可查。

代价是要处理中间状态。用户可能看到订单已创建但库存未扣,这时候前端要能展示“处理中”而不是直接报错。这个体验设计比技术实现更考验功力。

方案一致性复杂度适用场景
单事务改多聚合强一致低数据量小、并发低
事件驱动 + 补偿最终一致中高跨服务、高并发
Saga 编排最终一致高长流程、多参与方
定时对账兜底最终一致中金融类必备补充

5.4 常见问题速查

现象可能原因排查动作
实体类只有 getter/setter贫血模型回潮检查业务方法是否散落在 service
保存聚合时更新大量表聚合过大审视不变量是否真的需要强一致
跨聚合事务难以避免边界划错重新确认两个聚合是否该合并
领域层出现外部系统字段防腐层缺失检查 DTO 是否越界进入领域层
事件发出但状态回滚事件在事务外发布改为事务内记录、事务后投递
金额分摊后总和对不上浮点运算或舍入未兜底改用最小单位整数,末项兜底
仓储接口挂满查询方法当成 DAO 使用查询走独立的读模型,不塞进仓储
术语在代码里对不上通用语言未落地补术语表,评审时对照命名

最后分享一个我自己觉得最有用的小习惯:每次评审建模方案时,我会让作者用业务语言把核心流程讲一遍,不许出现技术词汇。如果讲的过程中频繁卡壳、需要补充“这里其实是……”,那说明模型还没想清楚,代码写得再漂亮也是白搭。这个习惯帮我拦下过好几次方向性的错误,比看代码有效得多。

返回列表