ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

领域驱动设计实战指南:从业务建模到代码落地 1. 业务系统腐烂的真实原因问题不在代码在建模我见过太多这样的场景一个新业务系统上线不到两年维护成本就开始飙升。产品提一个需求开发排期从三天变成两周新同事入职三个月还不敢碰核心模块的代码线上出了Bug全组人翻日志定位了两个小时最后发现是某个状态机分支没覆盖到。更让人头疼的是业务方跟你说的订单状态、技术团队代码里的订单状态、数据库表里的order_status三者含义经常对不上——产品说已发货并不代表快递员真的取走了包裹而代码里这个字段可能只是用户点击了确认发货按钮。这些症状表面上是代码质量问题但根子往往不在代码而在业务建模。传统分层架构下我们习惯性地把业务逻辑堆在Service层里一个下单方法能写两百行前端判断促销、库存、支付、风控、物流全部塞在一个方法里面。久而久之Service变成了上帝类Entity变成了纯粹的数据载体——这就是典型的贫血模型。它带来的问题非常直接任何一个业务规则的变化都要改这个上帝类改的人提心吊胆因为牵一发而动全身测试很难写因为所有逻辑都耦合在一起而且代码里的命名、规则、流程和业务人员的真实认知严重脱节。领域驱动设计DDD的名字很容易给人错觉好像它只是另一种代码分层或者多用几个设计模式。但我实际落地下来的感受是DDD解决的核心问题是业务复杂度。它逼迫你在写代码之前先回答几个看似简单但大多数团队根本没想清楚的问题——你的业务到底有哪些领域每个领域的边界在哪里领域里最关键的业务规则是什么业务人员和技术人员对同一个词的理解是否一致这些问题想清楚了代码怎么写反而是水到渠成的事。所以我一直觉得DDD不是一套代码框架而是一套从业务认知到技术实现的思维范式。如果你现在的系统只是简单的CRUD表单提交、列表展示、增删改查那DDD大概率帮不上你什么忙反而会增加复杂度。可一旦你的系统里有资金流转、有状态流转、有复杂的规则判断、有多个角色协作完成一件事那DDD就是成本最低的长期投资。2. 战略设计的三板斧统一语言、限界上下文、上下文映射第一次接触DDD的人最容易被一堆术语绕晕实体、值对象、聚合、领域事件、防腐层……但我要强调一个很多人忽略的事实——DDD真正最值钱的部分是战略设计而不是战术设计。战略设计回答的是这个业务系统应该被切成几块每块做什么、不管什么战术设计只是在你已经确定了切分方案之后指导你每一块内部的代码怎么写。大部分团队做DDD失败不是代码写得不好而是战略设计压根没做扎实。这里我把战略设计的三个关键动作逐一拆开。2.1 统一语言用一本词汇表解决沟通内耗统一语言Ubiquitous Language这个概念听起来简单做起来最难。它要求在某个业务范围内技术团队和业务团队使用同一套词汇每个词的含义唯一且明确。举个我自己经历过的例子。我们做一个分销系统时业务方说客户下单他们说下单是指用户提交了一个购买请求但此时钱还没付而我们技术团队理解的订单创建是数据库里insert了几条记录生成一个订单号不管用户付没付钱。后来讨论这个订单要不要进入履约流程时两边吵了半天最后才发现说的根本不是同一个状态。这就是典型的同名不同义。解决方式看起来很笨但非常有效拉上业务核心人员和研发核心人员一起建立一个领域词汇表把一个限界上下文里出现的高频业务词汇逐个定义清楚。比如订单在交易上下文里指什么、在履约上下文里指什么两者的生命周期又有何关联。而且这个词汇表要不断维护每次有新的概念出现都要补充进去。提示词汇表不要只存在架构师的文档里应该落到代码里的类名、方法名、常量名上。让代码本身就是对统一语言的表达这样大家读代码时看到的名字和业务人员日常沟通用的词是一致的才能形成真正的统一。2.2 限界上下文业务系统的国境线限界上下文Bounded Context是DDD最核心也最容易被误解的概念。我见过很多人把订单系统用户系统支付系统当成限界上下文这种按系统边界划分的思路是有问题的。限界上下文应该按业务语义的一致性来划分而不是按部署单元来划分。举个例子同一个用户在注册登录上下文里它有用户名、密码、手机号在营销上下文里它有积分、等级、优惠券在订单上下文里它是收货人信息、发票抬头。如果强行用一个User表包含所有这些字段模型必然会被稀释得很严重。我常用的判断方法是问一个问题**这一部分业务规则是否可以不依赖于外部系统独立理解和描述**如果能它就应该是一个独立的限界上下文。每个限界上下文内部有自己的通用语言、自己的业务规则、自己的数据模型——哪怕两个上下文都叫用户它们各自的模型也是独立的。从工程实践角度来说限界上下文并不一定要对应一个微服务。它可以是一个模块、一个包、一个进程内的边界。对于多数业务系统来说模块级的边界已经足够。2.3 上下文映射上下文之间怎么协作限界上下文之间不是孤岛它们需要协作。上下文映射Context Mapping就是描述这种协作关系的工具。最常见的几种关系有关系类型场景描述落地方式合作关系Partnership两个上下文需要同步交付才能完成业务紧密沟通共同规划共享内核Shared Kernel两个上下文共享部分模型如状态枚举提取公共模块谨慎变更防腐层Anti-Corruption Layer下游上下文保护自己的模型不被上游污染在边界处做翻译和转换客户-供应商Customer-Supplier下游需要配合上游的接口约束签订契约遵循接口协议发布语言Published Language用公开的协议/文档进行交互消息契约、OpenAPI等在实际项目里我遇到最多的是防腐层落地。比如我们的订单履约上下文需要查询商品上下文的商品信息但商品上下文的数据模型里面有大量的营销字段——什么限购标识预售标记组合商品规则履约上下文根本不关心这些东西。如果没有防腐层履约系统会直接去连商品库的表一旦商品表结构变化或者要同时兼容多个版本的商品数据履约系统就被污染了。正确的做法是在履约上下文的边界处定义一个履约商品模型只保留履约需要关心的商品属性SKU编码、名称、重量、体积、温层等然后通过一次适配转换把商品上下文的数据翻译成履约商品模型。这就像两个国家之间设置一个海关货物进出都要通过海关检查、登记、换汇不允许两国人员随意跨境流动。3. 战术设计的落子规则实体、值对象与聚合边界战略设计确定了切几块之后我们进入每块内部的设计这就是战术设计。这块最容易让人要么设计过度、要么凭感觉瞎画。我在给团队做评审的时候把重心放在三个判断上哪些是实体、哪些是值对象、聚合边界划在哪里。3.1 实体与值对象一个耐人寻味的判断标准实体Entity和值对象Value Object的经典判断标准是这个对象有唯一的身份标识吗它的身份需要在整个生命周期中保持不变吗有唯一身份、身份不随属性变化而改变——实体。没有唯一身份、用属性本身来定义它是它——值对象。这样说还是有点抽象我举一对例子订单是实体。一个订单订单号定了就是定了哪怕收货地址、商品数量都改了它还是那笔订单。订单中的收货地址是值对象。你不需要给一个地址分配一个地址ID来追踪它地址由省市区街道门牌号收件人电话这些属性整体决定它的意义。如果把下单地址和改寄地址进行比较你比的是属性内容而不是看它们是不是同一个ID。在编码实现上值对象最重要的特点是不可变。地址的属性一旦创建就不能改变要新地址就新建一个值对象。这与实体的做法形成鲜明对比——实体的属性可以被修改比如订单的物流单号可以更新因为实体靠ID维持身份的连续性。我见过不少开发者在实现金额的时候直接用BigDecimal裸字段在多个方法之间传来传去然后每个方法里都要先判断一下金额不能为负数。这种贫血式做法就是没有意识到金额本身就是值对象。把金额封装成Money对象内部持有币种和数值提供add、subtract、isGreaterThan等方法整个系统的货币计算逻辑就收敛在一处不会出现某个方法忘了判负、某个接口偷偷传了错误币种的问题。3.2 聚合与聚合根把操作边界划清楚聚合Aggregate是DDD战术设计里最能提升代码质量的概念。聚合表达的是一组对象的集合它保证这一组对象作为一个整体来执行业务规则、保证数据一致性。我常说聚合就是以聚合根为入口管理一组强关联对象的边界。聚合根Aggregate Root是聚合中唯一能被外部访问的对象外部只能通过聚合根的方法去操作聚合内的其他对象不能直接绕过聚合根去修改内部状态。选择聚合根、划定聚合边界有两个经验法则第一事务边界就是聚合边界。如果一个业务操作必须同时修改A、B、C三个对象并且事务必须一起成功一起失败那它们大概率应该在同一个聚合内。如果每次操作只改A、不依赖B的状态那就把B拆出去缩小聚合规模。第二聚合的规模宁小勿大。我见过一些人的设计把Order和OrderItem放一个聚合里同时把Customer和Address也塞进Order聚合美其名曰一个下单流程涉及这些对象——这就是典型的过度聚合。聚合是被业务规则强绑定的一群对象不是一个业务流程涉及的所有对象。下单涉及客户信息但修改客户地址和创建订单不应该在一个事务里完成所以Customer也不应该被拉进订单聚合。我推荐用一个小例子讲透聚合规则。订单聚合根是Order聚合内包含OrderItem、OrderStatus、PaymentInfo。外部不能直接调用order.items.add()来加一个商品必须调用order.addItem(sku, quantity, price)在这个方法内部校验库存、校验数量限制、更新总金额。这套封装保证了聚合内的所有状态变更都是受控的、经过业务规则检查的而不是被外面某个Service方法直接set个字段就完事。3.3 领域服务与应用服务让流程不变成大杂烩实体和聚合适合表达状态和行为但有些业务动作不天然属于某个实体——比如生成对账单计算运费锁定库存。这时候要不要把它塞进某个实体里我的建议是塞不进去的业务动作就建模成领域服务Domain Service。需要辨析的是领域服务和应用服务Application Service。很多人把Service层直接当成业务逻辑层然后命名OrderService里面既做参数校验、又做事务管理还写具体业务规则。这在DDD里是有问题的。应用服务是编排职责。它负责获取输入参数、调用一个或多个领域对象的方法、协调领域服务的执行、管理事务边界然后返回DTO给上层。它自身不承载业务规则。领域服务则承载那些不归属于某个实体的业务规则——比如计算两个地点之间的配送时效它无法被放在Order或Warehouse实体中因为这是两者协作产生的规则于是把它建模为一个DeliveryTimeCalculator领域服务专门负责这一规则。一个实用的分层原则是应用服务保持薄领域服务和聚合保持厚。如果你发现应用服务里面的业务判断越来越多、领域对象里的方法越来越少说明业务逻辑又跑到应用层了需要警惕。4. 实战记录从臃肿Service到有条理领域模型的重构过程理论讲再多不落到代码上面都白搭。这一节拿我们团队做过的一个订单履约模块重构当案例带你完整走一遍从看不下去到能睡觉的改造过程。4.1 改造前的代码病灶改造前的订单履约模块基本是教科书级的反面案例。一个OrderServiceImpl类三千多行方法总数四十多个。核心的submitOrder方法长度接近一千行伪代码大概长这样public void submitOrder(OrderRequest request) { // 1. 参数校验 if (request.getUserId() null) throw new IllegalArgumentException(); if (request.getItems().isEmpty()) throw new IllegalArgumentException(); // 2. 商品查询 ListProduct products productMapper.selectByIds(request.getItemIds()); // 3. 库存锁定直接操作库存表 for (Product product : products) { if (product.getStock() request.getItemMap().get(product.getId()).getQuantity()) { throw new StockNotEnoughException(); } stockMapper.decreaseStock(product.getId(), quantity); } // 4. 价格计算这里包含大量优惠规则 BigDecimal totalPrice BigDecimal.ZERO; for (Product product : products) { totalPrice totalPrice.add(product.getPrice()); } if (request.hasCoupon() request.getCouponType() 1) { totalPrice totalPrice.subtract(new BigDecimal(10)); } // 还有更多优惠分支... // 5. 构建订单并落库 Order order new Order(); order.setUserId(request.getUserId()); order.setTotalPrice(totalPrice); order.setStatus(1); orderMapper.insert(order); ListOrderItem items new ArrayList(); // ... 组装 items ... orderItemMapper.batchInsert(items); // 6. 发送消息通知库存系统 mqTemplate.send(stock.deducted, ...); // 7. 记录操作日志 logMapper.insert(...); }这段代码的问题一眼就能看出来库存逻辑、价格计算、订单创建、消息通知、日志记录全部耦合在一个方法里。某个版本中优惠券满减规则变了改这个方法库存双11大促时不支持锁定的新需求来了改这个方法需要增加订单备注也改这个方法。每次改动都战战兢兢因为稍不留神就会改坏其他逻辑。而且这个方法根本没法做单元测试——要mock一堆Mapper维护成本极高。4.2 建模过程从一个顺口溜开始改造的时候我没有先画UML图而是先和业务方聊清楚订单履约的完整业务流程用业务语言表达。我们梳理出的几个关键业务需求是用户提交订单时系统需要校验商品是否可售上下架状态、售卖时间、限购规则锁定库存必须和创建订单在同一个事务里完成否则会出现锁了库存但订单没生成的数据不一致优惠金额计算规则非常多每个月都在变但基础金额和优惠金额是分开记录的订单生成后要发消息给物流系统但消息发送失败不能影响订单创建事务带着这些需求我们开始明确限界上下文。订单履约这个上下文内部可以分为两个聚合OrderAggregate聚合根为Order聚合内包含OrderItem、OrderDiscount。每个订单内会维护它的商品明细和优惠明细。InventoryAggregate聚合根为SkuInventory负责库存扣减、释放、锁定的生命周期管理。这里最关键的设计决策是库存状态不再通过OrderServiceImpl里的Mapper去直接操作而是统一收口到InventoryAggregate的领域服务InventoryService中。Order聚合和Inventory聚合之间通过领域事件OrderCreatedEvent来异步解耦——订单创建成功后发布事件库存系统监听事件后再去执行实际的扣减。这样一来submitOrder的核心流程从一千行瘦身到几十行。4.3 改造后的核心代码骨架下面是改造后submitOrder的骨架部分节选你可以体会一下应用服务和领域对象的分工Service public class OrderApplicationService { private final OrderRepository orderRepository; private final ProductQueryService productQueryService; private final OrderDomainService orderDomainService; private final DomainEventPublisher eventPublisher; Transactional public OrderId submitOrder(SubmitOrderCommand command) { // 应用服务只做编排获取基础数据、调用领域方法、落库、发事件 ListProductInfo productInfos productQueryService.queryAvailableProducts(command.getSkuIds()); // 创建订单聚合根 Order order Order.create( command.getUserId(), productInfos, command.getDeliveryAddress(), orderDomainService.calculatePrice(command, productInfos) ); orderRepository.save(order); eventPublisher.publish(new OrderCreatedEvent(order.getId().getValue(), order.getUserId(), order.getItems())); return order.getId(); } }你会注意到应用服务里面几乎没有业务判断逻辑它唯一做的是拉取数据、调用Order.create、保存、发事件。真正的规则都在聚合里public class Order { private OrderId id; private UserId userId; private ListOrderItem items; private Money totalAmount; private OrderStatus status; public static Order create(UserId userId, ListProductInfo products, Address address, Money totalAmount) { // 领域规则构造时校验商品是否为空、数量是否合法 if (products.isEmpty()) { throw new InvalidOrderException(订单至少包含一个商品); } // ... 更多创建规则 return new Order(userId, products, address, totalAmount, OrderStatus.CREATED); } public void confirm() { // 领域规则确认订单时才允许进入下一个状态 if (this.status ! OrderStatus.CREATED) { throw new IllegalStateException(当前状态不允许确认); } this.status OrderStatus.CONFIRMED; } }价格计算被建模成一个领域服务OrderDomainService.calculatePrice它内部实现多套优惠规则的组合与验证。不同优惠策略可以用策略模式组织起来每种优惠规则一个类不再堆在OrderServiceImpl里写if-else。4.4 改造之后的效果改造完成后最直观的效果有三个OrderApplicationService从三千行降到一百多行核心方法没有超过40行针对优惠规则的单测从零增加到四十多个每个优惠规则都可以独立测试不必启动整个Spring容器最实际的价值是——运营提需求时我们已经能快速评估出这个改动落在哪个聚合影响面有多大。以前的需求沟通是一个业务→产品→开发→猜的黑箱过程现在业务方说的订单确认订单锁定和代码里的方法名、聚合行为一一对应改动点从拍脑袋变成了可预期的局部变更。提示这次改造我们没有重写数据库表结构也没有引入事件总线等重型框架只在代码架构层面做了聚合边界调整。DDD改造并不是非要上微服务、上ES、上CQRS才叫落地哪怕在单体内把业务复杂度理清楚已经能解决大部分问题。5. 团队落地DDD最容易踩的坑与应对策略一个方法论落地最大的阻力往往不是技术而是团队对建模这件事缺乏耐心。DDD在很多团队最终变成了换了个包名多写几个对象的形式主义。我在这里把实践中踩过、看过的坑列出来这些比概念细节更值钱。5.1 坑一把DDD当成代码分层模板我经常看到团队说我们用了DDD打开他们的代码controller-service-repository三层结构只不过Service里面把原来一个类拆成五个类类名多了Domain字样然后每一个方法还是原来的老逻辑数据库表驱动设计一个表对应一个实体。这种团队做的不是DDD只是换皮。问题的根源在于他们依然从数据库表出发思考问题而不是从业务模型出发。DDD的建模过程天然应该是先有领域的语义和规则然后才有数据表和代码结构。如果表都已经定死了再套DDD的壳子只会增加一层没必要的抽象。我建议在项目启动阶段就组织一次完整的建模工作坊。不用太久两到三天的集中讨论就够。参与者包括业务方代表、产品经理、后端主力、前端代表。产出物只有两个限界上下文图或者一张表格列清楚每个上下文的职责和协作方式以及实体/聚合清单。代码在这两天里一行都不用写但后面要写的代码基本都是这次工作坊的翻译。5.2 坑二没有领域专家建模变成程序员自嗨DDD的整个理论体系都建立在有领域专家参与这个前提上。但在国内很多公司没有专职领域专家对业务的理解分散在不同的人身上有的在运营那里有的在产品经理那里有的在测试用例里甚至有的只在老代码里。面对这种情况不要等领域专家从天而降而要做两件事。第一让最懂业务的技术负责人承担起业务翻译官的角色他必须能回答出为什么这个字段叫这个名字这个状态变化背后是为了满足什么业务诉求。第二技术团队主动组织事件风暴Event Storming。事件风暴是一种轻量的建模工作坊把业务专家拉到一个屋子里贴着黄色便签把业务过程中发生的领域事件一个个写出来再按时间轴排列最后聚类出聚合和一致性问题。这一招特别适合启动一个全新项目或者重构一个烂摊子它逼着所有人在白板上把业务跑一遍而不是坐在一起讨论订单状态有几种这种干巴巴的问题。5.3 坑三过度设计——建模建到无法迭代有经验的开发者容易走到另一个极端把每个小规则都建模成策略模式每两个对象之间都加一个接口和工厂一个聚合里塞五个实体、十个值对象。最终结果是代码很DDD但没人能看懂一个新同学入职一个月都改不动一个简单需求。我的标准是**能用一个对象方法表达的业务规则就不要另外抽象一个类能放在同一个包下的强关联就不要拆出独立的模块不要为了扩展性预先设计一堆用不上的接口。**DDD的聚合边界、领域服务划分不是越细越好而是要做到刚刚够用。系统是动态演进的你今天觉得会变化的点三个月后也许根本没人动它而你完全忽略的另一个点可能下周就要改。所以设计的核心是拥抱当下明确的复杂度而不是预测未来所有可能的复杂度。另外我补充一个非常现实的指标如果你们团队的代码评审开始因为聚合边界划得不合理而反复争论、迟迟无法达成一致那大概率是你们已经设计过度了。真正的聚合边界讨论应该是一拍即合、很快能达成共识的。如果一个边界怎么划都纠结说明业务本身还没梳理清楚这时候与其画边界不如先把业务流程图走通。5.4 坑四全量重构的冲动与渐进式改造的智慧还有一个高频错误一听到上DDD立刻有人提议全部重写。这是技术债务的陷阱——旧系统的逻辑本身就是业务知识的沉淀如果一次性推翻重写你有极大概率把很多隐含的业务规则丢掉然后上线后各种出bug。我建议采用绞杀者模式的渐进式改造思路。比如刚才案例里的订单履约模块就是从整个系统中先圈出订单创建这一个用户触点集中力量对这个触点做DDD建模和改造其他模块维持原样。随着改造的进行新的模块逐渐覆盖旧的逻辑最终达到新系统完全替代旧系统的目标。渐进式改造有一个重要前提旧系统的核心逻辑必须先梳理清楚。这个梳理不是看代码而是要跟业务方把这个东西到底是怎么运作的一条条过一遍并且要求旧系统的维护人员把很多只可意会不可言传的规则文档化。否则你改出来的新系统一样是看不懂的。5.5 坑五DDD落地初期与团队能力的错配最后说一个组织层面的坑。DDD对团队成员的能力要求是高于普通CRUD开发的它要求每个人都具备一定的抽象思维和业务分析能力。如果团队整体水平还不成熟强行上DDD很容易导致只有架构师看得懂代码其他人都是工具人的局面。应对思路是不要一上来就让全团队都改写法而是选一个核心模块由两位有经验的同事组成先锋小组打样同时安排定期的代码走读和建模复盘让其他同事在参与评审的过程中逐渐理解背后的思路。这个过程大概要持续两到三个迭代周期。新方法论的导入本质上是一种团队能力建设节奏快不了也急不来。最后再分享一个我个人的实操体会DDD带给团队最大的变化往往不是代码结构更合理而是沟通方式更合理了。大家开始认真地讨论业务规则、讨论领域词汇、讨论系统的边界——这些讨论在传统的写代码模式下是严重缺失的。当技术团队和业务团队能围绕同一个统一语言对话时很多以往要等到上线前才暴露的扯皮问题在设计阶段就能消弭于无形。这也是我为什么一直坚持在实战中用DDD去拆解复杂业务系统而不是单纯依赖某套框架或工具的原因。
返回列表