
霸王餐这个业务规则看着不复杂真做起来却能让你怀疑人生。商家要发活动、平台要审核、用户要抢名额、还要交押金、到店核销、写评价平台再核验评价、退押金中间还穿插各种运营活动规则、商家评级、用户等级。我在负责这块平台重构的时候第一件事就是用领域驱动设计DDD把霸王餐的核心业务边界和聚合根重新拆了一遍。这篇文章就围绕这件事展开把为什么拆、怎么拆、拆完怎么落地以及我踩过的坑全部摊开讲一遍。读完你至少能掌握一个真实业务的建模路径而不是在网上看到各种“DDD架构”概念后依然搞不懂它在项目里到底怎么用。1. 项目背景与整体设计思路1.1 霸王餐业务到底在做什么霸王餐不是字面意义上去“白吃白喝”它是一种常见的引流营销模式。商家为了拉新、冲评价、提升店铺热度在平台上发布一个免费或大折扣套餐用户看到活动后报名参与到店消费后再提交一条真实评价。平台在其中承担审核、撮合、跟踪和担保的角色。一条完整的霸王餐链路大概是这样的商家创建活动设置折扣力度、名额数量、使用时间、参与门槛。平台运营人员审核活动确保内容合规、商家有资质、套餐价格设置合理。活动发布后符合条件的用户在客户端浏览、报名、抢名额。为了防止有人恶意占用名额通常需要用户支付一笔小额保证金或预约金。用户到店消费出示核销码商家或系统核销。用户在规定时间内提交评价包含图片、文字有时还有标签和评分。平台审核评价内容审核通过后保证金退回用户账户同时可能发放积分或奖励。商家端看到报名人数、回头客比例、评价质量等数据作为后续运营参考。表面上是“发活动-抢名额-写评价”三段流程但内部涉及的参与者有用户、商家、平台运营、审核员每种角色关注的信息和操作权限都不同。如果直接按数据库表来设计很容易搞成一张巨大的“活动订单表”塞下所有字段把业务流程压成数据堆。等到你发现要处理“核销却还没评价”“退款了但评价被删”“活动下架了报名还在继续”这类交叉状态时改代码的成本会高到你想重写系统。1.2 为什么要用DDD来拆边界过去在中小型项目里我习惯写一种“表驱动”的代码先设计数据库表再在Service层堆业务遇到状态不统一就加两个 if遇到跨表查询就 join。表面上开发快但霸王餐这种业务最怕的就是这种“快”。为什么举几个最简单的例子报名人数和活动名额必须一致。用户报名成功名额要减一用户退款名额要加回来。如果这两个数据散落在不同模块里没人能保证它们永远同步。评价审核和退款结算有强关联。评论被驳回用户需要有机会修改重新提交评论最终通过押金才能退。发布大而全的状态字段逻辑会绕成一团。库存扣减、支付回调、退款通知这些外部事件是异步的不能什么都塞进一个同步事务里。DDD的核心贡献在于它强迫从业务视角去识别系统应该有哪些业务边界并为每个边界内部建立一致性。哪些东西必须同一个生命周期哪些明明可以独立变化却被人为绑在一起这些不通过深入业务建模就想不清。用 DDD 拆一遍得到的结果并不是“有多少张表”而是一张“业务如何运行、每个决策由谁来负责”的清晰地图。1.3 拆边界这件事难点在哪拆边界的难度不是技术而是两个问题第一个是“边界到底切得细不细”第二个是“切完以后大家认不认”。切太细的典型表现是聚合根遍地都是每个VO都老大不小一个业务流程横跨五个聚合代码里到处都是事件和异步补偿系统被拆成了微服务的样子却没有任何微服务的收益。切太粗的相反活动、报名、评价、退款全都在一个聚合里看上去很像最开始的“大表”每次加载都很重而且交易锁粒度太粗。我自己的经验是拆边界要从“变化原因”和“一致性约束”出发而不是从数据表或页面菜单出发。一个边界内的成员应该会因为同一个业务规则而发生改变并且必须保持同步。如果两个东西经常因为不同的目标被修改那它们大概率应该分开如果某个业务错误在拆分后无法被及时发现那它们就应该在一个聚合里待着。这些道理说起来简单实际操作需要一环一环来。2. 把DDD几个核心概念说清楚2.1 聚合根公司内外有别的“管理单元”如果你第一次听说“聚合根”直接读《领域驱动设计》原版定义通常会看晕因为它用了大量形式化描述。我的理解方式是用公司来类比。一间公司内部的部门可以随时互相聊天比如运营可以把一条活动规则直接发给客服确认行政可以登记会议室这些内部沟通不需要经过外部审批。但公司对外的行为只有一个口径合同要法务盖章报价要销售总监批准项目交付要项目经理签字。聚合根就好比公司的总经理聚合内部的对象是各部门员工员工之间可以内部协作但外部访问公司时必须找总经理跨公司合作只能通过总经理。对于用户来说操作聚合根就是调总经理接口不直接操作员工。这样能保证公司内部的规则不会被绕过被绕过的风险高一致性就容易出问题。聚合根承担的就是“这个业务单元的整体入口”职责。数据库实现上一个聚合可以对应一张表也可以对应多张表聚合内部的数据修改必须经由聚合根路由事务边界以聚合为界。2.2 实体与值对象谁是谁以及它值多少另一个容易绕晕的概念是实体和值对象。区分它们有一个通用判断法你关心的是“它是谁”还是“它是什么”。实体有唯一的身份标识身份在生命周期内保持不变。比如用户是实体因为任何时刻我们都需要能区分“张三”和“李四”张三改昵称还是张三。活动、参与记录、评价记录这些业务对象也是实体因为它们的ID会被长期引用。值对象没有身份它只由一组属性值构成。两个值相等就认为它们是同一个东西。比如用户的地址是值对象邮编改成100020还是同一个地址概念活动的金额保证金200元是一个值对象换成300元就是另一个值对象。建模时区分实体的意义在于实体内部的状态是最终一致的负责人值对象则适合用来封装不可变规则。在霸王餐业务里我把“活动时间范围”“参与门槛规则”“订单金额”“核销码”都设计成值对象把“活动”“参与记录”“评价”设计成实体。这样做的好处是当业务规则变化时变化点会被限制在值对象里不会污染整个聚合。2.3 聚合边界的四个判断标准判断两个对象能不能放进同一个聚合我看四个标准一致性约束。如果有任何场景要求“报名人数必须小于等于活动名额”这种原子约束必须成立报名和名额就要在同一个聚合里或至少要保证它们之间是紧耦合的。生命周期。如果一个对象不存在了另一个对象也没有继续存活的意义它们通常属于同一个聚合。业务闭环。一个“完整业务操作”是否必然同时涉及两个对象。评论审核通过和退回押金理论上存在先后关系但不是原子关系。变化理由。两个对象会因为同一个需求变更而改变还是经常因为不同需求被修改频繁同变的放一起更合适各自独立的拆开更合理。还有一个我常用的反证法如果拆开后跨聚合一致性让我需要频繁定义最终一致的补偿事件那可能是切分过度如果合并后一个聚合加载大量无关数据、锁范围过大那就是合并过度。好的边界最终会在“强一致性”和“可维护性”之间得到一个大家都愿意接受的平衡。3. 霸王餐业务建模全过程3.1 从事件风暴开始梳理业务做DDD建模我永远从事件风暴开始。不需要先画类图也不要先建表而是先把业务里会发生的“事实”全部列出来。做法是拿一张白板把角色、动作、事件写在不同颜色的便利贴上按时间线从左到右排列。霸王餐核心事件依次有商家提交了活动申请。平台审核通过了活动申请。平台审核驳回了活动申请。活动发布上架。活动被设置为下架或结束。用户提交了报名申请。用户支付了保证金。活动名额被扣减。报名资格确认成功。用户到店完成了核销。用户提交了一条评价。平台审核通过了评价。平台驳回了评价。系统发起了保证金退款。退款完成。用户被发放积分或优惠券。事件列完以后再给每个事件找触发它的“命令”比如“提交活动申请”由商家触发“审核活动”由运营触发“支付保证金”由用户或支付系统回调触发。这些命令最终都会指向某一个聚合也就是这个命令要调用的业务对象。这一步能让我快速看到哪些命令总是围绕同一个业务对象在发生。3.2 划分有界上下文事件的分布已经暗示了几个相对独立的业务区域。我在霸王餐项目里划分出了以下有界上下文上下文核心职责主要对象活动上下文商家创建活动、平台审核、上下架、名额设置活动聚合、活动审核记录参与上下文用户报名、资格确认、支付保证金、到店核销参与聚合评价上下文评价提交、内容审核、修改申诉评价聚合账户结算上下文用户余额、押金流水、退款、积分用户账户聚合、押金流水商家中心上下文商家主数据、门店、经营数据统计商家聚合五个上下文听上去不少但对于霸王餐这类业务是合理的。如果平台很小、团队只有两三个人可以把“账户结算”和“参与上下文”合并如果业务对审计要求很高甚至可以单独拆出“退款流水上下文”。我的经验是边界不该为了拆分而拆分但核心上下文之间的耦合越少后期做活动策略和风控扩展就越容易。3.3 识别聚合根并展示理由在上下文之下我进一步找出了真正的聚合根。这一步最花时间因为需要不断问“这个对象到底在保证什么一致性”。活动聚合根Activity包含活动基本信息、活动规则、参与门槛、名额设置、审核状态以及报名名单。为什么把报名名单放在活动聚合里关键在于“名额不能超发”是业务硬规则。用户报名时活动聚合需要在一个事务里检查“当前已报名人数 最大名额”并在成功时扣减名额。如果报名记录不在活动聚合里这条约束要么需要跨聚合事务要么需要分布式锁代价非常大。当然这会让活动聚合在报名高峰期成为热点。处理方式会在后面讨论但建模阶段我不会因为性能难就拆掉一个本来该有的不变式。参与聚合根Participation参与聚合代表一名用户对某一次活动的一次报名行为。它内部包含参与状态、押金状态、核销状态、资格信息等。为什么参与不能完全塞进活动聚合因为参与的后续生命周期很长支付有回调、核销可能要等几天、评价审核可能要等更久。如果一直锁活动聚合日常高并发下谁都抢不到名额。参与聚合的根实体负责维护从“已报名”到“已退款”的全部状态流转它是用户端感知最直接的对象。评价聚合根Review评价为什么单独拆分在我最初设计时也想过把评价放参与聚合里毕竟一次参与对应一条评价。后来发现评价有独立的审核规则内容格式、图片数量、标签匹配、疑似虚假点评判断、驳回原因记录。审核员关注的是评价本身而不是参与记录风控要拉取用户历史评价做反作弊也不可能逐个查参与记录。把评价独立成聚合方便在评价上下文里做各种审核状态机。此时评价聚合通过 ParticipatedId 字段关联参与聚合并不直接持有参与对象引用。评价审核通过后发出领域事件通知参与聚合推进状态。用户聚合根User与商家聚合根Merchant用户聚合保存用户主数据、信用等级、历史行为摘要。商家聚合保存商家主体信息、店铺状态、活动统计摘要。它们都是独立稳定的实体有自己的生命周期和一致性约束但更多时候会被其他上下文引用ID做只读展示。各聚合的归属关系可以用这个表格看清楚聚合根内部核心对象主要不变量独立变化的原因Activity活动信息、活动规则、报名名单报名人数小于名额、活动状态与活动时间一致活动创建、审核、上下架Participation参与记录、押金记录、核销记录摘要一次参与状态流转不可跳跃或逆序报名、支付、核销、退款Review评价正文、图片列表、审核结论评价不可重复提交、只有核销后才能评价评价内容变化、审核驳回User用户基本信息、账户摘要用户名唯一用户注册、信息修改Merchant商家资料、门店信息、统计摘要商户状态合法商家资质更新、被处罚3.4 跨聚合流程推演与域事件聚合拆完之后要重新推演一遍完整业务流看聚合之间怎么协作。霸王餐最典型的一段流程是“用户报名-支付-核销-评价-退款”我分步骤描述一下第一步用户点击报名。参与上下文的应用服务调用活动聚合的公开方法“尝试报名”活动聚合检查名额并预占一个资格。这会生成报名资格令牌或预占记录。活动聚合保存成功后发布“活动名额已占”的领域事件这个事件会驱动参与聚合创建 Participation 聚合根。第二步用户支付保证金。支付回调进来后参与聚合更新押金状态并确认报名成功。这里参与聚合与活动名额的关系已经通过第一步的资金或令牌达成了最终一致性活动侧的预占记录不会失效用户侧的参与记录等待支付。第三步用户到店核销。核销时活动聚合和参与聚合都可能需要变化。我选择让核销命令落在参与聚合上因为“用户去哪家店、核销码对不对”是参与记录本身的状态。核销完成后参与聚合发出“已核销”事件活动聚合更新完成人数。第四步用户提交评价。评价聚合负责创建评价并进入审核状态同时发出“评价待审核”事件。参与聚合监听到该事件后将自身状态改为“已评价待审核”。第五步审核通过并退款。审核员在评价上下文里通过评价聚合之后发出“评价审核通过”事件。参与上下文接收到事件后把参与状态推进为“已审核”同时调用账户结算上下文生成退款单。押金退款完成后账户上下文发出“退款完成”事件。整个流程始终保持“一个聚合一个事务”的原则跨聚合的状态同步统一走事件。虽然异步会让最终一致性的窗口存在但在霸王餐业务中这个窗口是可接受的。4. 聚合根与边界落地的关键点4.1 聚合根状态机设计聚合根设计最终会落到状态机。状态机不是画着玩它是聚合根一致性的核心表现。如果状态可以随便跳聚合根就失去了存在意义。活动聚合的状态这个顺序比较稳定草稿 - 待审核 - 已发布 - 已结束另有已驳回、已下架分支。从已下架可以回到已发布从已发布可以进入进行中或结束但不能从草稿直接跳到已结束。参与聚合的状态稍微复杂已报名 - 已支付 - 已核销 - 待评价 - 审核通过 - 已退款旁路有已取消、已关闭。这里特别容易踩坑的是“已核销”和“已评价”的关系我在设计时要求只有已核销的参与记录才能创建评价避免用户还没到店就开始编评论。评价驳回后参与状态会进入“评价驳回待修改”允许用户重新提交但重新提交不会影响核销状态。退款只能在评价审核通过后触发同一笔参与记录的退款只允许执行一次靠状态位保证。4.2 跨聚合事务与最终一致性在具体实现时最受质疑的就是跨聚合事务。说得难听一点如果整个项目都要同一个数据库事务很多人会把跨聚合做成分布式事务。我的实践结论是在霸王餐这种业务里大部分跨聚合协同都可以用发件箱事件推送实现不必引入重量级事务框架。活动的名额预占和参与记录的创建是两个聚合之间的核心一致性场景。我在设计时选择一个偏实际的做法活动聚合先在自己内部完成名额预占预占记录放在报名名单列表里状态是“已预占未支付”。预占记录保留一个过期时间比如30分钟。参与聚合支付成功后发出“支付成功”事件活动聚合收到事件后把预占改成“已确认”并扣减最终剩余名额。如果30分钟内没有支付活动聚合的定时任务主动把预占释放回名额池。简单来说把“先锁名额”归属活动聚合的事务域把“支付置位”归属参与聚合的事务域两边的最终一致性通过在活动聚合里保留一个局部状态的双阶段实现。这种做法比单库大事务更轻也比引入TCC更简单同时能有效防超卖。实际经验是根据并发量还可以进一步用乐观锁保护名额扣减每次扣减时检查剩余名额。4.3 仓储与域服务怎么摆仓储是聚合根的存储入口。每个聚合根一个仓储比如 ActivityRepository、ParticipationRepository、ReviewRepository、UserRepository、MerchantRepository。仓储不是给每个实体建一个也不是给每张表建一个而是只对聚合根开放。查询复杂读模型时我通常单独建读仓储或使用视图投影不强行复用写模型的仓储。域服务用在那些不属于某个聚合、但业务规则又很重要的地方。比如“报名领域服务”会负责协调活动名额预占和参与记录创建“评价审核领域服务”负责调用反作弊系统、根据商家信用分和用户历史评价决定审核策略。这些服务只负责决策和编排具体的状态变更仍然通过聚合根完成。这样落位下来代码结构很清晰领域对象里写业务规则仓储里做持久化应用服务做流程编排域服务做横切决策。5. 常见问题与避坑心得5.1 “聚合根当成数据表”建模就废了一半这是DDD新人最容易踩的坑。有人把数据库里每张表都对应一个聚合根结果一个用户表一个用户聚合一个地址表一个地址聚合拆出来十个聚合根业务却一点没变简单。聚合根不是表它是一个业务概念边界不是物理存储单元。反过来一个聚合内也不能因为“数据库用起来方便”就包含一堆毫不相关的对象。我以前见过有人把“评价”表和“退款记录”表硬塞进同一个聚合里因为它们之间有一个外键。这样的聚合在业务上毫无意义只会让每次修改都变成一场灾难。判断一个对象是否属于某个聚合应该看它是否具有内聚的业务规则。不能因为“字段在一个页面展示”就认为它们属于同一个聚合那是页面设计逻辑不是领域设计逻辑。5.2 一个请求改三四个聚合问题出在哪如果你在自己的代码里偶然发现一次报名操作同时要更新活动聚合、参与聚合、用户聚合和积分聚合那基本说明边界分得有问题。要么增量的原子不变量被拆分到了不同聚合导致被迫写一堆补偿逻辑要么应用层过度编排把不该绑在一起的动作硬性串到一个请求里。比较好的修法是先回看设计问一个问题“这次请求要保证什么不变量”如果答案是“报名必须让参与名额减少”那参与资格和名额就必须收进同一个聚合或由同一个事务保护如果答案只是“报名完成可以后续再去发积分”那应该让报名聚合发出事件积分模块异步监听处理。把不需要原子一致的东西从请求执行路径里拆掉代码会清爽非常多。5.3 定边界时拿不准怎么办建模时经常出现“这个对象好像两边都能算”的情况比如评价审核记录说它是评价上下文的一部分也可以说是账户结算上下文的前置条件也可以。我面对犹豫时有一个固定做法列出该对象每个状态变化时外界需要同步满足什么条件。评价审核记录变化时外界需要同步的是“参与状态是否需要立即修改”“退款是否必须马上触发”。如果这些同步在毫秒级内必须完成对象离参与聚合近如果允许秒级甚至分钟级的延迟对象可以是消息驱动触发。用这个标准去衡量很多边界都能得出较为稳定的结论。注意不要陷入完美建模的误区。边界本身是可以迭代的。第一版按活动、参与、评价三个核心聚合去做后面发现某个规则确实无法落地再调整仍然来得及。DDD项目里没有永远不变的边界只有当前业务阶段下最合适的边界。5.4 给正在学DDD的你的心得网上关于DDD的争议很多有人说它是过度设计有人说书读三遍也搞不懂。我的体会是DDD不必为了用而用但如果你面对的是业务规则复杂、状态多、协作方多的系统用DDD把业务边界想清楚它带来的收益远超多写的那些接口和事件代码。入门阶段不要直接去啃那些大量抽象概念的书而是从“事件风暴”开始把业务事件、命令、聚合画出来。画的时候你会自然产生大量疑问比如“这个状态到底该谁改”“这两个对象为什么必须一起变”这些疑问恰恰就是DDD要解决的问题。等到你带着这些问题去读概念会发现聚合根、有界上下文都不是玄学而是对现实业务约束的建模表达。最后说一个操作层的小技巧编码落地时优先写好聚合根的状态机。用枚举限制状态流转把非法跳转直接挡在聚合内再通过事件驱动的框架实现跨聚合协作。状态机和事件都设计清楚后再复杂的霸王餐业务也能保持结构稳定后续扩展新的活动形式、规则或者风控策略都不会把原有边界打乱。这是我这次重构下来最关键的一条经验。