领域驱动设计:从方法论到电商计价实践
迁移说明:本文整合原有的“电商计价系统 DDD 实践”和“领域驱动设计读书笔记”两篇文章,把通用方法论与计价案例收敛为一个持续维护的入口。旧地址仅承担兼容迁移,不在正文中暴露归档路径。
领域驱动设计不是一套必须完整照搬的技术清单,而是一种把业务知识变成可讨论、可验证、可演进模型的协作方式。本文先解释为什么需要 DDD,再建立战略与战术设计的共同语言,随后用电商计价域贯穿需求分析、模型、代码、集成与测试,最后给出渐进迁移和误区检查清单。
目录
- 一、为什么需要领域驱动设计
- 二、领域驱动设计的核心概念
- 三、战略设计:划分业务边界
- 四、战术设计:构建领域模型
- 五、电商计价域的 DDD 实战
- 六、从模型到系统架构
- 七、实施误区与演进清单
- 八、总结与参考资料
一、为什么需要领域驱动设计
DDD 处理的不是代码行数,而是业务知识在多人、多系统和长期变化中不断丢失的问题。Eric Evans 的 Domain-Driven Design 把模型、语言与实现放在同一条反馈回路里;Fowler 对 Domain-Driven Design 的概述 也强调复杂领域与模型之间的联系。所谓“驱动”,指需求讨论、代码命名、边界划分和测试断言都接受领域模型约束,而不是建模结束后再把图放进文档。
1.1 从变化成本识别真正的问题
一个系统值得采用 DDD,通常不是因为它有很多表,而是因为规则会组合、术语会分叉、决策需要解释。电商计价正是这种场景:商品价、会员价、活动价、券、运费与税费来自不同责任方;展示、下单、支付、退款又处在不同时间点。若团队只维护一个“最终金额”字段,任何新政策都会穿透接口、数据库和对账链路。
复杂性可以从四类证据观察。第一,产品、客服、财务对同一个词给出不同定义;第二,一条规则修改需要多个服务同步上线;第三,事故复盘只能描述技术调用,无法说明业务决定;第四,测试依赖大环境,却不能穷举规则边界。DDD 的第一步不是创建 Entity 基类,而是把这些证据变成可讨论的语言、所有权和不变量。
1.2 模型是一组可执行的选择
模型必然省略现实。计价模型关心金额、规则、资格、时间和证据,不负责描述商品图片如何展示;商品模型关心 SPU、SKU 与可售属性,不负责决定优惠叠加。好模型不是“字段最全”,而是为当前决策保留恰当事实,并明确哪些事实由别的上下文解释。
统一语言的价值也在这里:它让省略与选择可见。团队约定“报价”是有有效期、有版本、可解释但尚未被订单接受的裁决,“价格快照”是订单已经接受的成交证据。于是“重新算一下价格”不再是无害实现细节,而是可能改变承诺的业务操作。
1.3 战略与战术必须形成闭环
战略设计回答哪里使用哪套模型、团队如何协作;战术设计回答边界内部如何表达身份、值、不变量和事实。只做战略设计会留下漂亮边界图,却没有代码约束;只做战术设计会出现大量实体和值对象,却继续共享数据库和模糊语言。本文先建立通用原则,再把每个原则映射到计价请求、规则、报价和订单衔接。
DDD 不强制采用特定分布式架构。Richardson 的 CQRS pattern 适用于读写模型确有不同需求的场景;Fowler 对 Event Sourcing 的说明 讨论用事件重建状态的代价与能力;Microsoft 的 Microservices architecture 指南 则描述独立部署边界。CQRS、事件溯源和微服务都不是 DDD 的必选项:模块化单体、普通状态存储和同一读写模型完全可以承载成熟的领域模型。
1.4 什么时候不应重投入
规则稳定的 CRUD 后台、一次性数据搬运、以查询聚合为主的报表,通常不需要完整 DDD。此时清晰的数据结构、事务脚本和自动化测试更经济。即使业务复杂,也不应全域平均投入:把最能形成差异、规则最密集的部分视为核心域,把通用能力购买或复用,把支撑能力保持简单。
选择 DDD 后也要定义收益证据,例如规则交付周期、价格事故恢复时间、跨团队接口变更次数、无法解释报价的比例。指标不是为了证明某种方法先进,而是帮助团队判断模型是否真的降低了变化成本。
模型是否有效还可以通过新人接手检验:新人应能从术语、示例和测试解释一次报价,而不是先追踪几十个调用。若关键规则只能由少数人凭记忆维护,系统即使运行稳定,也承担着高知识集中风险。把隐性判断写成实例、类型与失败结果,既降低交接成本,也让业务专家能够直接指出模型误解。
这种检验不追求“无需沟通”。相反,它让沟通围绕明确分歧展开:术语是否同义、边界是否正确、承诺是否足够强。无法从代码看出的运营策略可以保留在规则数据中,但必须受模式和示例约束;无法从配置表达的核心不变量则应留在模型代码中。二者的边界随变化证据调整。
持续记录这些调整,团队才能判断复杂性是在下降还是转移。
还可以用“决策密度”判断投入位置:同样一千行代码,若其中包含大量资格判断、时间边界、互斥关系和例外政策,就比单纯的数据搬运更需要模型。计价的决策密度通常集中在规则组合和成交承诺,运营配置页面的决策密度却很低。团队不应因为两个模块位于同一代码库,就对它们采用相同的设计重量。
另一个判断维度是知识寿命。短期活动页面可能几周后下线,但“订单接受什么价格”“退款依据什么证据”会存在多年。长寿命知识值得进入稳定类型、聚合和契约;短寿命变化应尽量成为受校验的数据。把二者混在大量条件分支里,会让临时政策永久侵蚀核心模型。
变化信号:同一金额有三种口径
运营说订单金额指优惠前,客服说优惠后,财务说含税应收。评审的焦点应落在先识别三个语境是否属于不同上下文,再为各自口径命名,而不是先讨论框架。
需要持续成立的约束是每个金额的构成、币种、时间点和所有者。验收时检查术语表中的正反例、接口字段定义、三方共同认可的订单样例,并用失败注入证明边界没有依赖隐式调用顺序。
如果出现 totalAmount 在各系统被不同公式写入,短期看似减少了接口或代码,长期却把错误定位、历史回放和策略迁移绑在一起。更稳妥的权衡是接受显式契约与版本成本,换取责任可追踪。
协作信号:一次促销要五个团队同时上线
当新增券类型需要商品、营销、计价、订单和支付一起改枚举时,表面故障背后的建模选择是找出变化真正属于哪个模型,并通过稳定契约传播结果。
模型以“上游新增规则不会迫使无关下游理解内部类型”作为不变量。版本化候选优惠契约、消费方测试、独立发布记录构成发布证据,缺少其中任何一项都不能只靠人工确认。
共享 SDK 每次加常量都要求全链路升级是该方案的否决信号。团队宁可承担少量转换和存储开销,也不应把所有权重新藏回共享状态;上线后由领域指标验证这个取舍。
解释信号:客服无法说明成交价
总额正确但没人能还原哪些规则命中和如何分摊,这会迫使团队明确把价格解释视为计价结果的一部分而非日志补丁。
边界内必须保证总额由可追踪明细唯一推导,明细关联规则与输入版本。评审材料应直接展示 PriceBreakdown、回放工具、事故样例的确定性结果,让产品、测试与值班人员看到同一结论。
一旦看到只能从多服务日志猜测计算顺序,应暂停扩展功能并收紧边界。这里牺牲的是局部开发便利,得到的是并发行为、审计证据和故障恢复的确定性。
测试信号:只有联调环境能验证规则
以“单测必须启动数据库、缓存和多个远程服务”为反例,系统必须先决定把纯业务决策与数据取得、事务和协议翻译分离。
实现可替换,给定固定领域输入即可重复得到同一裁决却不能被破坏。对应证据是快速表格测试、端口替身、少量真实契约测试,它们同时服务回归测试与事故回放。
每个边界样例都依赖不稳定的共享环境意味着抽象没有降低变化成本。修正方案通常会增加一个版本、适配器或校验步骤,但能避免下游以猜测方式解释结果。
投资信号:复杂度集中而非平均分布
计价组合频繁变化,而规则后台大多是简单维护。若只在流程上打补丁,便会绕开真正的选择:把深入建模集中在核心决策,不把全系统统一成重型模板。
设计接受的底线为核心域获得清晰模型,支撑域保持足够简单。团队通过变更频率、事故成本、差异化价值与维护周期判断方案是否达到底线,而非统计新增了多少领域类。
若实现退化为用代码生成器制造大量没有行为的领域对象,技术指标可能仍然正常,业务结果却不可证明。评审必须把可解释性和回放能力作为与延迟同等重要的约束。
二、领域驱动设计的核心概念
领域模型需要一组分工清楚的构件,而不是一套类名清单。Fowler 对 Bounded Context 的说明 指出模型只在明确边界内成立;Fowler 对 Domain Model 的说明 则解释了数据与行为共同表达业务规则的意义。下面每个概念都按定义、计价映射、边界和失败信号展开。
2.1 统一语言先于类型设计
统一语言由产品、运营、客服、财务、测试和研发共同维护。它不只是术语表,还要进入验收条件、接口、代码、日志与监控。词语发生歧义时不要急着选一个“标准定义”,应先问歧义是否来自不同限界上下文。例如营销的“优惠金额”是预算消耗,订单的“优惠金额”是成交分摊,财务的“优惠金额”还要区分承担方;强行合成一个字段会损失差异。
计价团队可以为每个关键词记录定义、反例、所有者、单位、时间语义和示例。术语变更应像接口变更一样评审:如果“销售价”开始包含会员权益,受影响的不只是名称,还有门槛基数、报表和退款。语言与模型共同演进,避免文档说新话、代码保留旧含义。
2.2 实体:用身份承载连续性
实体由身份而不是全部属性定义。属性会变化,但同一业务对象在生命周期中保持可追踪。身份应来自领域语义,并在创建、恢复和跨边界引用时保持稳定。
在计价域中,PriceQuote 可以作为实体:同一个 quoteId 的报价会从草稿变为已计算、被接受或过期,状态变化不能把它当成另一份报价。这个映射不是为了给旧代码换一组名词,而是为了回答“谁作决定、哪些状态必须一起成立、变化被限制在哪里”。实体不等于数据库行;持久化主键可以服务技术实现,但领域身份要能解释业务连续性。
评审时可以用一个反向问题检验模型:去掉存储主键后,业务是否仍需要区分这两个对象。如果答案依赖调用顺序、共享表或某位同事的经验,模型仍然没有把知识说清楚。常见失败信号是:所有对象都继承统一 BaseEntity,或用可变属性参与相等比较。
2.3 值对象:把约束放进类型
值对象由完整取值决定相等性,没有独立生命周期,适合设计为不可变。它在构造时校验合法性,让非法状态难以进入模型。
在计价域中,Money、Currency、RulePeriod、Quantity 和 Percentage 都应在边界处拒绝非法值,并提供具有业务语义的运算。这个映射不是为了给旧代码换一组名词,而是为了回答“谁作决定、哪些状态必须一起成立、变化被限制在哪里”。ISO 4217 currency codes 提供币种代码语义,但精度、舍入和现金最小单位仍需业务政策明确。
评审时可以用一个反向问题检验模型:两个实例取值相同后,业务是否关心它们分别是谁。如果答案依赖调用顺序、共享表或某位同事的经验,模型仍然没有把知识说清楚。常见失败信号是:用 float64 表示钱、到处传裸字符串币种,或允许构造后任意修改内部字段。
2.4 聚合:定义一致性与修改入口
聚合是一组需要共同守护不变量的对象,聚合根是外部修改它们的唯一入口。Vernon 的聚合讨论与实现资料 强调小聚合和按业务规则划边界。
在计价域中,报价聚合维护输入版本、调整明细、应付总额与状态,保证分项之和等于总额、币种一致且已接受报价不可被重写。这个映射不是为了给旧代码换一组名词,而是为了回答“谁作决定、哪些状态必须一起成立、变化被限制在哪里”。聚合外只保存根的标识,跨聚合变化通过应用编排或事件完成,不能为了“方便一次保存”把商品、用户和订单装进报价。
评审时可以用一个反向问题检验模型:哪些规则在一次事务提交后必须立即成立。如果答案依赖调用顺序、共享表或某位同事的经验,模型仍然没有把知识说清楚。常见失败信号是:聚合巨大、加载缓慢、不同请求频繁争用同一版本,或应用服务绕过根直接改内部集合。
2.5 领域服务:安放无自然归属的决策
当一项核心计算需要多个对象协作、又不自然属于任何一个实体或值对象时,可用无状态领域服务表达。服务名应是业务动作,输入输出仍是领域类型。
在计价域中,PromotionComposer 根据候选规则、互斥组和预算约束选择组合,但报价状态流转仍由 PriceQuote 自己守护。这个映射不是为了给旧代码换一组名词,而是为了回答“谁作决定、哪些状态必须一起成立、变化被限制在哪里”。领域服务不能成为所有逻辑的收容所;远程调用、事务和 DTO 转换属于应用或基础设施职责。
评审时可以用一个反向问题检验模型:把这段逻辑放进某个实体,会不会让那个实体承担与自身生命周期无关的知识。如果答案依赖调用顺序、共享表或某位同事的经验,模型仍然没有把知识说清楚。常见失败信号是:出现通用 DomainService,方法只做 CRUD,或每个实体都退化成 getter 与 setter。
2.6 仓储:用集合语义隔离持久化
仓储让应用以领域语言取得和保存聚合。Fowler 的企业应用架构模式目录 给出 Repository 与 Data Mapper 等模式的关系。
在计价域中,QuoteRepository 按 quoteId 取得报价并以乐观版本保存,调用方无需知道使用 SQL、文档库还是缓存。这个映射不是为了给旧代码换一组名词,而是为了回答“谁作决定、哪些状态必须一起成立、变化被限制在哪里”。接口放在需要它的内层,查询条件围绕业务用例;跨聚合报表不必强迫仓储返回完整领域对象。
评审时可以用一个反向问题检验模型:替换存储实现后,领域和应用代码是否仍保持原意。如果答案依赖调用顺序、共享表或某位同事的经验,模型仍然没有把知识说清楚。常见失败信号是:接口泄漏 ORM 查询构造器,或一个通用仓储为所有实体提供无限制的 Save 与 Find。
2.7 领域事件:命名已经发生的事实
领域事件记录模型中已经发生且其他参与者关心的事实。Fowler 对事件驱动架构的分类 提醒团队区分通知、状态转移和事件携带状态。
在计价域中,QuoteAccepted 表示订单已经接受某个报价,它可以触发订单快照、预算记账或分析投影,但事件本身不命令消费者必须怎样实现。这个映射不是为了给旧代码换一组名词,而是为了回答“谁作决定、哪些状态必须一起成立、变化被限制在哪里”。领域事件先服务边界内的表达;跨上下文发布前可转换为稳定、最小且版本化的集成事件。
评审时可以用一个反向问题检验模型:事件名能否用过去式陈述一个业务事实,并指出事实发生的聚合与时间。如果答案依赖调用顺序、共享表或某位同事的经验,模型仍然没有把知识说清楚。常见失败信号是:把 SendMessage 当事件、广播完整聚合,或以事件为借口隐藏同步强一致要求。
2.8 概念之间如何协作
应用服务接收用例请求,把裸输入转换为值对象,经仓储取得聚合,再调用聚合或领域服务完成决策;聚合成功后记录领域事件并由仓储保存。这个顺序不是框架模板,而是一条责任链:协议校验不污染领域,决策不泄漏持久化,事务不由实体自行开启,事件也不在模型尚未保存时贸然发布。
模型评审应从具体实例出发。给出一个正常报价、一个币种不一致、一个报价过期和一个并发接受,逐步指出哪个对象拒绝、哪条不变量生效、应用如何返回结果。若图上概念很多,却无法回答失败由谁承担,就需要继续收缩边界。
工厂也要谨慎使用。创建报价需要生成身份、固定评估时间并建立初始状态时,可以由 NewPriceQuote 集中执行;从存储恢复则使用不触发新业务事件的恢复路径。创建与恢复若共用一个“万能构造器”,很容易在读取历史数据时重复发出事件,或绕过新建对象必须满足的条件。
规格对象适合表达可复用且能被业务命名的资格,例如“新用户”“华东可配送”“会员等级至少为金卡”。但不应把所有布尔表达式都包装成 Specification。判断标准仍是语言:业务会不会单独讨论、组合和测试这条政策。如果它只是某个方法内部的一次性保护条件,普通私有函数更直接。
领域异常也不是越多越好。稳定的拒绝类别应成为可匹配错误,如 CurrencyMismatch、QuoteExpired 和 RuleNotEligible;诊断细节作为字段附加。若每个规则都创造无法治理的新错误类型,协议层会被迫了解内部实现。相反,只有一个 BusinessError 又会让调用方无法选择重试、刷新或提示用户。
身份与取值的判断
两份内容相同的报价分别发给两个购物车。评审的焦点应落在报价按身份区分,金额按取值比较,而不是先讨论框架。
需要持续成立的约束是 quoteId 表示生命周期,Money 相等不依赖存储地址。验收时检查实体相等性测试、值对象相等性测试和恢复样例,并用失败注入证明边界没有依赖隐式调用顺序。
如果出现所有对象都用数据库主键或所有对象都按字段比较,短期看似减少了接口或代码,长期却把错误定位、历史回放和策略迁移绑在一起。更稳妥的权衡是接受显式契约与版本成本,换取责任可追踪。
聚合边界的判断
当接受报价时需要确认版本和有效期,但不需要修改商品时,表面故障背后的建模选择是只把同事务不变量放进报价聚合。
模型以“Accepted 状态、版本与成交明细一次保存后共同成立”作为不变量。并发 Accept 测试、乐观锁冲突和过期拒绝构成发布证据,缺少其中任何一项都不能只靠人工确认。
为了少写接口把商品、用户和活动全部加载进聚合是该方案的否决信号。团队宁可承担少量转换和存储开销,也不应把所有权重新藏回共享状态;上线后由领域指标验证这个取舍。
领域服务的判断
多个优惠候选需要互斥、择优与封顶,这会迫使团队明确把无自然实体归属的组合政策放进无状态服务。
边界内必须保证相同候选和基数产生确定的 RuleDecision。评审材料应直接展示组合表格、平局规则、预算封顶和顺序无关性质测试,让产品、测试与值班人员看到同一结论。
一旦看到服务一边计算一边查询数据库并发送消息,应暂停扩展功能并收紧边界。这里牺牲的是局部开发便利,得到的是并发行为、审计证据和故障恢复的确定性。
仓储与查询的分工
以“客服按手机号检索近三月报价,订单按 quoteId 接受一个报价”为反例,系统必须先决定命令路径用聚合仓储,检索路径用专用读模型。
实现可替换,写模型不因报表字段而膨胀,查询不绕过写入规则却不能被破坏。对应证据是仓储接口、查询 DTO、权限和数据新鲜度说明,它们同时服务回归测试与事故回放。
一个通用 Repository 暴露任意过滤与任意更新意味着抽象没有降低变化成本。修正方案通常会增加一个版本、适配器或校验步骤,但能避免下游以猜测方式解释结果。
三、战略设计:划分业务边界
战略设计的产物不是服务列表,而是业务能力、语言边界、数据所有权和协作关系。Microsoft 的 DDD 领域分析指南 提供从业务能力识别模型边界的工程视角。对计价来说,目标是避免“价格”成为所有上下文都能改写的共享概念。
3.1 从事件时间线发现决策边界
工作坊先让业务参与者按时间写出事实,再补充触发事实的命令、作出决定的角色、外部政策与热点问题。Fowler 的 Event Storming 条目 与 Brandolini 的 EventStorming 官方站点 都把领域事件作为探索业务流程的入口。计价链路可从“商品已选定、候选优惠已取得、报价已计算、报价已接受、订单已创建、退款已完成”开始,而不是先画已有服务。
事件之间的规则密集处往往是核心决策边界,语言突然变化处往往是上下文边界。例如“规则命中”属于营销与计价协作,“报价接受”把可变建议转成订单承诺,“支付扣款”则进入支付上下文。边界不是由一次会议永久确定;事故、组织调整和新业务都会提供修正证据。
3.2 五个上下文的计价地图
商品上下文拥有商品身份、规格与可售属性,并向计价提供版本化商品事实;它不决定会员折扣。价格上下文拥有基础价、渠道价和价格表版本;它不解释营销资格。营销上下文拥有活动、券、预算与资格,输出候选调整及约束。用户上下文拥有会员等级、分群与权益事实,但不计算订单成交价。订单上下文接受报价、保存成交快照并管理履约与售后承诺。
计价上下文位于这些事实的交汇处,拥有组合、舍入、分摊和解释。它读取其他上下文提供的事实,却不通过复制表获得所有权。每项输入应写清提供方、版本、新鲜度、缺失策略与错误责任;每项输出应写清承诺强度、有效期和可否重放。
3.3 上下文关系不是一条 RPC 线
当计价需要商品数据时,可由商品上下文提供开放主机服务,计价侧用反腐层翻译成本地 PricingProduct。这样商品枚举、字段和发布节奏不会直接进入核心模型。营销与计价若共同演进组合契约,可以采用客户—供应商关系,由下游计价提出兼容需求,上游营销公布版本和弃用窗口。
共享内核只适合很小且真正共同拥有的语义,例如经过双方治理的 CurrencyCode;它要求联合发布与严格变更纪律,不能成为共享所有 DTO 的借口。遵奉者适用于上游模型稳定、下游差异很小且没有谈判能力的场景,但下游仍要隔离协议错误。不同关系的选择应写入上下文地图,而不是藏在调用代码中。
跨上下文传输的是契约,不是对象引用。同步请求适合用户等待且必须立即确认的事实;异步订阅适合允许短暂陈旧或需本地高吞吐决策的事实;批量同步适合规则发布和历史校正。选择依据是业务新鲜度与失败政策,而不是团队统一偏好某种中间件。
3.4 子域优先级与团队责任
核心域需要最强建模投入和稳定团队。计价组合、价格解释和资损控制直接影响竞争力,可以作为核心域;币种代码校验、通用消息投递更接近通用能力;规则管理后台中的简单 CRUD 可能只是支撑域。同一限界上下文内部也可采用不同实现深度,不必所有模块都套用聚合和事件。
组织责任要与模型责任一致。若一个团队拥有价格计算却无权控制价格表、促销契约和事故指标,它无法真正对结果负责。上下文地图因此还应标注团队、值班责任、变更审批和争议升级路径。边界清晰不是减少协作,而是让协作成本可见。
3.5 边界评审的可验证问题
评审时逐项回答:同一术语在边界内是否唯一;数据由谁写入;不变量在哪里执行;失败由谁降级;历史结果如何回放;接口变更谁批准;团队能否独立测试和发布。若两个模块总要同事务修改、同版本发布且共享大量规则,它们可能尚未形成真实边界。若一个模块只有转发 DTO,也可能只是技术分层而非领域上下文。
战略设计最终要回到实例。用正常下单、活动刚过期、商品改价、营销超时、部分退款五条时间线穿过上下文地图,检查每次翻译和承诺。能够解释这些路径,比画出更多方框更有价值。
边界还需要数据血缘。基础价格从哪个价格表版本产生,营销资格依据哪一版用户分群,税额由哪个政策版本计算,都应随报价进入证据链。血缘不等于复制全部上游数据;它保存可定位的版本、摘要和必要快照,使上游数据已变化时仍能解释历史结果。若只能连接生产数据库查看“现在的值”,审计得到的并不是当时的事实。
跨上下文错误也要翻译。商品服务返回 SKU_DISABLED,计价可能解释为“商品当前不可报价”;营销服务返回预算并发冲突,计价可能重新取得候选或返回资格变化。直接把上游错误码穿透给前端,会让调用者依赖内部协议,也无法形成稳定业务语义。反腐层因此既翻译成功数据,也翻译失败和可重试性。
团队拓扑发生变化时,上下文不一定立即变化。把两个团队合并,不代表两个模型应合并;把一个团队拆成两组,也不代表必须拆服务。应先观察语言、发布节奏和数据所有权是否真的分离。组织结构是边界的重要约束,但不是替代领域分析的自动规则。
3.6 用退款链路反向检验边界
正向下单容易掩盖所有权,因为多数系统只需提供数据;退款会迫使每个上下文解释自己的承诺。订单决定哪些成交行进入售后,计价提供原始分摊证据,营销决定权益是否返还,支付执行资金退回,财务记录承担方调整。任何一个上下文试图独自“完成退款”,都会复制其他上下文的政策。
部分退款尤其能暴露边界漏洞。用户先退一件、随后再退一件时,累计可退额必须受到订单证据约束;满减是否追回属于售后政策;券是否返还属于营销政策;渠道手续费是否退回属于支付与财务政策。计价不能用当前规则重新生成历史成交价,但应提供稳定明细,让各方基于同一证据作出不同决定。
评审证据应是一条完整时间线:原报价、订单接受、第一次退款、权益返还失败、补偿成功、第二次退款。团队逐步标注命令、事实、责任方和幂等键。如果某一步需要直接改另一个上下文的数据,或只能通过人工查询共享表继续处理,边界尚未建立。退款链路比静态上下文图更能证明协作模型是否真实。
还要评估“业务上已完成、技术上仍补偿”的中间状态。资金已退但券返还稍后重试,不应把整个退款重新标记为失败;订单可以记录核心退款完成,同时通过进程管理器追踪附属补偿。状态名称必须面向客服可解释,监控则区分用户承诺与内部收尾,避免一次非关键重试让用户看到相互矛盾的结果。
商品改类目影响优惠
展示类目调整后品类券突然失效。评审的焦点应落在区分导航分类与计价分类,并通过反腐层翻译,而不是先讨论框架。
需要持续成立的约束是计价规则读取版本化计价分类,不依赖前台目录结构。验收时检查分类契约、版本、迁移映射和历史报价回放,并用失败注入证明边界没有依赖隐式调用顺序。
如果出现计价直接连接商品分类表,短期看似减少了接口或代码,长期却把错误定位、历史回放和策略迁移绑在一起。更稳妥的权衡是接受显式契约与版本成本,换取责任可追踪。
会员等级在结算中变化
当报价时是金卡,下单时等级被后台修正时,表面故障背后的建模选择是定义用户事实的新鲜度和报价承诺强度。
模型以“报价记录会员事实版本,接受时按政策校验或锁定”作为不变量。等级版本、有效时间、重价提示和客服解释构成发布证据,缺少其中任何一项都不能只靠人工确认。
每一步都读取最新等级却没有差异处理是该方案的否决信号。团队宁可承担少量转换和存储开销,也不应把所有权重新藏回共享状态;上线后由领域指标验证这个取舍。
营销预算不足
候选优惠有效但预算在并发请求中耗尽,这会迫使团队明确区分资格判断、预算预占和最终核销的所有者。
边界内必须保证计价不直接修改营销预算,凭证有期限且幂等。评审材料应直接展示预占标识、过期释放、确认事件和补偿记录,让产品、测试与值班人员看到同一结论。
一旦看到 Calculate 方法跨库扣预算,应暂停扩展功能并收紧边界。这里牺牲的是局部开发便利,得到的是并发行为、审计证据和故障恢复的确定性。
订单需要保存什么
以“活动下线后发生部分退款”为反例,系统必须先决定订单保存成交证据,营销继续拥有规则定义。
实现可替换,退款不依赖当前规则仍在线,也不篡改历史报价却不能被破坏。对应证据是快照模式版本、分摊、规则引用和退款政策,它们同时服务回归测试与事故回放。
退款时重新调用当前计价引擎意味着抽象没有降低变化成本。修正方案通常会增加一个版本、适配器或校验步骤,但能避免下游以猜测方式解释结果。
跨团队契约升级
营销要增加新的互斥作用域。若只在流程上打补丁,便会绕开真正的选择:由上下游关系决定兼容、试运行和弃用窗口。
设计接受的底线为旧消费者在窗口内仍能解释已有字段。团队通过契约版本、示例、消费方测试和责任人判断方案是否达到底线,而非统计新增了多少领域类。
若实现退化为共享群消息代替正式变更协议,技术指标可能仍然正常,业务结果却不可证明。评审必须把可解释性和回放能力作为与延迟同等重要的约束。
四、战术设计:构建领域模型
战术设计把战略边界变成代码可以守护的约束。Vernon 的 Implementing Domain-Driven Design 对聚合、领域事件和上下文集成给出系统化实践;可运行示例可参考 Vaughn Vernon 维护的 IDDD Samples。下面不重复定义清单,而是沿一份报价从创建到接受的生命周期组织模型。
4.1 创建时就拒绝非法值
协议层先完成 JSON、HTTP 等格式校验,随后立即构造 Quantity、Currency、Money、RulePeriod 等值对象。值对象构造器负责领域合法性,例如数量必须为正、币种必须受支持、开始时间早于结束时间。成功构造后,内部代码无需在每个分支重复检查同一条件。
Money 不接受浮点元值,因为 float64 乘以 100 既可能出现二进制误差,也会在转整数时静默截断。接口可直接接收最小单位整数;若面向人工输入,则用十进制字符串解析,明确小数位和舍入策略。以下示例拒绝多余小数位与非法输入,不把精度错误变成悄悄少一分:
1 | type Money struct { |
示例用受控的币种元数据同时校验 currency 与 scale,避免调用者给 JPY 任意指定两位小数。生产实现应从经过版本管理的 ISO 4217 元数据生成完整映射,并对元数据升级做兼容评审。需要四舍五入的输入应调用名字明确的 ParseMoneyRounded,并传入 HalfEven、HalfUp 等策略;不要让 ParseMoney 猜测。负数是否允许也由用途决定:商品单价可以拒绝负数,调整额则用独立 Discount 类型表达方向。
4.2 聚合围绕不变量而不是页面组装
PriceQuote 聚合包含请求摘要、输入版本、行项目结果、订单级调整、费用、税、总额、状态和有效期。它不持有完整 Product、Customer 或 Campaign 对象,只保存完成决策与审计所需快照。创建后先处于 Draft,计算成功变为 Calculated,被订单采用变为 Accepted,超过有效期变为 Expired。
聚合方法用业务动作命名:ApplyItemAdjustment 校验作用域和币种,AllocateOrderDiscount 保证分摊总和,Accept 校验版本与有效期。没有 SetTotal,因为总额只能由明细推导。每个公开方法返回后聚合都保持有效,不依赖应用服务最后记得调用 Validate。
1 | func (q *PriceQuote) Accept(now time.Time, cartVersion int64) error { |
4.3 应用服务组织一次用例
应用服务把一次报价拆为明确步骤:解析请求、加载版本化事实、构造领域输入、调用规则组合、保存报价、提交事务并映射响应。它处理超时、事务和幂等,却不编写折扣公式。领域层通过端口声明需要的 PriceCatalog、PromotionCandidates 和 CustomerFacts,基础设施适配器完成远程调用与本地翻译。
仓储一次加载和保存一个聚合,并使用版本检查处理并发。查询列表、运营报表和客服检索可以走专用读模型,不必为保持“纯领域”而反序列化大量聚合。命令与查询在代码职责上分开,不等于必须部署完整 CQRS 基础设施。
4.4 领域服务只承载组合决策
促销组合不自然属于某一个候选规则,可以由 PromotionComposer 处理。它先按作用域分组,再在互斥组内择优,然后按显式顺序应用可叠加规则,最后执行封顶和预算约束。输入是不可变候选与基数,输出是 RuleDecision 列表;服务本身不访问数据库或发送消息。
当规则数量增长时,不要把每个条件都做成一个类。稳定差异可用策略,频繁配置可用经过校验的规则数据,真正需要编程能力的少数规则再实现扩展点。核心标准是业务语义能否被读取、测试和解释,而不是模式数量。
4.5 从领域事件到可靠集成
聚合记录事件,应用层在业务数据保存成功后安排发布。数据库事务无法与消息代理天然原子提交时,可采用 Transactional Outbox 模式:业务记录与 outbox 同事务写入,后台发布器至少一次投递,消费者按 eventId 幂等。Outbox 解决丢消息窗口,不提供全局顺序或恰好一次语义。
领域事件转换为集成事件时应删除内部字段、补充模式版本和关联标识。CloudEvents 1.0 可统一事件信封,AsyncAPI 3.0 文档 可描述通道、消息和操作。契约兼容规则与消费方测试比“所有事件都放同一个 JSON”更能保护演进。
4.6 测试按责任分层
值对象测试非法输入、相等性和运算;聚合测试状态转换与不变量;领域服务用表格驱动样例覆盖组合、互斥和封顶;应用服务用端口替身验证超时与保存顺序;适配器做协议契约测试;端到端只保留关键成交路径。测试应断言业务结果和事件,而不是实现内部调用次数。
性质测试适合金额分摊:任意合法输入下,分摊之和等于原调整额,每行不超出可分摊基数,相同输入结果稳定。事故样例先缩减为最小反例,再进入长期回归集。这样测试既保护当前规则,也帮助团队发现模型边界错误。
恢复路径应与创建路径接受同样严格的审查。仓储把持久化记录还原为聚合时,要验证模式版本、币种和状态组合;遇到旧版本数据,应调用明确的迁移器,而不是用当前默认值补齐。静默补默认值会让历史订单看似可读,实际证据已经被改写。迁移器需要输入旧版本、输出新版本,并保留原始载荷摘要,便于对账。
聚合事件的清理时机也很关键。仓储保存成功前不能清空未提交事件;事务回滚后也不能把它们当作已经发布。一个可行做法是应用层在事务内保存聚合与 Outbox,提交成功后再标记事件已收集。这里不要求领域对象理解数据库事务,但要求应用端口明确成功边界。
对于批量报价,不要为了吞吐把多个购物车塞进一个聚合。批处理应用服务可以并行调用独立报价,并以批次标识追踪整体进度;每个报价仍保留自己的事务、一致性和失败结果。批次允许部分成功时,响应必须逐项表达状态;若业务要求全有或全无,则需要上层用例明确代价,而非让聚合边界随批次大小膨胀。
Money 构造边界
客户端发送 12.345 CNY。评审的焦点应落在拒绝多余精度或调用显式舍入接口,不能静默截断,而不是先讨论框架。
需要持续成立的约束是每个 Money 的最小单位整数和币种始终合法。验收时检查非法输入、溢出、负数和舍入模式测试,并用失败注入证明边界没有依赖隐式调用顺序。
如果出现 float64 乘一百后直接转 int64,短期看似减少了接口或代码,长期却把错误定位、历史回放和策略迁移绑在一起。更稳妥的权衡是接受显式契约与版本成本,换取责任可追踪。
报价状态转换
当过期报价与用户提交同时发生时,表面故障背后的建模选择是由聚合在同一版本上判断状态与时间。
模型以“过期报价不能 Accepted,已接受报价不能重写明细”作为不变量。边界时刻、并发版本、重复 Accept 和恢复测试构成发布证据,缺少其中任何一项都不能只靠人工确认。
控制器直接更新 status 字段是该方案的否决信号。团队宁可承担少量转换和存储开销,也不应把所有权重新藏回共享状态;上线后由领域指标验证这个取舍。
领域事件发布
数据库提交成功后消息代理短暂不可用,这会迫使团队明确在同事务保存 outbox,再异步重试发布。
边界内必须保证每个已提交事实最终可发布,消费者处理重复投递。评审材料应直接展示故障注入、积压指标、eventId 去重和重放测试,让产品、测试与值班人员看到同一结论。
一旦看到先发消息再保存或提交后只尝试发送一次,应暂停扩展功能并收紧边界。这里牺牲的是局部开发便利,得到的是并发行为、审计证据和故障恢复的确定性。
应用服务厚度
以“报价用例需要三个依赖和一次事务”为反例,系统必须先决定应用层协调,领域层裁决,适配器翻译。
实现可替换,业务公式不出现在应用服务,领域对象不启动事务却不能被破坏。对应证据是端口接口、用例测试和领域纯单元测试,它们同时服务回归测试与事故回放。
一个 Service 同时拼 SQL、算折扣和发 Kafka 意味着抽象没有降低变化成本。修正方案通常会增加一个版本、适配器或校验步骤,但能避免下游以猜测方式解释结果。
读模型边界
运营需要按活动统计优惠成本。若只在流程上打补丁,便会绕开真正的选择:从事件或变更流构建分析投影,不扩张报价聚合。
设计接受的底线为投影可重建且标注新鲜度,不能反向修改领域状态。团队通过投影版本、重建命令、水位和对账测试判断方案是否达到底线,而非统计新增了多少领域类。
若实现退化为为报表给聚合增加大量无行为字段,技术指标可能仍然正常,业务结果却不可证明。评审必须把可解释性和回放能力作为与延迟同等重要的约束。
五、电商计价域的 DDD 实战
5.1 用一个可验收的计价请求开始
计价域最危险的起点是“写一个计算公式”。公式只能回答某一时刻怎样加减,不能回答谁有权提供输入、规则为何生效、冲突如何裁决、结果能否重放,以及下单后凭什么证明用户看到的价格就是支付价格。一个可验收的请求至少要包含销售渠道、地区、用户、商品行、数量、请求时间和货币;一个可验收的结果至少要包含逐行价格、订单级调整、费用、税、应付总额、命中规则、失败原因与追踪标识。把这些词写进接口和测试,团队才是在实现同一个业务。
需求讨论应从实例而不是抽象名词开始。假设上海站点的一名会员购买两件商品:商品目录给出日常价,营销上下文给出会员折扣和跨店满减,履约上下文给出运费,税费模块给出税额。团队要逐项确认规则适用范围、门槛基数、叠加顺序、舍入位置、退款分摊和有效时间。每一个仍然用“按以前逻辑处理”回答的问题,都是尚未显式化的领域知识。
计价结果不是若干数字的临时容器,而是一次业务裁决。它应能解释“原价多少、为何减免、在哪一步舍入、谁提供证据、何时失效”。这使客服可以解释、财务可以对账、测试可以构造断言、风控可以回放,也使研发不必依赖某位熟悉历史代码的人口述规则。
5.2 统一语言与输入所有权
计价团队应把“原价、销售价、成交价、应付价”拆开。原价通常来自商品或价格上下文;销售价可能已经包含渠道定价;成交价是在商品级和订单级优惠之后形成;应付价还可能包含运费、税费和抵扣。每个词都要有唯一计算阶段、数据所有者和时间语义。若产品文档说“订单金额”,而代码里同时用它表示优惠前后两个值,错误迟早会出现在门槛判断或退款分摊中。
输入所有权比字段数量更重要。商品上下文拥有商品身份和可售属性,价格上下文拥有基础价格版本,营销上下文拥有资格与优惠定义,用户上下文拥有会员事实,订单上下文拥有购买意图与价格快照。计价上下文读取这些事实并作出裁决,但不应反向成为所有数据的权威副本。跨上下文传递的应是稳定契约或值对象,而不是对方数据库的表结构。
时间必须是显式输入。促销是否有效、会员等级是否已变更、汇率使用哪个时点,都不能藏在任意位置调用系统时钟。把评估时间作为请求的一部分,既能支持历史回放,也能让测试固定边界条件。对于“支付时价格变了怎么办”,模型应明确选择重新计价、锁价或人工兜底,而不是让调用顺序偶然决定结果。
5.3 Money 值对象与精度纪律
Money 至少由金额最小单位与币种组成。金额使用整数可以避免二进制浮点误差,但不能因此忽略币种的小数位、无小数货币、舍入模式和现金支付最小面额。币种标识应使用 ISO 的 ISO 4217 currency codes 维护的代码语义;模型还应拒绝不同币种直接相加,要求汇率换算产生新的、带证据的 Money。
舍入不是显示层细节。按行舍入后求和与先汇总再舍入可能产生差额;百分比优惠、税费和退款分摊都必须声明舍入阶段。一个可靠规则是:内部计算保留业务所需精度,在明确的业务边界舍入,并把无法整除的尾差按确定算法分配到具体订单行。算法必须稳定,例如按金额降序再按行号打破平局,这样重试和回放才会得到相同结果。
负数也需要领域解释。优惠金额可用正数表示“减免”,由 PriceBreakdown 决定如何合计;不要让调用者通过正负号猜语义。应付总额通常不得小于零,但退款、余额调整等其他上下文可能允许负向金额。把不变量放在对应值对象或聚合中,能避免一个通用 Money 类型被迫承载互相冲突的政策。
5.4 规则建模:资格、效果与互斥分离
一条定价规则可以拆成三个问题:它是否适用,适用后产生什么调整,它与其他规则如何组合。资格判断读取商品、用户、渠道、时间和数量等事实;效果计算产生固定减免、比例减免、改写单价或费用调整;组合策略处理互斥、择优、封顶和先后顺序。分开后,新增“仅新用户可用”通常只扩展资格,不必复制优惠算法。
规则优先级不能只靠一个整数。整数能排序,却表达不了“会员折扣可与店铺券叠加,但不可与秒杀价叠加;平台券在店铺券之后计算;运费券只作用于运费”。更稳妥的模型是先按作用域分组,再根据互斥组选择候选,最后由显式流水线应用。每一步都向 PriceBreakdown 写入规则标识、基数、调整额和拒绝原因。
“最优惠”也需要定义。对用户最省钱、对平台成本最低、对商家补贴约束最友好,可能是不同解。若业务要求自动择优,策略应在有限候选集合上计算并记录被淘汰方案;若候选组合可能爆炸,应先利用互斥组、预算和作用域剪枝。任何启发式选择都要成为可测试政策,而不是散落的循环提前退出。
5.5 计算流水线与不变量
可读的计价流水线通常按“规范化输入—加载事实—生成候选—裁决组合—计算费用与税—校验—生成结果”推进。阶段之间传递不可变中间结果,能降低后续规则意外修改前序基数的风险。流水线并不意味着所有业务都必须线性;需要迭代的税费或配送选择,可以封装为一个有明确收敛条件的阶段。
计价聚合守护的是一次报价的一致性,而不是把商品、营销、订单和用户全部装进一个大对象。它可以维护请求、输入快照、调整明细、总额和版本,并保证总额等于各部分之和、同一互斥组至多命中一条、调整不超过封顶、每个订单行都有币种一致的结果。外部事实以快照或引用进入,不在聚合内部远程查询。
不变量要在变更发生时检查。若先暴露 SetDiscount、SetFee 等 setter,最后才由应用服务调用 Validate,任何遗漏都可能保存非法状态。更好的做法是只开放有业务含义的方法,例如 ApplyPromotion、AllocateOrderDiscount、AddShippingFee;方法接收完成决策所需参数,成功后对象始终有效。
5.6 跨上下文协作与反腐层
计价上下文不应理解营销服务的数据库字段,也不应把商品服务返回的巨大 DTO 直接传入领域层。适配器先把外部响应翻译成 PricingProduct、EligiblePromotion 和 CustomerSegment 等本地概念;外部枚举新增或字段缺失时,变化停在反腐层。翻译失败应携带来源、版本和可重试性,便于应用层决定降级还是拒绝报价。
同步查询适合用户正在等待且必须新鲜的事实,异步订阅适合允许短暂陈旧、查询量大或需要本地决策的事实。选择不应只由技术偏好决定:基础价若错误会造成资损,可能需要版本化强校验;会员标签短暂延迟若仅影响小额权益,可以用本地投影并配置补偿。每个依赖都要写出新鲜度目标、超时策略、缺失策略和责任团队。
接口返回成功不等于报价正确。应用层应把依赖版本一起写入 PriceQuote,例如价格表版本、促销规则版本、用户分群版本和评估时间。下单使用报价时可验证是否过期;客服回放时能重建当时输入;监控也能按版本定位异常峰值。
5.7 快照、幂等与订单衔接
展示页报价、提交订单报价和支付金额承担不同承诺。展示价强调低延迟,可以允许受控缓存;提交订单必须形成可追溯快照;支付通常只验证订单快照的有效性和完整性,不应悄悄重跑全部促销。若业务允许支付前重价,必须把差异展示给用户并重新确认。
幂等键应对应业务意图,而不是某次 HTTP 请求。客户端重试创建报价时,可用 cartId、购物意图版本和计价参数摘要形成键;服务端保存键与结果,在同一键输入不一致时返回冲突。这样网络超时后的重试不会重复消耗一次性权益,也不会生成多个互相竞争的价格快照。
订单保存快照时要区分“引用”和“证据”。规则名称等展示信息可以复制,优惠资格的最终权威仍在原上下文;但成交价、分摊结果和输入版本是订单履约与退款需要的证据,应随订单保存。快照结构必须版本化,旧订单读取不能依赖当前规则代码仍然存在。
5.8 领域事件与可靠发布
QuoteCalculated、QuoteAccepted 和 QuoteExpired 是已经发生的业务事实,名称使用过去式,并包含事件标识、聚合标识、发生时间、模式版本和最小必要载荷。不要把“发送短信”命名为领域事件,也不要把完整聚合序列化后广播给所有消费者。前者混淆事实与命令,后者泄漏内部模型并制造隐性耦合。
数据库事务与消息发布之间存在双写窗口。计价或订单聚合在同一事务写入业务数据与 outbox 记录,再由独立发布器重试投递,是 Richardson 对 Transactional Outbox 的模式说明 所覆盖的典型做法。它不保证消费者只收到一次,所以消费者仍要按事件标识去重,并让处理逻辑可安全重放。
跨团队事件需要机器可读契约。AsyncAPI 3.0 文档 可描述异步通道与消息,CloudEvents 1.0 可统一事件信封;二者解决的层次不同,可以组合使用。契约要声明必填字段、兼容规则、示例、所有者与弃用窗口,消费者契约测试则验证生产者变更没有破坏既有订阅者。
5.9 应用服务、仓储与错误协议
应用服务负责用例编排:校验请求、加载外部事实、调用领域模型、保存聚合、提交事务并返回 DTO。它不应重新实现折扣公式,也不应把 transport 层错误码传入领域对象。仓储以领域概念暴露 Get、Save 等接口,基础设施层处理 SQL、缓存和乐观锁;保存失败时,应用层根据错误类型决定重试、冲突响应或告警。
HTTP API 应把领域拒绝、输入错误、版本冲突和依赖不可用区分开。错误体可遵循 IETF RFC 9457 Problem Details,使用稳定的 type 标识问题类别,并通过扩展字段携带 ruleId、quoteId 或可重试提示。不要把内部堆栈、SQL 或供应商响应原样暴露给调用方。
领域错误要面向业务表达,例如 CurrencyMismatch、RuleNotEligible、QuoteExpired、ConcurrentModification。适配器再把它们映射为 HTTP 状态、错误类型和本地化文案。这样同一用例通过 HTTP、消息或批处理调用时仍共享相同领域语义,协议差异停留在最外层。
5.10 测试边界与可观测证据
Money、资格、效果、互斥和分摊算法适合快速单元测试;聚合测试验证不变量与事件;应用服务测试使用端口替身验证编排;适配器测试验证外部契约翻译;少量端到端测试覆盖真实数据库、消息和关键依赖。不要用端到端测试穷举促销组合,也不要通过大量 mock 断言内部调用顺序来替代业务结果断言。
规则测试应采用表格驱动与性质测试。表格覆盖典型实例和边界:门槛前后、有效期端点、零数量、不同币种、封顶、互斥和平局。性质测试验证更一般的不变量:总额不为负、分摊之和等于调整额、相同输入重复计算结果一致、行顺序变化不会改变无顺序语义的结果。生产事故应先沉淀成最小复现样例,再修正模型。
观测指标要能对应领域决策。除延迟和错误率外,还应记录规则命中率、拒绝原因分布、优惠成本、重价差异、过期报价比例、outbox 积压和事件处理延迟。日志使用 quoteId、orderId、ruleId 和版本串联,但避免记录完整用户隐私或可伪造金额。追踪只能告诉我们请求经过哪里,PriceBreakdown 才能说明为什么得到这个价格。
5.11 决策案例一:商品级折扣与订单级满减
假设购物车包含甲商品两件和乙商品一件。甲参加八折,订单满三百再减五十。第一项决策是满减门槛使用原价、商品级优惠后的金额,还是排除某些特价商品后的金额。三种口径都可能合理,模型不能用一个含糊的 Subtotal 代替。可以定义 ItemAdjustmentBase 与 OrderThresholdBase 两个值对象,并由规则明确读取哪一个。
第二项决策是五十元如何分摊。按优惠后行金额比例分摊,退款时最容易保持订单总额;但尾差必须落到确定订单行,并记录每行承担的订单级优惠。若退款时重新按剩余商品计算,会把购买时的促销政策改写成退款时政策,造成用户、商家和平台账务不一致。正确做法通常是保存成交分摊,退款政策另建规则处理。
第三项决策是规则解释。结果明细不仅写“优惠五十元”,还应给出规则标识、门槛基数、命中区间、分摊方式和版本。客服看到的解释与机器计算来自同一个 PriceBreakdown,避免代码算一套、文案配置又维护一套。
5.12 决策案例二:秒杀价、会员价与优惠券
秒杀常被误写成优先级最高的普通折扣,但它往往同时包含库存配额、用户限购、时间窗口和不可叠加政策。计价域只负责价格裁决,资格和配额事实由营销或活动上下文提供;计价请求携带已确认的活动候选及版本。若配额必须在报价时占用,则这是跨上下文流程,应显式建模预占、过期释放与下单确认,而不是在 Calculate 内部偷偷扣减库存。
会员价与秒杀价可能是二选一,也可能先选较低价再允许使用平台券。模型应先产生候选基础价,再执行互斥组选择,最后进入可叠加调整阶段。用“priority=100”只能得到顺序,无法说明为什么另一个候选被拒绝。RuleDecision 应记录 rejectedBy、exclusiveGroup 和比较结果,让选择逻辑可测试、可解释。
优惠券失败也不总是整个报价失败。用户主动选择的券不可用时,提交订单应返回明确拒绝,防止静默换成更贵结果;系统自动推荐的券不可用时,可以重新择优并在结果中说明。是否允许降级属于用例政策,由应用服务选择领域策略,不应由远程调用超时偶然决定。
5.13 决策案例三:跨店、跨品类与作用域
跨店满减会同时涉及平台活动、商家承担比例和多个店铺订单。把所有店铺商品放进单个订单聚合,会让交易边界无限扩大;完全按店铺独立计算,又无法判断平台级门槛。可将计价会话视为一次跨店裁决,先生成全局调整,再把结果分摊到店铺子单。订单上下文最终各自保存分摊证据,而平台活动预算由营销上下文独立结算。
品类券要求商品分类事实在评估时稳定。直接读取商品当前分类会使历史报价无法回放;请求应携带已版本化的计价分类快照。这里的“计价分类”不一定等同展示导航分类,二者变化节奏与业务目的不同,应通过反腐层翻译,避免商品前台调整目录导致优惠规则意外失效。
跨作用域规则的先后必须可见。商品级调整通常先于订单级门槛,费用级优惠只作用于配送费,支付级立减则可能不改变订单商品成交价。若所有调整都塞进一个 Discount 列表,财务分录、退款和商家结算都会失去依据。PriceBreakdown 应按作用域保留独立小计,并定义总额公式。
5.14 决策案例四:多币种展示与结算
国际站点可能用用户偏好币种展示,却用商家结算币种成交。展示换算可以使用短期缓存汇率,最终报价则需要汇率标识、来源、有效时间和舍入规则。Money 仍然只表达单一币种金额,换算由 ExchangeRate 值对象或领域服务完成,输出新的 Money 与 ConversionEvidence;禁止把汇率塞进 Money 后让任意加法自动换算。
优惠门槛在哪种币种判断必须明确。若活动写“满 100 美元减 10 美元”,先将商品换成美元再比较,和在本币比较等值金额可能因舍入产生边界差异。规则定义应固定基准币种与比较精度,结果记录换算前后值。对于接近门槛的请求,测试要覆盖汇率精度和有效期端点。
退款不应使用退款当天汇率重算原成交优惠。通常应沿用订单快照中的成交币种、换算证据和分摊结果;若支付渠道以另一币种退款产生汇兑差,差额属于支付或财务政策,而不是修改历史 PriceQuote。上下文边界在这里直接决定账务是否可解释。
5.15 决策案例五:库存、配送与税费
库存数量影响阶梯价,但计价不应把库存扣减纳入自己的数据库事务。对普通报价,可以读取可售性快照;对稀缺活动,可由库存上下文返回带期限的预占凭证。计价结果引用凭证,订单确认后再完成占用。凭证过期属于明确领域结果,调用方重新计价,而不是无限重试同一请求。
配送费可能依赖地址、重量、店铺、配送方式和商品优惠后的金额。为了避免计价与履约互相递归,应先定义依赖方向:履约提供配送选项和基础费用,计价应用费用券并形成应付费用;若免邮门槛取优惠后金额,计价流水线必须明确该阶段,且不得再次触发无限配送重算。需要迭代时,应设最大次数和稳定条件。
税费同样不是简单尾加。含税价、未税价、优惠是否影响税基、运费是否计税都由地区政策决定。计价域可以定义 TaxQuote 端口并保存税务服务返回的证据,但不复制税法。依赖不可用时,是拒绝成交、使用短期缓存还是标记待复核,应由地区和风险等级配置,不能统一吞掉错误返回零税。
5.16 决策案例六:规则发布与有效时间
促销配置从草稿到生效应经历校验、审批、模拟和发布。运行时只读取不可变规则版本;修改已发布规则实际是创建新版本。这样同一 quoteVersion 可以稳定指向完整规则集合,回放不依赖后来被覆盖的数据库行。紧急下线可以改变规则可用性,但仍要保存操作人、原因和生效时刻。
有效时间建议使用半开区间,即开始时刻包含、结束时刻不包含,避免相邻版本在边界同时生效。时区是规则属性或统一转换政策,不能依赖服务器本地时区。跨夏令时地区需要用明确时区标识解释“当地零点”,并在发布前模拟缺失或重复的本地时间。
发布前模拟应使用脱敏样本和合成边界样例,比较新旧版本的命中率、优惠成本和用户价格差。超出阈值时阻止发布或要求更高级审批。模拟不是为了证明新规则绝对正确,而是把大范围意外变化从线上事故提前变成可讨论证据。
5.17 决策案例七:降级与资损边界
依赖失败时“返回原价”看似安全,实际上可能违反已向用户承诺的权益;“返回最低价”又可能造成无上限资损。每个依赖应定义失败模式:基础价缺失通常不能报价,自动推荐优惠缺失可以标记降级,用户已领取权益校验失败可能要求重试或稍后提交,税务失败则依地区合规政策处理。
降级结果必须进入领域结果和监控。QuoteStatus 可以区分 Confirmed、Degraded 和 Rejected,并列出缺失证据。展示层对 Degraded 报价不能伪装成最终承诺;订单应用服务也可以禁止接受某些降级类型。这样技术可用性策略与业务风险政策在接口上对齐。
熔断只减少故障扩散,不决定业务结果。熔断器打开后,适配器返回结构化 DependencyUnavailable,应用服务再按场景政策选择缓存、候补提供者或拒绝。把默认金额写在基础设施层会绕过领域审计,是资损事故常见来源。
5.18 决策案例八:并发、版本与重试
用户修改购物车与提交订单可能并发发生。计价请求携带 cartVersion,结果也绑定该版本;订单接受报价时同时核对购物车版本、报价版本和有效期。只凭 cartId 读取最新状态,会让用户确认的页面与后端实际下单内容不一致。版本冲突应返回可恢复错误,引导客户端刷新,而不是盲目覆盖。
规则发布与报价计算并发时,计算过程应固定一个规则集快照。不能前半段读取旧促销,后半段读取新门槛。实现上可以先解析 ruleSetVersion,再按版本加载全部规则;若存储无法提供一致快照,则需要在应用层复制不可变版本或用事务读保证。
乐观锁失败后的重试必须重新评估输入是否仍有效。保存报价这种纯派生结果通常可以安全重算;消耗优惠资格或预占库存则不能只重放写操作,必须依赖幂等凭证确认先前副作用。重试策略应限制次数、加入抖动,并在最终失败时保留关联标识供人工调查。
5.19 决策案例九:退款、取消与售后
退款首先读取订单价格快照,而不是调用当前计价引擎重算。商品级优惠可直接按已保存分摊退回;订单级满减需要政策决定部分退款是否追回优惠。若退货后剩余金额低于原门槛,可以按原成交分摊退款,也可以重新核算并扣回差额,两者对用户体验和财务都有影响,必须由售后规则明确。
优惠券是否退回属于营销上下文决策。订单发布 RefundCompleted 或 OrderCancelled 事实,营销根据券类型、过期时间和退款原因决定返还;订单不应直接更新券表。计价快照提供使用证据,事件提供发生事实,二者结合避免同步调用把售后流程锁死在营销服务可用性上。
多次部分退款需要累计不变量:累计退款不得超过可退金额,各行累计退款不得超过成交分摊,最后一次退款吸收合法尾差。把这些规则放进 Refund 聚合或订单售后聚合,比在每个渠道适配器各写一套计算更可靠。
5.20 决策案例十:从影子计算到切流
替换旧计价引擎时,不应直接把全部流量切给新模型。第一阶段让新引擎读取同一输入做影子计算,不影响用户结果;比较总额、明细和拒绝原因,并按业务允许差异分类。仅比较最终总额会漏掉分摊错误,这类错误可能在退款或结算时才暴露。
第二阶段选择低风险渠道或品类灰度,把报价版本写入订单,确保后续链路能识别来源。灰度指标除技术错误率外,还要关注价格差异、优惠成本、订单转化、客服投诉和人工对账。超过阈值自动回退,但保留失败样本用于修正规则。
第三阶段逐步扩大流量,并冻结旧系统的新功能,只修复阻断迁移的问题。双写期间必须明确哪个系统是事实源,禁止运营人员在两边分别改规则。最终下线前完成历史订单读取、退款、对账和审计路径验证;迁移完成的标准不是旧服务无流量,而是所有依赖旧语义的业务能力都有明确去向。
5.21 一次完整报价如何落到代码
下面的代码刻意只展示边界,不绑定 Web 框架或 ORM。请求先以最小单位金额、币种和版本进入应用服务;应用服务把外部事实翻译为领域输入,规则引擎返回决定,聚合再生成不可变明细。远程调用发生在进入领域模型之前,消息发布发生在事务提交之后。
1 | type CalculateQuoteCommand struct { |
这段编排里有四个值得保留的决定。第一,EvaluatedAt 来自请求而不是领域内部读取系统时钟,使历史回放和边界测试可重复。第二,指纹覆盖 CartVersion 与规范化输入摘要;同一个幂等键若携带不同指纹,立即返回冲突。第三,Claim 依赖 request_id 唯一约束,并与报价、Outbox、Complete 放在同一数据库事务中:并发请求不能各自创建结果,事务回滚也不会留下“已占位但无报价”的记录。第四,Complete 的错误向外返回,不能在成功响应前静默丢失幂等结果。
5.22 错误协议与用户可见承诺
计价失败至少分四层:格式错误由协议层拒绝;领域拒绝说明请求合法但规则不允许,例如币种不一致或报价过期;版本冲突说明调用者基于陈旧事实;依赖失败说明当前无法取得必要证据。把所有失败都映射成 500 会让调用方盲目重试,把所有失败都返回 200 又会隐藏不可成交状态。
HTTP 适配器可以采用 RFC 9457 Problem Details,以稳定 type 区分问题类别,用扩展字段携带 quoteId、ruleId、currentVersion 和 retryable。API 契约用固定的 OpenAPI 3.1 规范 描述,不把 latest 链接写成某个固定版本。错误文案可以本地化,错误类型与字段语义必须保持稳定。
降级报价必须显式标记。若自动推荐优惠超时但基础价可靠,服务可以返回 Degraded,并说明缺少哪类权益;提交订单是否接受该状态由用例政策决定。若基础价、税务或用户主动选择的券无法确认,宁可拒绝也不要悄悄替换为另一个金额。可用性策略必须与资损和用户承诺一起评审。
5.23 用示例审查模型而不是审查类图
审查一:用户看到两件商品共 320 元,商品折扣后 280 元,订单满减 50 元,运费 10 元,应付 240 元。团队要指出门槛使用哪个基数、50 元如何分摊、退款时读取哪份证据。答不出时,不应继续讨论类名。
审查二:活动在 20:00 结束,请求在 19:59:59 创建、20:00:01 到达服务。规则使用客户端时间、服务接收时间还是明确的评估时间?时钟由谁信任?半开区间如何定义?答案要进入 RulePeriod 和测试,而不是留给线上偶然顺序。
审查三:用户提交订单时购物车已被另一个设备修改。报价与 cartVersion 绑定,Accept 返回版本冲突;客户端刷新后重新报价。系统不能因为 quoteId 存在就覆盖新购物车,也不能让支付环节重新计算后静默扣取不同金额。
审查四:Outbox 发布器重复发送 QuoteAccepted。订单消费者用 eventId 去重,业务处理本身也要可重放;分析消费者可以重复消费后按主键覆盖投影。生产者承诺至少一次,不虚构恰好一次。团队能用这些实例给出一致答案,模型才真正进入工程流程。
5.24 计价完成定义
一个计价需求完成,不只是接口返回正确总额。术语表已更新,规则作用域、互斥和优先级有示例;Money 的币种、精度、舍入与尾差有断言;报价记录输入版本和明细;失败协议对调用方稳定;指标能按规则版本观察命中与拒绝;订单、退款和对账知道读取哪份快照;回退方案不会抹掉审计证据。
完成定义还应包含所有者。价格表异常由谁处理,促销资格争议由谁解释,计价组合错误由谁值班,订单快照不一致由谁发起对账。领域边界只有连接到运行责任才具有约束力,否则它仍是一张只在设计评审中出现的图。
5.25 请求指纹、唯一约束与重试语义
请求指纹必须由规范化输入生成:订单行按稳定键排序,数量和最小单位金额使用规范格式,渠道、地区、币种、CartVersion、评估时间和显式选择的权益都参与摘要。不能直接对原始 JSON 求哈希,因为字段顺序、空白和等价默认值会让相同意图产生不同指纹;也不能漏掉评估时间,否则跨有效期重试可能错误复用旧报价。
request_id 唯一约束负责仲裁并发。两个相同请求同时到达时,都可以在事务外准备候选输入,但只有一个事务能够 Claim;另一个事务等待唯一索引结果后读取已完成记录。若指纹一致,它返回同一 QuoteView;若指纹不同,它返回幂等冲突。数据库隔离级别、唯一冲突到 Claim 结果的映射,以及等待超时都要有集成测试。
远程依赖发生在 Claim 之前意味着重复请求可能做重复计算,但不会产生两个成交结果;把 Claim 提前又会让长时间远程调用占据“处理中”记录,需要租约、接管和清理机制。示例选择前者,以额外计算换取简单的原子提交。高成本场景可以引入带租约的 Pending 状态,但必须明确崩溃恢复和同指纹等待策略,不能只增加一个布尔锁。
事务内的 Complete 不能被忽略。报价保存、Outbox 写入和幂等完成任一失败,整个事务都回滚,调用方收到失败并可以安全重试。若响应已经发送才写幂等记录,客户端超时重试就可能创建第二份报价;若幂等记录先提交而报价回滚,后续请求又会读到没有结果的成功占位。这正是三者需要同一原子边界的原因。
计价请求的最小完备性
同一购物车在不同渠道和时刻得到不同结果。评审的焦点应落在让渠道、地区、用户事实、评估时间、币种与版本全部显式,而不是先讨论框架。
需要持续成立的约束是相同完整输入得到相同决定,缺少关键事实直接拒绝。验收时检查请求摘要、输入版本串、幂等键和回放样例,并用失败注入证明边界没有依赖隐式调用顺序。
如果出现模型内部随时读取系统时钟或全局用户上下文,短期看似减少了接口或代码,长期却把错误定位、历史回放和策略迁移绑在一起。更稳妥的权衡是接受显式契约与版本成本,换取责任可追踪。
订单级优惠分摊
当五十元满减要分到三个订单行并支持部分退款时,表面故障背后的建模选择是选择稳定分摊基数和确定尾差归属。
模型以“行分摊之和等于五十元且每行不超可分摊金额”作为不变量。比例、平局、最小单位、全退与多次部分退测试构成发布证据,缺少其中任何一项都不能只靠人工确认。
展示时临时分摊,退款时又按新规则重算是该方案的否决信号。团队宁可承担少量转换和存储开销,也不应把所有权重新藏回共享状态;上线后由领域指标验证这个取舍。
降级报价
自动推荐优惠超时但基础价可用,这会迫使团队明确返回明确 Degraded 状态并由下单政策决定能否接受。
边界内必须保证降级不会伪装完整承诺,缺失权益可被监控和解释。评审材料应直接展示缺失依赖、风险等级、用户提示和转化影响指标,让产品、测试与值班人员看到同一结论。
一旦看到基础设施适配器直接返回零优惠,应暂停扩展功能并收紧边界。这里牺牲的是局部开发便利,得到的是并发行为、审计证据和故障恢复的确定性。
影子切流
以“新旧引擎最终总额相同但行分摊不同”为反例,系统必须先决定比较总额、明细、命中原因和版本,而不只比较一个数字。
实现可替换,灰度订单可追踪引擎来源并完整走退款对账却不能被破坏。对应证据是差异分类、阈值、自动回退和失败样本库,它们同时服务回归测试与事故回放。
只看成功率便宣布迁移完成意味着抽象没有降低变化成本。修正方案通常会增加一个版本、适配器或校验步骤,但能避免下游以猜测方式解释结果。
六、从模型到系统架构
从模型到架构的原则是让依赖指向业务决策,让跨边界失败变得显式。四层、整洁架构和六边形架构名称不同,但都可以用“领域不依赖数据库、消息、HTTP 与框架”这一条规则检验。计价模型只声明端口,基础设施在组合根装配实现。
6.1 从模型边界推导部署边界
分层和六边形架构首先是代码依赖规则:领域模型位于内层,应用层组织用例,端口表达核心所需能力,数据库、消息、HTTP 和第三方 SDK 都是可替换适配器。它们可以在单体中完整成立,不要求每层部署成独立服务。对计价系统而言,先在一个进程内把价格、营销、商品与订单模块的所有权和调用方向理顺,往往比立即增加网络边界更能降低复杂度。
服务拆分应同时检查四类证据:业务语言是否稳定不同,数据是否有明确所有者,团队是否能独立交付,运行时目标是否需要独立扩缩。只满足“代码很多”不是拆分理由。计价引擎可能因高计算负载需要独立扩缩,但规则管理后台未必需要;二者可以属于同一限界上下文,却采用不同部署单元。反过来,两个限界上下文也可暂时共处模块化单体,只要依赖只能经公开端口发生。
6.2 契约、迁移与回退
同步接口要区分提交命令与查询结果。创建报价是带幂等语义的用例,查询报价只读取已经形成的裁决;不要让 GET 请求在读取时偷偷重新计算。异步接口则区分领域事件与集成事件:前者服务于模型内部事实,后者经过筛选、版本化和隐私审查后发布给其他上下文。二者可以由同一事务触发,却不必共享数据结构。
迁移采用绞杀路径:先建立反腐层包住旧系统,再让新模型影子计算,随后按渠道或品类灰度,最后把旧调用者逐个迁到新契约。每阶段保留可操作回退开关,但回退不应删除已生成的版本与审计证据。双轨期间用同一业务标识关联新旧结果,差异分类到输入、规则、舍入、依赖或实现问题,而不是只统计“金额不一致”。
6.3 测试与可观测性的架构位置
测试边界跟随架构边界。领域层用纯内存测试验证金额、规则和聚合不变量;应用层以假的端口验证用例编排和失败政策;适配器用契约测试验证 SQL 映射、HTTP 翻译与消息模式;端到端测试只覆盖创建报价、接受报价、下单与退款等少数关键旅程。若一个规则只能启动全部基础设施才能测试,说明业务语义仍与框架耦合。
可观测性不是在最外层统一打印请求日志。领域决策产生结构化 DecisionTrace,应用层补充用例、耗时与依赖版本,适配器加入协议和资源指标。三者通过 quoteId 和 traceId 关联,又各自遵守隐私边界。告警应对应可行动风险:重价差异突然升高、特定规则拒绝异常、过期报价激增、outbox 持续积压,都比笼统的“接口错误率上升”更快指向责任边界。
6.4 同步、异步与一致性预算
同步调用把延迟和失败直接交给当前用户,适合必须即时确认的基础价与资格;异步投影降低运行时耦合,适合允许短暂陈旧的会员标签或规则索引。选择前先写一致性预算:数据最多陈旧多久、错误报价可否纠正、用户是否需要确认、失败能否补偿。没有预算的“最终一致”只是把责任推迟。
Hohpe 与 Woolf 的 Enterprise Integration Patterns 可用于明确消息通道、路由、端点和相关标识;Kleppmann 的 Designing Data-Intensive Applications 有助于理解复制、流与一致性的工程取舍;Fowler 的 Patterns of Distributed Systems 则提供可复用的分布式协调模式。模式只描述机制,计价团队仍需定义业务上允许的延迟和补偿。
6.5 CQRS 与服务拆分的采用门槛
写模型需要守护报价不变量,客服和运营查询却可能需要按用户、规则、渠道和时间聚合。只有当直接查询写库已明显妨碍模型或性能时,再引入独立读模型。Microsoft 的 CQRS pattern 提醒读写分离会带来同步延迟和双模型治理;团队必须准备投影重建、版本迁移和陈旧数据提示。
微服务也应在模块边界稳定后采用。Richardson 的 Microservices Patterns 入口 总结 Saga 等跨服务模式,但网络边界会新增超时、重试、观测和发布协调。若计价与营销仍共享大量表、术语和同步发布,先做模块化单体更诚实。部署边界可以晚于模型边界,反过来却很难用网络修复混乱模型。
6.6 可观测性必须解释业务决定
技术指标记录吞吐、延迟和错误;领域指标记录规则命中、拒绝原因、优惠成本、重价差异、过期报价和分摊尾差。日志关联 requestId、quoteId、cartVersion、ruleSetVersion 与 eventId,但不记录完整身份信息。追踪展示调用经过哪些服务,DecisionTrace 解释哪些候选被接受或拒绝。
报警要对应行动。基础价缺失率上升通知价格所有者,某规则拒绝率突变通知营销所有者,outbox 积压通知平台值班,订单接受过期报价则同时通知计价和订单团队。仅用统一“接口错误率”报警,会在最需要边界时重新制造模糊责任。
6.7 渐进迁移的四道闸门
第一道闸门是语言:为旧字段建立翻译表,所有新用例使用新术语。第二道闸门是模型:在旧数据库之上用仓储和反腐层收口不变量。第三道闸门是行为:影子计算比较总额、分摊和原因,再按渠道灰度。第四道闸门是数据与部署:只有边界、指标和回退已验证,才拆库或独立服务。
每道闸门都保留旧路径,但只允许一个事实源。双写时要明确写入权威、校验差异和结束日期,避免“临时兼容”成为永久架构。迁移完成以订单、退款、对账、客服和审计都不再依赖旧语义为准,而不是以旧服务流量归零为准。
架构决策记录应包含被放弃的方案。例如读压力上升时,团队可能比较增加索引、只读副本、缓存和独立投影。若最终选择投影,需要记录为何其他方案不足、允许多大延迟、怎样重建和怎样向用户显示陈旧状态。只记录结论会让半年后的团队无法判断前提是否仍成立,进而把临时设计当作永久真理。
容量规划也应使用领域单位。除 QPS 外,还要估算每个购物车的订单行数、候选规则数、组合上限和 PriceBreakdown 大小。某次活动把候选规则从十条提高到两百条,CPU 正常并不代表决策时间可控。入口应限制不合理组合,并在发布规则时提前模拟最坏复杂度,而不是把超时完全交给运行时熔断。
灾难恢复需要验证业务证据,而非只验证数据库可启动。恢复演练后抽取报价、订单和退款样本,核对输入版本、明细分摊、Outbox 水位与消费进度;确认重复发布不会改变成交事实,缺失投影可以重建。恢复点目标决定可能丢失多少尚未接受的报价,但已接受报价与订单快照通常需要更严格保护。
6.8 缓存不是绕过边界的捷径
计价常用缓存承载价格表、规则和用户事实,但每类缓存需要不同一致性政策。价格表缓存应携带版本和生效时间;规则缓存应按不可变规则集发布,切换时一次选择完整版本;用户事实缓存要说明最大陈旧时间。统一设置“五分钟过期”无法表达这些差异,也无法在事故中判断结果是否可信。
缓存键必须包含影响决定的维度。只按商品 ID 缓存基础价,会混淆渠道、地区、会员层级和币种;把所有请求字段都塞进键,又可能造成不可控基数。正确做法是由领域输入明确价格的决定因素,再据此设计键与失效事件。键设计因此属于模型与基础设施共同评审的契约,而不是缓存组件的私有细节。
缓存穿透和击穿也有业务后果。热点规则失效时,大量回源可能拖垮价格服务;返回陈旧值则可能违反活动结束时间。团队可以采用单飞、预热和短暂陈旧读取,但每种策略都要绑定允许场景。例如展示页可在标明“以结算为准”时接受短暂陈旧,提交订单必须使用可验证版本,不能沿用展示缓存作成交证据。
缓存事故的证据包括命中版本、数据年龄、回源结果与使用该值的 quoteId。仅记录 hit/miss 无法解释错误金额。恢复后也不能简单清空全部缓存:大规模冷启动可能形成第二次故障。按规则集或价格表版本逐步失效,并比较重价差异,才能让恢复动作本身保持可控。
同步还是异步
会员标签允许分钟级延迟,基础价必须实时确认。评审的焦点应落在分别定义新鲜度与失败政策,而不是统一 RPC 或消息,而不是先讨论框架。
需要持续成立的约束是每类事实的陈旧上限和降级后果可被调用者理解。验收时检查 SLA、缓存版本、积压水位和重价差异,并用失败注入证明边界没有依赖隐式调用顺序。
如果出现因技术偏好把所有依赖改成同一种通信,短期看似减少了接口或代码,长期却把错误定位、历史回放和策略迁移绑在一起。更稳妥的权衡是接受显式契约与版本成本,换取责任可追踪。
是否拆计价服务
当模块计算负载高但规则与营销仍频繁共同修改时,表面故障背后的建模选择是先独立扩展计算适配器或模块,再验证领域边界。
模型以“部署变化不迫使模型共享数据库或泄漏内部类型”作为不变量。性能数据、独立发布率、调用失败预算和团队所有权构成发布证据,缺少其中任何一项都不能只靠人工确认。
按代码行数拆服务后仍同步发布是该方案的否决信号。团队宁可承担少量转换和存储开销,也不应把所有权重新藏回共享状态;上线后由领域指标验证这个取舍。
是否引入 CQRS
运营复杂查询拖慢报价写入,这会迫使团队明确比较索引、只读副本和独立投影的成本后再选择。
边界内必须保证写模型仍守护规则,读模型声明陈旧并可重建。评审材料应直接展示查询负载、投影延迟、重建时间和故障演练,让产品、测试与值班人员看到同一结论。
一旦看到为简单列表复制完整读写模型,应暂停扩展功能并收紧边界。这里牺牲的是局部开发便利,得到的是并发行为、审计证据和故障恢复的确定性。
契约兼容
以“QuoteAccepted 要增加承担方分摊”为反例,系统必须先决定新增可选字段、版本化语义并给消费者迁移窗口。
实现可替换,旧消费者仍能处理,新消费者能识别字段来源却不能被破坏。对应证据是 AsyncAPI 示例、消费方测试、弃用日期和使用清单,它们同时服务回归测试与事故回放。
直接改字段含义却保持原版本意味着抽象没有降低变化成本。修正方案通常会增加一个版本、适配器或校验步骤,但能避免下游以猜测方式解释结果。
可观测责任
报价延迟正常但优惠成本突然翻倍。若只在流程上打补丁,便会绕开真正的选择:把技术指标与规则版本、命中率和成本指标关联。
设计接受的底线为报警能指向业务所有者并保留决策证据。团队通过规则维度仪表盘、DecisionTrace 和异常样本判断方案是否达到底线,而非统计新增了多少领域类。
若实现退化为只有 CPU、P99 和统一错误率,技术指标可能仍然正常,业务结果却不可证明。评审必须把可解释性和回放能力作为与延迟同等重要的约束。
七、实施误区与演进清单
实施 DDD 最常见的失败不是少用了一个模式,而是模式替代了业务讨论。下面先给出决策原则,再用检查清单约束日常评审。
7.1 不从目录和基类开始
先选择一条高价值用例,收集真实实例、术语冲突和事故,再确定边界与不变量。若第一周的产出只有 entity、repository、service 目录,业务专家无法判断对错,团队很可能只是在重命名分层。一个可以复述的报价案例比十个空接口更能推动模型。
7.2 不把贫血与充血理解成方法数量
充血模型的标准是对象守护业务规则,而不是 getter 少、方法多。把数据库字段搬进私有成员,再让应用服务决定所有状态变化,仍然是贫血;把远程调用、事务和消息发送塞进实体,又会破坏边界。实体负责自身生命周期,领域服务负责无自然归属的决策,应用服务负责用例和技术协调。
7.3 不以分布式技术证明先进
事件、CQRS 和微服务都会扩大状态空间。事件需要幂等、版本和积压治理;CQRS 需要投影与陈旧数据处理;微服务需要超时、重试和跨团队发布。只有现有模型的具体痛点能证明收益时才引入。技术选型记录必须同时写收益假设、额外成本、验证指标和退出条件。
7.4 让生产韧性成为领域政策
幂等、超时、重试和降级不是统一中间件参数,它们会改变用户承诺。AWS Builders’ Library 对超时、重试和分布式系统实践提供了生产视角。计价团队应分别定义基础价、优惠、税和事件发布的失败政策,避免基础设施层用同一个默认值处理所有依赖。
7.5 可逐项勾选的误区检查清单
- 我们先写了业务实例、术语和不变量,再决定是否引入框架或模式。
- 同一个术语在当前限界上下文内只有一个明确含义;跨上下文同名概念有翻译规则。
- 聚合只包含必须在同一事务内保持一致的状态,没有把整个业务流程塞进一个对象。
- 实体的方法表达业务意图,不依赖应用服务按正确顺序调用一组 setter 才能有效。
- Money 明确币种、精度、舍入阶段和尾差分摊,禁止不同币种直接运算。
- 规则资格、效果、互斥与优先级分离,并为边界、平局和组合爆炸准备测试。
- 外部上下文通过端口和反腐层接入,领域层不读取其他服务的数据表或 DTO。
- 报价包含输入版本、评估时间和明细,能解释、回放并支撑订单价格快照。
- 聚合内一致性由事务保证,跨聚合流程显式接受最终一致性、补偿与人工兜底。
- 事件表达已发生的事实,载荷最小且版本化;消费者按事件标识幂等处理。
- 业务数据与事件通过 Outbox 等机制避免双写窗口,并监控积压与失败重试。
- HTTP 和异步契约都有稳定错误语义、兼容规则、所有者和弃用窗口。
- CQRS 只在读写模型确实需要不同形态时使用,未把简单查询问题升级为双模型治理。
- 事件溯源只在完整历史是核心需求且团队能承担回放、版本与存储成本时使用。
- 微服务拆分发生在边界经过模块化验证之后,不以部署单元代替领域分析。
- 单元测试覆盖规则与不变量,契约测试覆盖边界翻译,端到端测试只守关键旅程。
- 指标和日志能回答哪些规则命中、为何拒绝、哪个版本异常,而不泄漏敏感信息。
- 每一步迁移都有基线、验收指标、回退方案和责任人,不追求一次性重写。
7.6 演进节奏:一次只验证一个风险
第一阶段只统一语言和输入所有权,不改部署;第二阶段把金额和规则不变量收进模型,用旧数据库实现仓储;第三阶段影子计算,验证新旧差异;第四阶段让少量订单接受新报价并完整走退款、对账;第五阶段再决定是否拆服务。每阶段都能独立交付价值,也能在证据不足时停止。
重构优先级由风险决定。资损与不可解释报价优先于目录美观,跨团队共享写表优先于消除少量重复代码,历史订单无法读取优先于采用新框架。技术债清单应关联业务后果和验证方式,避免“DDD 化”成为没有终点的大项目。
7.7 团队协作:模型需要固定反馈回路
建立每周短时模型评审,参与者至少包括产品、领域专家、开发和测试。会议只处理真实实例:新规则如何命名、落在哪个上下文、改变哪条不变量、需要哪份证据。争议记录为示例和反例,决议同步到术语表、契约和测试,不另建脱离代码的宏大模型文档。
事故复盘也要回到模型:是输入所有权不清、时间语义遗漏、规则组合错误、聚合边界错误,还是集成可靠性不足。修复不仅加一个 if 或重试,还要让新的知识进入类型、方法、契约、指标或运行手册。这样模型才会随生产事实演进。
7.8 退出条件与适用边界
当业务规则已稳定、模型的抽象成本高于变化成本,可以停止加深 DDD,保持简单事务脚本。某个上下文也可能从核心域变为通用能力,应主动缩减定制模型。DDD 是投资决策,不是身份标签。
实施成功的信号是:产品和研发能用相同词解释规则;改动影响被限制在清晰边界;测试能覆盖不变量;报价可以回放;事故能快速定位责任;团队知道何时不采用更复杂的模式。满足这些结果,比代码中出现多少 Aggregate 或 Event 更重要。
评审清单不能取代抽样阅读。每个迭代随机选择一条真实报价,从入口追到价格表、候选规则、组合决定、订单快照和事件,检查术语与证据是否连续。抽样发现的问题要回写到自动检查:例如缺少 ruleSetVersion,就让契约测试失败;出现未知币种,就让构造器拒绝,而不是只在会议纪要中提醒。
上线后应安排短周期复盘,而不是等待事故。比较预期与实际命中率、优惠成本、降级比例和重价差异,判断模型假设是否成立。若某条规则经常需要人工解释,优先改善 PriceBreakdown;若大量冲突来自相同边界,重新评估输入版本和所有权。反馈进入下一轮建模,才形成真正的持续设计。
最后要管理删除。规则版本过期、事件字段废弃、旧适配器下线都需要使用清单、观察窗口和回退点。只擅长新增而不删除,会让统一语言同时存在多代含义,边界再次模糊。删除完成的证据包括零消费者、历史数据可读、监控无旧流量和运行手册已更新。
7.9 把评审结论变成发布闸门
高风险变更不能只依赖评审人记忆。规则发布前自动验证时间区间、币种、门槛、互斥组和预算上限;契约发布前运行消费方测试;数据库迁移前验证旧快照仍可恢复;服务发布前用固定样本比较新旧 PriceBreakdown。自动闸门守住可机械判断的底线,评审者才有精力讨论新的业务权衡。
闸门失败要返回领域语言,而不是笼统的“校验不通过”。例如“规则 A 与规则 B 同属互斥组却配置为强制叠加”“JPY 金额包含小数”“事件新必填字段会破坏三个消费者”。清晰错误既缩短修改时间,也让规则所有者逐渐理解模型约束。若每次都由开发翻译日志,统一语言并未真正进入工具链。
紧急发布可以有受控旁路,但必须记录批准人、风险、有效期和补偿动作。旁路不应关闭所有校验,而是明确豁免某一条,并提升监控和抽样对账。到期后系统自动阻止继续使用,避免“临时开关”永久存在。事后复盘决定是补充模型能力,还是确认该异常不值得长期支持。
发布完成后的证据同样重要。观察窗口内核对规则命中、优惠成本、错误类型、降级比例和用户投诉,并与模拟基线比较。指标正常后才结束变更;出现偏差时按预设阈值回退规则版本,而不是临时修改更多参数。这样发布、观察与回退共同成为模型演进的一部分。
避免大爆炸重写
旧引擎难维护但仍承载全部订单。评审的焦点应落在按语言、模型、影子、灰度和下线分阶段验证,而不是先讨论框架。
需要持续成立的约束是每阶段可回退且只有一个事实源。验收时检查阶段验收、差异报告、流量标记和回退演练,并用失败注入证明边界没有依赖隐式调用顺序。
如果出现新系统一次性复制旧系统所有隐含行为,短期看似减少了接口或代码,长期却把错误定位、历史回放和策略迁移绑在一起。更稳妥的权衡是接受显式契约与版本成本,换取责任可追踪。
避免模式考核
当团队把聚合数量和事件数量当成改造成果时,表面故障背后的建模选择是用交付周期、事故解释和边界独立性评价模型。
模型以“每个模式对应明确业务问题和退出条件”作为不变量。基线指标、变更记录、事故恢复时间和团队反馈构成发布证据,缺少其中任何一项都不能只靠人工确认。
为了覆盖模板创建无行为类和无消费者事件是该方案的否决信号。团队宁可承担少量转换和存储开销,也不应把所有权重新藏回共享状态;上线后由领域指标验证这个取舍。
避免文档漂移
术语表、接口、代码和客服话术各自更新,这会迫使团队明确让变更评审同时更新模型资产和验收示例。
边界内必须保证同一术语在边界内的定义可从多入口一致验证。评审材料应直接展示链接到测试的示例、契约检查和定期抽查,让产品、测试与值班人员看到同一结论。
一旦看到模型只存在于过期幻灯片,应暂停扩展功能并收紧边界。这里牺牲的是局部开发便利,得到的是并发行为、审计证据和故障恢复的确定性。
知道何时停止
以“支撑域规则趋于稳定且维护投入超过收益”为反例,系统必须先决定保留清晰边界但降低战术复杂度。
实现可替换,简化不破坏数据所有权、历史兼容和关键不变量却不能被破坏。对应证据是变更频率、维护成本、替代方案和回退计划,它们同时服务回归测试与事故回放。
把 DDD 当成不可逆的组织身份意味着抽象没有降低变化成本。修正方案通常会增加一个版本、适配器或校验步骤,但能避免下游以猜测方式解释结果。
八、总结与参考资料
DDD 的主线是把业务知识变成共同语言、明确边界和可执行模型。战略设计决定价格、营销、商品、用户与订单分别拥有何种事实;战术设计让值对象、聚合、领域服务、仓储和事件各守一类责任;架构则保护这些决定不被数据库、协议和分布式机制反向控制。
计价案例表明,困难从来不只是算术。币种与舍入、规则互斥、有效时间、输入版本、分摊、快照、幂等和错误协议共同定义了用户看到并最终支付的价格。实施应从实例和不变量开始,用影子计算、灰度、对账与事故反馈逐步验证。CQRS、事件溯源和微服务只有在收益证据充分时才加入。
DDD 原典与模式
- Eric Evans;Domain-Driven Design(2003)— https://www.domainlanguage.com/ddd/
- Vaughn Vernon;Implementing Domain-Driven Design(2013)— https://vaughnvernon.com/
- Vaughn Vernon;IDDD Samples(持续维护)— https://github.com/VaughnVernon/IDDD_Samples
- Martin Fowler;Patterns of Enterprise Application Architecture(2002)— https://martinfowler.com/eaaCatalog.html
- Martin Fowler;Bounded Context(2014)— https://martinfowler.com/bliki/BoundedContext.html
- Martin Fowler;Domain-Driven Design(2006)— https://martinfowler.com/bliki/DomainDrivenDesign.html
- Martin Fowler;Domain Model(2003)— https://martinfowler.com/eaaDev/DomainModel.html
- Martin Fowler;Event Sourcing(2005)— https://martinfowler.com/eaaDev/EventSourcing.html
架构与集成
- Martin Fowler;Event-Driven Architecture(2017)— https://martinfowler.com/articles/201701-event-driven.html
- Martin Fowler;Event Storming(2018)— https://martinfowler.com/bliki/EventStorming.html
- Alberto Brandolini;EventStorming(持续维护)— https://www.eventstorming.com/
- Gregor Hohpe、Bobby Woolf;Enterprise Integration Patterns(2003)— https://www.enterpriseintegrationpatterns.com/
- Chris Richardson;Microservices Patterns(2018)— https://microservices.io/
- Chris Richardson;CQRS pattern(持续维护)— https://microservices.io/patterns/data/cqrs.html
- Chris Richardson;Transactional Outbox(持续维护)— https://microservices.io/patterns/data/transactional-outbox.html
- Martin Fowler;Patterns of Distributed Systems(持续维护)— https://martinfowler.com/articles/patterns-of-distributed-systems/
云原生与规范
- Microsoft Azure;Microservices architecture(持续维护)— https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/microservices
- Microsoft Azure;CQRS pattern(持续维护)— https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs
- Microsoft Azure;Domain analysis for microservices(持续维护)— https://learn.microsoft.com/en-us/azure/architecture/microservices/model/domain-analysis
- AWS;Builders’ Library(持续维护)— https://aws.amazon.com/builders-library/
- CloudEvents Community;CloudEvents Specification(1.0)— https://cloudevents.io/
- AsyncAPI Initiative;AsyncAPI Specification(3.0)— https://www.asyncapi.com/docs
- OpenAPI Initiative;OpenAPI Specification(3.1.0)— https://spec.openapis.org/oas/v3.1.0.html
- IETF;RFC 9457 Problem Details(2023)— https://www.rfc-editor.org/rfc/rfc9457.html
计价、货币与数据语义
- ISO;ISO 4217 currency codes(持续维护)— https://www.iso.org/iso-4217-currency-codes.html
- Martin Kleppmann;Designing Data-Intensive Applications(2017)— https://dataintensive.net/