首页 / 资讯中心 / 文章详情

领域驱动设计实战:限界上下文、聚合与事件风暴完整指南

领域驱动设计实战:限界上下文、聚合与事件风暴完整指南 ★ FEATURED ARTICLE
1. 为什么很多团队搞不清DDD到底解决什么问题我见过太多团队在技术选型时把领域驱动设计挂在嘴边结果一讨论就变成“聚合根怎么建”“Repository接口怎么抽象”这类纯代码层面的争论。代码是落地方式不是DDD的核心。DDD的全部价值在于它教你怎么用代码去表达业务而不是用业务去迁就代码。一个典型的反例是某支付系统上线两年后所有逻辑都堆在Service层一个转账方法写了800行里面藏着手续费计算、风控校验、账务流水、通知发送谁都不敢动。这时候团队想起DDD开始重构结果发现根本不知道边界画在哪里——因为业务规则早已散落得到处都是。这正是需要DDD的场景业务复杂度已经压倒了技术复杂度你需要一套方法论把混乱的领域知识重新组织起来。DDD不是一个框架、不是一种代码分层模板它是Eric Evans提出的一套软件建模思想核心就两件事第一把业务拆成清晰的领域和边界第二在每个边界内部用代码构建一个能够准确表达业务规则的模型。它解决的关键问题是“业务知识的丢失”——传统开发模式下业务专家脑子里的规则经过需求文档、口头转述、开发理解、代码实现层层衰减最后代码表达的和真实业务已经偏离了很远。这篇文章适合正在做业务系统开发、被复杂逻辑困扰的技术负责人和高级开发者也适合刚接触DDD但被各种概念绕晕的初学者。我会把战略设计、战术设计、代码落地、常见坑全部串起来讲尽量用实际场景说话不讲空理论。2. 从战略设计开始先划边界再谈建模2.1 限界上下文就是系统的“国境线”很多初学者学习DDD时直接扑向实体、值对象、聚合这些战术概念这是本末倒置。DDD的起点是战略设计而战略设计的起点是限界上下文。限界上下文Bounded Context的概念其实非常简单——它就是一个业务边界。在一个限界上下文内部业务语言是统一且自洽的跨了边界同一个词可能是完全不同的含义。我用一个最经典的电商例子来说明。在“商品目录”上下文里“商品”指的是SPUStandard Product Unit标准产品单元包含名称、类目、属性组、图片这些信息在“库存”上下文里“商品”指的是SKUStock Keeping Unit库存量单位关注的是库存数量、仓库位置、预警阈值在“订单”上下文里“商品”又变成了订单行项目关心的是下单时的价格快照、数量、优惠分摊。同一个词“商品”在三个上下文里形态完全不同。如果强行用一个全局的“商品”模型去服务所有场景结果就是一个类几百个字段每种场景只用其中一小部分并且任何修改都要评估对全链路的影响。这就是典型的大泥球Big Ball of Mud的诞生过程。所以画限界上下文的本质是画业务语言的边界。每个上下文内部使用通用语言Ubiquitous Language也就是业务专家和开发人员共同认可的那套名词和规则。某供应链项目里业务方口中的“在途库存”和“可用库存”经常混用开发团队也跟着含糊后来在建模阶段把每个上下文里的术语表拉出来一一对照才理清楚这是两个不同上下文里的不同概念。2.2 上下文映射定义边界之间的协作关系划完边界之后边界之间怎么协作这是战略设计的第二步。DDD定义了多种上下文映射关系合作关系Partnership两个上下文互相依赖需要同步协调变更。共享内核Shared Kernel两个上下文共享一部分模型代码比如共享值对象。客户方-供应方Customer-Supplier上游供应方决定模型下游客户方按其接口开发典型如订单上下文调用库存上下文。防腐层Anti-Corruption Layer下游上下文为抵御上游模型腐蚀而建立的翻译层后端系统对接第三方系统时常用。开放主机服务Open Host Service通过API将上下文能力开放出去配合发布语言Published Language如REST API或消息协议。防腐层是我在实际项目中遇到最多的落地形态。我做过一个对接某外部物流平台的系统对方接口字段命名混乱、返回结构不稳定而且他们更新接口完全不考虑下游调用方。如果直接把对方的DTO透传到整个业务层我们的核心模型就会被对方的坏味道腐蚀掉。最终方案是在我们的上下文边界内建了一层ACL负责把外部物流平台的模型翻译成内部的运单模型和轨迹模型内部代码永远不直接引用外部类型。后期对方接口升级我们只改ACL内部逻辑核心业务模型毫发无损。上下文映射关系最好通过上下文地图Context Map画出来。不需要用什么高端工具一张白纸、几个矩形框、几条带箭头标注关系的线就够了。关键是让团队所有成员对边界和协作关系有一致的认知。2.3 战略设计的落地顺序与判断标准实际操作中我不建议一上来就做什么轰轰烈烈的“领域分析工作坊”。最稳妥的方式是第一步找业务专家做事件风暴Event Storming用黄色便签贴出业务过程中的所有领域事件比如“订单已创建”“库存已扣减”“支付已完成”。这个过程能快速暴露出业务全貌。第二步根据事件的发生逻辑聚簇找出主要的业务角色、聚合边界、业务规则然后命名限界上下文。第三步画出上下文地图明确每个上下文之间的依赖方向和数据流动方式。第四步为每个上下文明确通用语言术语表统一关键名词的定义。判断一个上下文划分得好不好有三个标准一是每个上下文内部的模型是否高内聚即改动原因是否唯一二是上下文边界是否符合业务本身的组织架构和语言习惯三是上下文之间是否有明确的依赖方向尽量避免循环依赖。很多团队战略设计做不好的根源在于划分上下文不是基于业务逻辑而是基于“为了服务拆分”或者“为了数据库解耦”。我见过一个团队把“用户”和“账户”放在两个上下文里理由是这俩表数据量都大以后要分库。等建模的时候才发现账户的核心业务规则余额变更、冻结/解冻离不开用户信息两个上下文之间反复互相查询最后不得不合并。正确的做法是先按业务逻辑分技术优化往后放。3. 战术设计核心聚合的正确构建方式3.1 实体与值对象的判断方法进入战术设计阶段第一个基础问题是什么是实体什么是值对象。最直白的判断标准如果一个对象你有且只有这一个它有生命周期你关心“它是谁”的变化——实体如果一个对象的价值在于“它是什么”而不是“它是谁”可以被替换、被比较、被无副作用地创建和销毁——值对象。订单是实体订单号是它的身份标识金额是值对象一百块和另一张一百块没有任何区分度地址是值对象“北京市海淀区某路某号”这个值本身就是全部意义你不会关心“这个地址是哪一次创建的”用户是实体用户ID是身份标识用户名、手机号、头像都可以变但用户这个身份不会变。我在实践中发现新手最容易犯的错误是把值对象活活做成实体。比如“收货地址”这个对象有人给它加了一个自增主键ID然后开始纠结“这个地址ID怎么传给订单”。地址完全不需要ID它就是客户下单那一刻的一个快照值。给它加ID意味着你能通过ID查询地址、修改地址、更新地址这实际上是把一个不该有生命周期的对象赋予了生命周期徒增复杂度。反过来该做实体的也绝不能做成值对象。“用户”如果被定义成值对象那修改用户昵称就成了“创建了一个新用户”整个系统就乱套了。3.2 聚合边界与聚合根的设计原则聚合Aggregate是DDD中最容易理解错也最容易用错的概念。聚合是一组对象的集合外部只能通过聚合根Aggregate Root访问聚合内部的任何对象。聚合的边界界定原则可以归结为一句话——一致性边界。什么意思一个聚合内部的对象之间必须满足强一致性事务的边界应该限定在聚合内部聚合之间则是最终一致性。所以设计聚合的第一件事就是问“哪些对象必须同时被修改并且如果被并发修改会产生错误”拿订单来举例。“订单”和“订单项”必须在一个聚合内。一个订单可能包含多个订单项删除、修改订单项都会影响订单总金额订单项离开订单没有任何存在意义因此订单是聚合根订单项是内部实体。但“订单”和“支付记录”是否需要放在同一个聚合内不需要。订单创建、支付成功、退款这些操作的发生是有时序性的中间可以隔很久而且查询维度完全不同它们不要求强一致最终一致就足够了。强行放在一个聚合里会导致每次下单都要锁定支付记录性能急剧下降而且并发场景下逻辑极其复杂。聚合设计的几个硬性规则我踩过坑之后总结如下聚合根必须通过ID被外部引用不允许外部直接持有聚合内部对象的引用。这是为了保边界。聚合内所有对象的事务性由聚合根负责修改任何内部对象都必须经过聚合根的方法。聚合根设计要尽量小一个事务只修改一个聚合。如果一个业务操作要涉及多个聚合应该通过领域事件或Saga来协调。聚合内部的一致性由不变条件Invariant维护比如“订单总金额必须等于所有订单项金额之和”“库存扣减不能低于0”这些规则必须在提交前的聚合根方法内强制校验。3.3 如何利用事件风暴辅助聚合划分事件风暴Event Storming是一个用于领域建模的实战工具极其适合在聚合设计阶段使用。操作过程大致如下准备一面足够大的墙按时间线横向排列。让业务专家和开发人员坐在一起先用橙色便签贴出所有领域事件过去发生的业务事实如“订单已提交”再用蓝色便签在事件前后贴出触发该事件的命令如“提交订单”用黄色便签贴出读取数据的视图和决策用绿色便签贴出外部系统或依赖方。事件和命令都贴完之后观察哪些命令和事件在时间和规则上高度关联——它们往往属于同一个聚合。比如“添加订单项”和“修改订单数量”都涉及订单金额的重新计算与订单相关的事件紧挨着你自然就能判断出订单聚合的边界在哪里。如果某个事件几乎不依赖其他事件比如“物流轨迹已更新”它大概率属于另一个独立的聚合。事件风暴最大的价值是让业务人员直接参与到建模过程中避免开发自己躲在工位上猜想业务规则。成本也不高半天到一天时间即可完成一次中等业务的建模收益远大于投入。4. 领域服务与领域事件让模型跑起来4.1 领域服务的使用时机与判断标准领域服务Domain Service是DDD战术设计中最容易被滥用的概念。很多人把领域服务当成了Service层的代名词实际上这是完全错误的。真正的领域服务适合承载那些“不属于任何实体或值对象但又必须属于领域层”的业务逻辑。典型的判断标准是这个操作涉及多个聚合或者涉及领域模型中不存在的计算规则但规则本身属于业务知识不该被丢到应用层。举个例子。转账操作涉及转出账户和转入账户两个聚合扣减余额和增加余额不归属于任何单一账户聚合但它们都是账户领域内的规则。这时候可以提供一个“转账服务”它协调两个账户聚合的操作确保两边的余额变动满足总账平衡的约束。再比如计息场景计算一笔贷款的应还利息涉及合同、还款计划、利率规则等多个聚合这个过程本身可以建模为领域服务。判断一个逻辑该不该做成领域服务的有效方法问自己“这个逻辑如果没有任何类和对象归属放在哪里最合适”。如果答案是“放在某个聚合根方法里合理”就不要建领域服务如果答案是“哪都不合适但它确实是业务规则”那就建领域服务。我的经验是领域服务的数量应该远少于应用服务如果一个项目里领域服务比聚合还多大概率是建模出了问题。4.2 领域事件的设计与发布订阅领域事件Domain Event表示领域中发生的、对业务有意义的事情例如“订单已支付”“库存已扣减”“用户已注册”。它的价值在于解耦聚合之间的协作让最终一致性成为默认选项。注意领域事件不是技术意义上的消息事件它是建模概念代表业务事实。实现层面可以通过事件总线、消息队列或事务性消息来承载。设计领域事件的时机判断标准一个聚合状态发生变更后是否需要其他聚合或系统做出响应如果需要并且响应方与变更方不属于同一个聚合就用领域事件。一个完整领域事件的基本要素包括唯一事件ID、事件发生时间戳、事件类型和相关实体的ID。事件内容不要携带完整数据快照只携带足够接收方处理的最小字段集。尽量减少“订阅方需要回查数据库”的依赖但也不能把大而全的数据全部塞进事件里这会给后续消费者增加负担。落地时常见的一个坑事件发布与聚合状态变更不一致。假设你在应用服务中先保存订单数据再发布“订单已创建”事件如果保存成功但发布失败比如网络抖动、消息服务不可用就会出现“状态变了但通知没发出”的问题。解决办法是使用事务性发件箱Transactional Outbox模式在同一个本地事务中先写入事件表后台任务再把事件表里的事件异步发布到消息中间件。这个方案看起来多一步但能够在极端场景下保住一致性值得为可靠而付出。4.3 应用服务、基础设施与领域层的职责划分很多项目分层混乱的原因在于应用服务Application Service做了领域层的活或者领域层里写了基础设施的代码。三者的职责划分应该是这样基础设施层Infrastructure提供技术能力比如数据库访问、消息发送、外部系统对接领域层Domain承载业务规则和业务状态应用服务Application Service作为薄层负责协调领域对象完成一个用例但不承载业务规则。用“用户下单”这个用例来示例基础设施层UserRepository接口的实现OrderRepository接口的实现数据库事务管理器消息发布组件。领域层Order聚合根OrderItem实体Money值对象OrderService领域服务OrderCreated领域事件以及OrderRepository接口的定义。应用服务CreateOrderApplicationService负责启动事务、调用Order聚合根创建订单、调用OrderRepository保存、发布OrderCreated事件、提交事务。判断是否干净的标尺应用服务的代码里不能出现业务规则的“if/else”。比如“订单金额不能低于0”“下单数量不能超过库存”这些必须存在于领域层而不是应用服务中。如果应用服务里出现了这类语句说明业务规则漏到了外层领域模型被架空后续迟早出大问题。5. 代码落地一个完整的订单聚合实现示例5.1 代码结构与传统三层架构的对比理论讲再多没有代码落地都是空谈。我以一个简化的订单上下文为例展示DDD落地后的代码长什么样。先看标准的目录结构这里按DDD的分层方式组织com.example.order ├── interfaces // 用户接口层Controller、DTO │ ├── web │ └── api ├── application // 应用服务层 │ ├── CreateOrderApplicationService.java │ └── assembler // DTO与领域模型的转换器 ├── domain // 领域层 │ ├── order │ │ ├── Order.java // 聚合根 │ │ ├── OrderItem.java // 聚合内部实体 │ │ ├── OrderId.java // 值对象 │ │ ├── Money.java // 值对象 │ │ ├── OrderStatus.java // 枚举/值对象 │ │ ├── OrderRepository.java // 聚合根对应的仓储接口 │ │ ├── OrderService.java // 领域服务如果需要 │ │ └── event │ │ ├── OrderCreatedEvent.java │ └── shared │ └── domainevent │ ├── DomainEvent.java │ └── DomainEventPublisher.java └── infrastructure // 基础设施层 ├── repository │ └── OrderRepositoryImpl.java ├── eventpublisher │ └── OutboxEventPublisher.java └── persistence └── OrderJpaEntity.java传统的三层架构Controller-Service-Mapper里你通常在Service里看到大量的业务判断和数据处理逻辑而Mapper层只有傻瓜式的增删改查。DDD代码落地后最大的变化是业务逻辑回归到领域模型中Service层变得非常薄业务语义更接近真实的业务语言。这个结构里最需要理解的不是某个具体文件而是“依赖方向”——依赖是由外向内的Interfaces层依赖Application层Application层依赖Domain层Domain层不依赖任何外部层。这是经典的依赖倒置原则在DDD中的体现领域层定义了Repository接口基础设施层去实现它方向是反的。5.2 聚合根的完整实现与事务边界示例下面看Order聚合根的简化代码重点观察它是如何封装不变条件的public class Order { private final OrderId id; private final ListOrderItem items; private OrderStatus status; private final Money totalAmount; private Long customerId; public Order(OrderId id, Long customerId) { this.id id; this.customerId customerId; this.items new ArrayList(); this.totalAmount Money.zero(); this.status OrderStatus.CREATED; } public OrderId getId() { return id; } public ListOrderItem getItems() { return Collections.unmodifiableList(items); } public Money getTotalAmount() { return totalAmount; } public void addItem(OrderItem item) { // 不变条件订单项不能重复添加 if (items.stream().anyMatch(i - i.getProductId().equals(item.getProductId()))) { throw new IllegalArgumentException(该商品已经在订单中请使用数量变更操作); } // 不变条件金额非负 if (item.getPrice().isGreaterThan(Money.zero())) { items.add(item); recalculateTotal(); } else { throw new IllegalArgumentException(商品价格必须大于零); } } public void markShipped() { if (this.status ! OrderStatus.PAID) { throw new IllegalStateException(只有已支付订单才能发货); } this.status OrderStatus.SHIPPED; } private void recalculateTotal() { // 不变条件总金额 所有订单行金额之和 Money sum items.stream() .map(OrderItem::getLineAmount) .reduce(Money.zero(), Money::add); this.totalAmount.update(sum); } }这份代码有几个必须关注的细节。getItems()返回的是Collections.unmodifiableList(items)—— 这是防外部修改确保聚合内部的任何变化只能通过聚合根的方法进行。我见过很多团队在这一点上忽视直接把内部List返回出去外部随便add/remove聚合的规则约束形同虚设。markShipped()里的状态校验是典型的业务规则封装。如果这个逻辑写在应用服务里那么应用服务必须知道“只有已支付订单才能发货”这条规则这条规则就会在代码的各个角落被复制。写在聚合根里所有调用方都被强制经过这条校验。事务边界要精确理解事务发生在应用服务层业务规则发生在领域层。也就是说应用服务内部调用订单聚合的方法时如果抛异常事务就被标记回滚如果正常返回事务提交。这个协作关系才是DDD常见代码实现里最精妙的部分——业务规则捕获在领域层事务控制保持在应用层。5.3 仓储接口的抽象与实现选择Repository的设计在DDD代码落地中同样关键。仓储Repository的作用是为聚合根提供持久化存取能力让领域层不必关心数据库细节。OrderRepository接口定义在Domain层public interface OrderRepository { OrderId nextId(); OptionalOrder findById(OrderId id); void save(Order order); }为什么接口定义在Domain层而实现放在Infrastructure层这是依赖倒置的核心体现——Domain层不依赖Infrastructure层Infrastructure层依赖Domain层。这样带来的好处是领域层的代码完全不知道自己在用什么数据库、什么ORM整个领域模型不受到技术栈的强制绑定。保存一个订单聚合时需要把内部的OrderItem一起保存。如果使用JPA或MyBatis等ORM框架基础设施层的实现就负责聚合根与持久化实体之间的转换方案一直接让Order继承或使用JPA注解ORM实体和领域模型合二为一。适合小项目和原型但耦合度高领域模型会被JPA的字段映射方式绑架。方案二领域模型与持久化模型分离Order和OrderJpaEntity是两个类RepositoryImpl负责转换。适合中大型项目领域模型保持纯净但需要写转换代码。我在落地时选方案二的居多因为长期来看领域模型应该保持对技术栈的独立性。如果哪天换了数据库中间件领域模型完全不受影响。有一个转折点要注意不要在Repository里提供“通用查询”接口比如findAll、findByCustomerIdAndStatus。因为聚合的加载应该是面向用例的——你只需要加载这个用例需要操作的聚合根而不是把所有可能的查询条件列全。查询逻辑可以另建查询仓储或读模型不必都塞进Repository。6. 落地DDD时常见的错误与排查方法6.1 常见坑位排行榜从充血模型到过量设计这几年帮不少团队看DDD改造项目总结下来最常见的坑位几乎一样。第一坑完全不理解“聚合”边界的直接结果——把一个实体类里的代码搞得极其膨胀一个类几千行各种业务方法全部塞进去美其名曰“充血模型”。这其实是把贫血模型的坏味道换了个地方集中。聚合根不是把系统里所有方法都收编它只负责与自己内部对象相关的强一致业务规则。跨聚合的协作交给领域服务或领域事件不要硬塞到聚合根里。第二坑过量设计。一上来就想把所有战术模式都用一遍实体、值对象、聚合、领域事件、领域服务、仓储、工厂、规格模式恨不得十个模式全上。这是DDD落地最严重的问题。实际上一个聚合可能只需要极少的模式比如订单聚合可能只需要实体、值对象和聚合根事件和领域服务是按需引入的。模式永远是服务于业务的工具不适合业务模式直接用不用没有任何问题。第三坑把领域事件当成消息聊天的工具。很多团队使用领域事件仅仅是为了让“订单创建后发个短信”这种需求这完全是误解。领域事件是业务事实不是技术分发手段。如果只是通知异步消息即可领域事件的核心价值在于它代表“已经发生、不可撤销、过去时态”的业务事件有时序和因果的约束。第四坑应用服务和领域服务的职责彻底混淆。很多代码里应用服务里写满业务规则领域服务却是空的或者领域服务承担了太多协调工作应用服务变成了纯粹的CRUD转发。我的纠偏建议应用服务讲用例、处理输入输出、管事务领域服务讲规则、协调聚合不要把两者搞混。6.2 排查方法论从现象反推模型问题当你的DDD代码已经写得比较“像样”了但系统仍然出现了问题该怎么排查我习惯按以下顺序排查第一步确认是否出现了“模型与事务不匹配”的现象。比如某个方法内改动了两个聚合并且用了一个事务事务性。如果确实发生了再回看这个业务操作是否真的需要强一致如果不是就该拆成两个聚合的事务配合领域事件做最终一致。第二步检查是否存在隐式关联导致的循环依赖。例如Order聚合内部保存了CustomerId而Customer聚合内部的某个值对象保存了OrderId。如果领域层出现了循环引用通常说明建模有误或者不该把对方ID存放在自己的聚合内部。第三步看看领域事件是否“丢失”。系统里有些操作没生效但数据库数据已提交。这种情况优先排查事务性发件箱有没有正常工作或者事件发布是不是放在了事务外面。第四步彻底审查你的限界上下文地图。业务增长后原本合理的上下文边界可能变得不合理。比如两个上下文之间的依赖关系从单向变双向了或者上下文越来越多依赖到同一个共享内核这些变化都在提示你需要重新审视上下文划分。我处理过的最难排查的一个真实问题某营销系统里订单金额字段在统计报表里的数据和业务系统数据对不上定位了三天最后发现是两个上下文里“订单金额”的定义不同——一个是含税价、一个是税前价而且双方都觉得自己是对的。这类问题需要通过统一术语表和模型校验来解决代码层查找永远找不到根源。6.3 引入DDD的合理路径非技术先行一定有人在想我们项目目前很混乱想引入DDD大改造怎么开始最稳我的建议是不要从重构代码开始。从一个新需求、一个新模块开始做小范围的DDD试点。选一个业务边界清晰、规则复杂度高、最容易出价值的小模块从事件风暴开始画出上下文地图确定聚合边界写代码落地。等拿到成果和团队经验再考虑逐步推广。另外不要想着一次性把整个系统全部重构成DDD风格。重构的每一步都应该独立可交付战术层先跑通战略层逐步调整每一次重构都保留业务行为不变。这个过程必须配合足够的自动化测试否则改到一半没人敢提“回归没问题”。我会特别提醒团队的一件事是DDD实施的成功与否核心不在开发在于业务专家的参与程度。如果业务专家不参与事件风暴开发团队闭门造车最后模型一定和真实业务两张皮。DDD不是什么技术框架它是“用技术重现业务世界”的建模过程必须让懂业务的人深度参与进来。7. 写在最后的一些个人体会关于DDD我最后想说的不是方法论本身而是方法论的适用性问题。DDD不适用于所有系统。如果你做的系统是CRUD为主的、业务规则极简单、数据关系纯粹的管理系统引入DDD大概率是负重前行造出一堆不必要的抽象。DDD发挥作用的前提是业务复杂度足够高高到你觉得技术手段已经无法兜住业务混乱的程度。这个时候DDD的价值才真正体现出来——它不是让代码变优雅的银弹而是帮助你重新建立业务和代码之间精准映射的方法。我实操下来最深的感受是DDD落地最难的不是写代码而是让团队建立“先建模、后写码”的思维惯性。绝大多数开发拿到需求的第一反应是“哪些表、哪些接口”而DDD要求你先问“这个业务的领域是什么、边界在哪、规则是什么”。这种思维方式的变化远比掌握几个战术模式困难得多。如果你所在团队正被复杂业务折磨得焦头烂额可以先从一场两个小时的限界上下文梳理会议开始把业务方拉进来一起在白板上画出上下文地图和通用语言术语表。不用一上来就谈Aggregate、Repository这些词汇先把业务边界画清楚你就已经迈出了DDD最重要的一步。剩下的路慢慢走代码层面的东西可以迭代方向对了就不会白费。
阅读完成 · 觉得有帮助?
咨询建站