ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

领域驱动设计实战:从业务建模到代码落地的完整指南

领域驱动设计实战:从业务建模到代码落地的完整指南 在过往十几年做后端系统的过程里我也算是亲眼看着代码从一个工程做到几十上百个服务、再被各种重构拆来拆去最后发现一个无法回避的问题业务复杂度一旦上来技术怎么分都救不了代码的混乱。这也是我后来认真啃、并且在项目里反复用领域驱动设计DDD的原因。DDD不是一门“高级技术”它不依赖框架、不限定语言它是一套帮你在复杂业务里理清边界、定好模型的语言和方法论。说实话第一次接触这个概念时我也觉得有点虚什么聚合、限界上下文、通用语言每一个词听起来都像“哲学”不像是能直接写的代码。但当我真正在一个混乱的订单模块里用DDD的思路重做之后才体会到它真正的价值。这篇内容适合已经被业务复杂度折磨过的后端开发、架构师、技术Leader也适合那些项目刚起步但已经预料到未来会失控的团队。我会用自己实际拆过的一个订单中台项目做主线从概念讲到战略设计、再到战术落地、最后聊一聊工程里常见的坑和排查策略尽量不做纯理论搬运。1. DDD到底是什么以及它要解决的真正问题刚开始接触DDD的人很容易陷入对概念的背诵比如实体、值对象、聚合、领域服务这些词单个拿出来都能看懂但合在一起就不知道从哪儿下手。我先从它要解决的问题说起。软件系统的复杂度来源其实只有两类业务复杂度与技术复杂度。技术复杂度通常是“可解决的”框架不合适就换框架存储有瓶颈就做缓存、分库分表、加消息队列这些都有相对成熟的手段。业务复杂度才是真正让人头疼的规则之间相互纠缠、不同部门对同一个词的理解完全不一致、需求文档写的是A但业务方嘴上说的是B、改一个简单字段可能牵扯出七八个隐藏状态。DDD的核心目的就是对付这种业务复杂度它让你不只是把代码写出来而是先把业务“想清楚”。从另一个角度看传统架构在很长一段时间里都是“数据驱动”的先设计数据库表结构再根据表的关联关系反推服务怎么写到一起最后把业务逻辑塞进Service层里。这种做法在业务流程简单的时候没什么问题但一旦业务增长Service里面开始堆砌大量if-else和各种状态流转表关系越连越复杂最后没有任何人能说清楚整个系统是怎么运作的。DDD选择换一个方向它把“业务模型”放在最核心的位置数据库和技术细节都变成支撑模型的底层设施。还有一个更贴近日常的痛点沟通成本。业务方嘴里说的“订单”和研发理解的“订单”可能完全不是一回事。业务方认为订单包含了支付信息、售后信息、物流信息而研发这边可能把订单、支付单、发货单分成了三张表三个服务。DDD里的通用语言本质上就是逼着业务人员和研发人员把每个关键业务名词掰开揉碎对齐一致让代码里的模型和业务方脑子里的模型保持同一套词汇。光这一点很多团队做到之后效率就有立竿见影的提升。对比传统的CRUD开发模式DDD带来的最大不同在于把你从“这张表怎么建”“这个接口怎么设计”的惯性思维里拉出来强制先回答“这个业务到底有几个核心领域”“边界在哪里”“谁是不可变的规则谁是会变化的流程”。一旦这些问题有了明确答案再回去看技术方案很多纠结自然消失。关键认知DDD不是银弹它解决的是“业务复杂度高”的问题。如果项目只是简单增删改查就没有必要强行上聚合根和事件溯源那一套徒增成本。2. 战略设计比写代码更重要的一步战略设计是做DDD的第一步它不考虑类怎么设计、表怎么建、服务怎么拆而是聚焦在“问题空间”和“解决方案空间”的划分上。绝大部分DDD落地失败问题都出在战略设计没做好就直接跳到战术细节导致聚合划分一塌糊涂、上下文边界模糊、团队之间依然互相打架。2.1 用事件风暴摸清业务全貌事件风暴是一种工作坊形式聚合业务人员、产品经理、研发一起在墙边贴满黄色便签把所有业务事件按时间顺序铺开。每一个事件都对应业务里真实发生的动作比如“订单已提交”“库存已扣减”“支付已完成”“退款已发起”。然后在此基础上找出触发事件的命令、执行命令的角色、以及事件发生后关联的数据和外部系统。我在自己负责的订单中台项目里第一次组织事件风暴花了差不多两天时间。十几个业务方和研发成员围在白板前吵得不可开交但恰恰是这种吵暴露了大量“我以为你懂其实你根本不知道”的细节。比如运营说的“退款成功”其实是指原路退回支付渠道并且通知用户而财务说的“退款成功”是指这笔钱已经进入对账清单并完成销账。同一个词两种语义如果不通过事件风暴以事件的方式梳理做到后面必然碰撞。做完事件风暴你需要产出一张时间轴事件图。这张图是战略设计的原材料它清晰说明了系统里有哪些核心流程、哪些环节依赖外部、哪些环节是内部的决策点。接下来可以根据这张图划分领域和限界上下文。2.2 核心域、支撑域、通用域怎么区分把业务事件归类以后你会发现事件背后其实存在几组相对独立的业务能力组合。这些组合考虑命名时需要主动去识别子域。子域一般分成三类第一类是核心域这是公司业务的核心竞争力所在决定了你在市场上的差异化优势。比如订单中台项目里订单状态机、库存预占、价格计算就属于核心域这些规则写得好不好直接决定了业务的灵活性和稳定性。第二类是支撑域它支撑核心域运转但本身不是核心竞争力比如订单里面的售后审核流程、自动分单逻辑、运营配置后台。这些能做但不是你最需要花精力去精雕细琢的部分。第三类是通用域属于市面上已经很成熟的通用能力比如用户认证、消息推送、文件存储这部分通常不需要自己做太深能用成熟方案就用成熟方案。分辨的标准其实很直接如果这块业务换一种做法用户能直接感受到明显差异那大概率是核心域。如果怎么改用户都无感那可能是支撑域或通用域。资源有限时优先投入核心域通用域尽量引入外部组件和成熟解决方案。2.3 限界上下文是微服务划分的真正依据很多人把限界上下文和微服务两个概念混在一起觉得一个上下文就是一个服务。严格来说限界上下文是业务层面的边界而微服务是部署和架构层面的物理边界。通常一个限界上下文可以对应一个或多个微服务也可以多个小上下文合并成一个服务具体取决于团队规模、部署成本和数据一致性要求。识别限界上下文有一条非常实用的经验看“通用语言”是否发生了变化。同一个业务名词在A上下文里和B上下文里含义不一样时它们之间就必然存在边界不能强行放在一起。比如“商品”在商品中心上下文里指的是SPU和SKU的完整定义包含图文、规格、属性但在购物车上下文里“商品”可能只需要一个ID、一个名称、一个价格、一个缩略图。如果强行共用一个商品模型两个上下文的团队就永远在扯皮为什么这么多字段为什么这个字段含义变了。有了上下文之后还要确认上下文之间如何协作。这就是上下文映射图的工作。最常见的映射关系有防腐层ACL、开放主机服务OHS、发布语言PL等。防腐层是哪天实践里用得更频繁的当你的上下文需要调用一个外部模型不匹配的系统不管内部老系统还是外部第三方时在边界处加一层转换适配把外部概念翻译成本上下文的语言防止外部模型的复杂性污染自己的领域模型。限制上下文A(订单) --调用-- 防腐层 --翻译-- 外部老库存系统这一段描述看起来简单但实际写代码时很多人容易把防腐层直接做成纯粹的数据搬运用DTO转换。真正合理的防腐层还应该承担协议适配、异常翻译、外部模型隔离等多重职责而不只是把字段从一个对象拷到另一个对象。战略设计的产出物应该包括领域划分清单、限界上下文清单、上下文映射关系图。有了这些架构师再去做服务拆分、接口规划、数据归属等工作就有了依据不必像以前那样凭感觉拍脑袋。3. 战术设计把模型落到可执行的代码战略设计定义了边界和关系战术设计则是在边界内部设计具体的领域模型元素包括实体、值对象、聚合、领域服务、领域事件、仓储等。这是DDD落地时写代码的核心环节。3.1 实体与值对象判断标准不是有没有ID实体是领域里有唯一标识、并且会经历一系列状态变化的对象。比如订单有订单号支付单有支付流水号用户有用户ID。实体强调的是“身份和连续性”同一个订单从新建到支付再到出库状态一直在变但拿着同一个订单号始终定位到同一份数据。值对象则完全不同它没有唯一标识由一组属性共同构成完整性用来描述某个维度上的特征。典型例子是金额、地址、经纬度、颜色。两个值对象只要属性完全相同就可以视为等价不需要知道它是谁。比如订单里的收货地址就是一个值对象北京海淀区的地址和上海浦东新区的地址是同一套结构的不同实例不存在“这个地址的唯一身份”。刚上手时最容易犯的错误是想给一切东西加ID。我见过有人给用户地址加地址ID给商品标签加标签ID给订单里的快照信息也加ID。地址本质上不具备独立生命周期的职责它依附于订单或用户标签更是如此。如果把值对象强行升级为实体后面所有代码里都是无意义的ID传递和查询模型复杂度会被凭空抬高。一个实用判断方法当一个对象只描述“是什么”而不关心“是哪一个”并且修改它通常意味着整体替换而不是局部更新时它就是值对象。比如修改地址正常流程是把旧地址整块替换成新地址而不是把旧地址的某个字段改一下保存回去。3.2 聚合与聚合根边界内的一致性问题聚合是把一组关联紧密的实体和值对象包装在一起的整体对外只允许通过聚合根进行操作。聚合根是聚合内唯一能被外部引用的对象所有外部访问必须先经过聚合根内部对象的修改也必须由聚合根来协调。这个设计的根本目的是在分布式环境下缩小事务一致性的范围避免牵一发而动全身。订单这个聚合里通常包含订单实体、订单项实体、收获地址值对象、金额值对象。外部系统不需要知道订单项如何与订单关联也不应该直接去操作订单项的状态而是通过订单聚合根的方法比如“修改商品数量”“取消订单”来统一触发内部校验和状态流转。这样做的好处是聚合内部的数据一致性由聚合根来保证外部不需要理解复杂的内部规则。聚合设计的粒度是DDD实践中最难拿捏的部分。粒度太大会把太多业务逻辑塞进一个聚合导致并发冲突高、数据库锁竞争严重、事务范围过大。粒度太小又会导致聚合之间大量互相引用领域模型退化成松散的数据结构。我自己的经验是聚合应该尽量小小到能保证业务上的“固定规则”在单事务内一致即可。什么叫固定规则比如“订单创建后总额必须等于明细之和”“订单取消后支付单必须同步取消”这些就是必须在同一个事务边界内保证的固定规则。至于通知外部系统、生成操作日志、更新统计报表这些不属于聚合内部的固定规则完全可以靠领域事件或应用服务来异步完成。3.3 工厂、仓储与领域服务各自负责什么工厂负责创建复杂对象。当创建一个聚合需要经过多个步骤、涉及多个内部对象的组装时直接放在构造函数里很吃力也容易让调用方知道太多内部细节。用工厂方法把创建逻辑收拢在一起可以保证生成的对象总是处于合法状态。仓储负责持久化聚合。仓储的接口定义在领域层而具体实现放在基础设施层。这样做的好处是领域层完全不依赖数据库、ORM、表结构仓储接口返回的是聚合根而不是数据表实体。写代码时要注意仓储粒度是以聚合为单位不应该为聚合内部的每个表都提供仓储。很多人把仓储直接做成“泛型CRUD仓库”这恰恰把DDD带回了传统的数据访问模式。领域服务与传统的Service不是一回事。传统业务开发里的Service基本就是把业务逻辑全写在里面最后变成一个没有任何边界的“上帝类”。领域服务只在领域对象本身无法承载某个业务行为时使用它的特征是无状态、行为涉及多个聚合或外部系统、属于领域逻辑但放在实体里又不合适。举一个例子计算订单折扣。这个逻辑需要读取订单明细、用户会员等级、优惠券信息计算结果是订单总金额的一部分。如果放在订单实体里订单就要依赖用户和优惠券耦合太重。这时候建立领域服务“订单折扣计算服务”把规则收敛到一个相对独立的域名服务里订单实体保持纯粹逻辑也更清晰。易混点应用服务和领域服务的区别。应用服务负责用例编排、事务边界、权限控制领域服务负责具体的业务规则计算。应用服务不写业务规则领域服务不涉及网络协议和事务提交。3.4 领域事件解耦和最终一致性的桥梁领域事件表示“领域里已经发生的一件事”比如“订单已支付”“库存已超卖”。在DDD里聚合根在状态变化后对外发布领域事件其他聚合或者限界上下文订阅事件并做出响应。领域事件的一个经典使用场景是跨聚合的一致性。继续拿订单和库存举例订单确认支付后需要扣减库存。如果把扣减库存放在同一个事务里那就要同时锁订单和库存两张表在并发量高的情况下会严重影响吞吐。通过领域事件方式订单聚合在自己的事务里提交同时发布“支付成功”事件库存服务订阅事件后在另一个事务里扣减库存。两个操作之间短暂不一致是可接受的系统通过最终一致性达到同步。落地领域事件时有几个细节要注意。第一事件一定要包含业务语义而不是数据库变更语义事件名应该是“已发生的事”而不是“表更新了”。第二事件的发布时机通常是在聚合事务提交后避免事件发出但事务回滚造成的幽灵事件。第三需要设计事件的幂等处理机制因为消息中间件本身只能保证至少一次投递消费端需要依靠业务唯一键做好幂等。现在工程上常用的事务发件箱模式来解决本地事务和事件发布的原子性问题在聚合所在的同一个数据库里建一张本地消息表事务提交时同时写入业务数据和领域事件后台任务再把事件发布到消息中间件。这样既保证了事件不丢也不引入分布式事务的复杂度。4. 分层架构与代码落地的实操方案战略和战术概念都清楚了代码层面怎么落地是接下来真正的问题。DDD并没有规定固定的分层架构但最常用的有经典四层架构和六边形架构。我实际项目里更建议使用六边形架构也就是端口与适配器风格因为它与SpringBoot这类框架的契合度好而且边界清晰。六边形架构的核心思想是领域模型位于最中心不依赖任何外部设施外部通过端口接口与内部交互而端口对应的适配器则负责具体的技术实现比如HTTP请求适配器、数据库适配器、消息中间件适配器。领域层只需要面对接口不需要知道JDBC、MyBatis或Redis的存在。调用方向如下控制器/适配器 - 应用服务 - 领域服务/聚合根 - 仓储接口 - 仓储实现/第三方端口写代码时建议严格按照这个依赖方向控制并且强制禁止跨层调用控制器不能直接访问仓储接口基础设施层不能反向依赖领域层领域层严禁出现Spring注解和MyBatis的Model类。分层落地之后模块划分也是一个容易纠结的点。我的做法是按限界上下文划分顶层Module每个Module内部再按Domain、Application、Infrastructure、Interfaces分层。比如订单服务就是order-context这个Module独占一个代码仓库或者至少独占一个Maven模块内部按四层分包。这样团队开发时可以快速定位到某块业务也能很好地控制代码下沉和上引。下面是订单聚合领域的代码片段作为一套简化但完整的示例// 领域层订单聚合根 public class Order { private OrderId id; private ListOrderItem items; private Money totalAmount; private OrderStatus status; public void addItem(ProductSnapshot product, int quantity) { if (quantity 0) { throw new IllegalArgumentException(数量必须大于0); } if (status ! OrderStatus.CREATED) { throw new IllegalStateException(订单已提交不允许修改明细); } this.items.add(new OrderItem(product, quantity)); this.totalAmount calculateTotal(); } public void confirm() { if (status ! OrderStatus.CREATED) { throw new IllegalStateException(只有新创建的订单才能确认); } this.status OrderStatus.CONFIRMED; registerEvent(new OrderConfirmedEvent(this.id)); } }领域层代码读起来几乎就是业务规则在自然语言层面的直译只有创建状态的订单才能被确认数量必须大于零已提交的订单不能改明细。这些规则不需要借助任何框架和ORM就能清晰表达。仓储接口也同样简洁public interface OrderRepository { Order find(OrderId id); void save(Order order); }实现放在基础设施层Repository public class OrderRepositoryImpl implements OrderRepository { Autowired private OrderJpaMapper mapper; Override public Order find(OrderId id) { OrderDO data mapper.selectById(id.getValue()); return OrderAssembler.convertToEntity(data); } Override public void save(Order order) { OrderDO data OrderAssembler.convertToDO(order); mapper.save(data); } }应用服务编排操作时要保持薄薄的一层Service Transactional public class OrderAppService { private final OrderRepository orderRepository; private final DiscountService discountService; private final EventPublisher eventPublisher; public Order confirmOrder(OrderId id) { Order order orderRepository.find(id); order.confirm(); orderRepository.save(order); eventPublisher.publish(order.extractEvents()); return order; } }应用服务做的事情很纯粹找到聚合、调用聚合方法、保证事务、发布事件。它没有携带业务规则业务规则都在领域层。这样Controller层就只会做参数校验和返回转换代码写出来非常稳定。5. DDD落地的常见问题与排查实录DDD不是看几篇文章就能顺利用起来的落地过程会遇到一堆困扰。我把过去踩过的坑和团队里其他人遇到的问题整理成一份速查希望能帮还在摸索的人省下一些时间。5.1 聚合根更新时的并发控制和旧状态校验候选问题当两个请求同时操作同一个订单一个要做“取消”另一个要做“确认支付”如果没有并发控制两个操作就可能相互覆盖。排查思路核心要理解并发冲突本质上是聚合根状态被并发修改。解决的常见方式有两种。一种是乐观锁在聚合根中增加version字段保存时比较并更新版本号。另一种是悲观锁在仓储实现层通过数据库select for update锁定对应行。分布式环境下也可以考虑通过分布式锁按订单ID加锁但要特别小心锁粒度必须精确到业务实例不能锁整个表。实际经验是订单这类聚合大多数场景并发写非常集中且一旦状态流转完成就不允许再被修改所以乐观锁最简单有效。而对于账户余额这类高频写操作如果需要绝对的更新顺序则要用更复杂的锁策略或改为事件溯源方式。5.2 贫血模型和充血模型之间的平衡很多团队在践行DDD时写出来的代码依然属于贫血模型实体里只有getter和setter业务逻辑全部跑在Service里。这其实说明战术设计还没有真正落地DDD的优势因此也发挥不出来。但要注意贯彻充血模型不等于把大量代码塞进实体。实体内只承载自身一致性所必需的规则比如状态机、金额计算、结构约束。那些跨聚合的编排和外部系统交互交给应用服务和领域服务。判断一个方法放不该放在实体内可以问一个问题这个方法改了当前聚合的字段状态吗如果改了而且需要依赖聚合内的其他对象来保证一致那就放在聚合根内部。如果不改状态只是查询或者计算那可能更适合放领域服务。过度充血也是坑。我见过有人把搜索引擎索引逻辑都塞进商品实体导致实体里面既不“纯”也不“轻”每次加载商品都要连带处理一堆无关职责。实体关注的是业务一致性不是集成逻辑。5.3 事务边界和“跨聚合的假事务”最经典的误区是“因为业务涉及订单和库存所以干脆让一个事务同时提交两张表”。从关系型数据库本地事务的标准看这个操作是合理的但放在高并发核心链路上这会导致严重锁竞争随着聚合数量增长还会产生分布式事务的协调成本。更好的处理思路是先用聚合把“必须同生共死的数据”收拢到一个事务里把跨聚合的最终一致交给领域事件。比如支付成功后需要同时更新订单和扣减库存不要用一个大事务去锁全部而是让订单事务提交成功后发布事件库存服务异步处理。业务短暂不一致没问题只要最后收敛一致并且有对账兜底。判断事务边界是否合理的简单办法如果你发现自己在事务里调用远程RPC、等待第三方回调、或者锁了多个聚合的行那么边界十有八九划错了。5.4 仓储与数据模型不一致时的适配DDD里领域模型与数据库表结构往往不是一一对应的。一个聚合在数据库里可能对应多张表一个值对象可能以JSON字段存储一个领域概念可能需要从多个数据源拼装。所以我一直强调仓储实现本质上是适配器它负责领域对象和持久化数据模型之间的双向转换。转换过程可以用Assembler或Converter模式独立成类避免在仓储实现里堆积永久映射逻辑。当遇到性能瓶颈时可以在仓储内部做查询优化比如减少跨表查询、局部延迟加载、合理使用物化视图等但对外暴露的仓储接口不需要因此变化。5.5 团队协作通用语言没对齐一切白搭这是最隐蔽但影响最深远的问题。如果团队没有真正建立通用语言并持续维护战略设计阶段的成果会被推翻战术设计阶段的模型会变得模糊。我见证过一个销售域上下文团队内部开发一直在用“客户单”这个叫法但业务方不认这个词。业务方说的是“线索”“商机”“客户”但代码里的类名和产品PRD里的名词完全是两套体系。最终导致每次需求沟通都要先翻译一遍模型频繁调整返工率很高。解决通用语言不一致没有捷径要不断召开事件风暴或模型评审会把业务方、产品、研发拉到一起统一术语词典是必须的。每个限界上下文要有术语表核心概念用代码名和业务名双向映射。如果有条件将术语表整理到团队Wiki并定期评审新成员入职时也要先过一遍。5.6 什么时候不该用DDD最后一条要比前面所有内容都更重要DDD有明确适用场景不是到处都要套用的。简单CRUD项目、报表类项目、管理后台类项目强行使用DDD只会增加开发成本写出“为了分层而分层”的代码。团队的领域专家参与度低研发和业务无法顺畅沟通时上DDD也大概率失败。适用DDD的场景往往有几个特征业务规则复杂且持续演化有核心业务需要精细化运营团队具备领域建模能力和技术基础开发节奏允许在前期设计和建模上有一定投入。如果你团队现在的业务只是简单增删改查完全可以先做一套标准的Controller-Service-Mapper架构等业务复杂度到了临界点再引入DDD也不迟。6. 关于DDD的几点个人体会DDD真正难的不是那些术语也不是聚合和事件这些技术点而是它要求团队里每一个人都愿意停下来思考“业务是什么”。在长期忙于接需求、赶上线、修Bug的氛围里这种思考显得特别奢侈。但项目越是复杂、业务越是在演进这种沉默的成本反而越高。我和团队实践下来最明显的收获不是某一套代码写得“好看了”而是业务方和研发之间的沟通方式变了大家开始用同一个词讨论同一个概念方案评审时不再争论实现细节而是在对齐业务规则。另外DDD一般不是一步到位的。如果团队第一次接触我建议不要试着一次性完成全部门或者整个中台的建模那样风险太大了。最安稳的第1步是找一个最痛的核心模块例如订单、库存或价格中心用两三天时间做事件风暴和上下文梳理再小范围写一部分代码验证模型。当团队积累了信心和方法论后再逐步推开会顺畅很多。我还想补充一个细节DDD和微服务天然契合但不代表DDD只服务微服务架构。单体应用领域模型同样可以用DDD来组织代码只是不需要考虑进程间事件通信那一套复杂性。所以不用为了上DDD而先去拆微服务很多时候业务模型的边界清晰了未来微服务该怎么拆反而是水到渠成的事。如果真的想更深入地感知DDD光看文章远远不够。建议找一个真实存在的、自己熟悉的业务场景亲手做一个完整的建模画出限界上下文、定义聚合、写一段领域代码然后找一个业务专家认真聊聊模型是否匹配。模型的推敲和被推翻本身就是DDD最有价值的部分之一。很多人问“领先的团队在用DDD吗”我的感受是真正领先的团队并不是在追这个名词而是在用DDD的方法帮助自己持续、高效地处理复杂业务。
返回列表