日期:2026-10-30 · 主题:领域驱动设计、软件架构、业务建模
一个电商平台要增加退款功能。
需求听起来很简单:用户点击退款,系统调用支付渠道,成功后把订单状态改成“已退款”。
真正开始实现时,问题随之出现:订单已经支付,但只退还一部分怎么办?用户和客服同时提交退款,如何避免总金额超过已支付金额?渠道已受理请求,网络却超时了,能否直接重新发起?订单系统看到的“退款中”,和支付渠道看到的“退款成功”,是否必须在同一时刻一致?
这些问题不是数据库字段命名的问题。它们涉及业务概念、职责边界、状态和必须保护的规则。
领域驱动设计(Domain-Driven Design,DDD)是一种围绕业务领域建立共同语言和软件模型的方法。 它帮助团队决定:哪些概念属于同一模型,哪些规则应由谁保护,以及不同模型怎样协作。
DDD 不要求所有项目都使用微服务,也不要求每个 CRUD 接口都拥有复杂领域对象。它尤其适合规则复杂、业务长期变化、不同团队对同一概念有不同理解的系统。Martin Fowler:Domain-Driven Design
本文的电商退款规则和代码均为虚构教学示例,不代表任何现有产品或具体公司的实现。真实退款流程需要依据实际支付渠道、业务政策和法律要求设计。
一、为什么先谈业务,而不是先画数据库表?
假设团队从数据库开始:
orders
payments
refunds
users
接着创建对应的 Controller、Service、Repository,再为每张表实现增删改查。
这种方式对于简单数据管理应用可能足够。但退款业务会很快出现一些跨字段、跨系统的规则:
- 支付成功后才能退哪些款项?
- 已确认退款和处理中退款分别占用多少可退额度?
- 渠道超时后,资金状态是失败还是未知?
- 用户申请退款,与支付渠道实际退款,是同一件事吗?
- 谁负责决定用户是否有权退款?
- 哪些状态允许重试,哪些状态需要人工处理?
如果这些规则散落在 API、定时任务、消息消费者和前端按钮判断里,同一个业务概念会逐渐出现多种解释。
DDD 的出发点是与业务人员共同回答这些问题,再根据答案建立模型。数据库和服务仍然重要,但它们应当服务于模型,而不是先决定业务一定要怎样组织。
二、先区分领域、子域和限界上下文
1. 领域与子域
领域是软件要解决的业务范围。对于一个电商平台,它可能包含交易、支付、售后、物流、结算等活动。
子域是其中可以单独理解的业务问题。例如:
| 子域 | 关注的问题 |
|---|---|
| 订单交易 | 商品、订单、付款状态和取消 |
| 售后退款 | 用户申请、审核、退款资格 |
| 支付 | 支付渠道、退款指令、支付结果 |
| 账务 | 金额记录、余额、资金核对 |
| 通知 | 把状态变化告知用户 |
团队可以进一步判断哪些是核心竞争力、哪些是支撑能力、哪些是通用能力。但这个分类需要结合具体公司;例如,同样是支付,对支付平台可能是核心能力,对普通内容网站可能只是通用能力。
2. 限界上下文
**限界上下文(Bounded Context)**是某套模型和语言成立的明确边界。
同一个“订单”,在不同上下文里可能有不同含义:
| 上下文 | “订单”最关心什么 |
|---|---|
| 订单上下文 | 商品、价格、用户、是否取消 |
| 支付上下文 | 已支付金额、渠道交易号、资金状态 |
| 退款上下文 | 申请金额、审核结果、可退额度 |
| 物流上下文 | 包裹、地址、发货与签收 |
这些模型可能指向同一个业务订单 ID,但不必强行共享一个包含所有字段的巨大 Order 类。
如果一个类同时管理商品、物流、渠道回调、退款审核和账务分录,任何一处变化都可能影响其他团队。相反,不同上下文可以通过明确契约交换它们真正需要的信息。
Martin Fowler 对限界上下文的解释强调:一个模型的语言和含义只在明确边界内保持一致,边界之间的关系也需要被描述。Bounded Context
3. 统一语言不等于全公司所有人用同一套词
**统一语言(Ubiquitous Language)**是业务人员和开发人员在特定上下文中共同使用的词汇。
比如退款上下文可以明确:
退款申请:用户或客服提出的业务请求。
退款指令:提交给支付渠道的一次操作。
退款成功:渠道已确认成功,满足本系统定义的确认条件。
结果未知:请求可能已被渠道受理,但本系统尚未得到可靠结果。
可退额度:已支付金额减去已确认退款和已预留退款额度。
这些定义是本例的设计选择。实际业务可能不同,但团队必须把“不同”说清楚。
统一语言不意味着订单团队和支付团队必须使用完全相同的 Order 模型。相同词在不同上下文可以有不同含义,但接口和映射关系需要明确。
三、从业务讨论到上下文地图
一个简单的上下文地图可以这样画:
flowchart LR
U[用户 / 客服] --> R[退款上下文]
R -->|查询订单资格| O[订单上下文]
R -->|提交退款指令| P[支付上下文]
P -->|调用与状态查询| X[外部支付渠道]
P -->|已确认的支付事实| L[账务上下文]
R -->|退款状态变化| N[通知上下文]这张图表达的是职责和交互,不是要求每个框都是独立部署的微服务。它们可以先是一个应用中的独立模块,也可以在有明确需要时拆分部署。
上下文之间需要回答:
- 谁拥有某项事实?例如,渠道是否实际退款,应由支付上下文结合渠道结果确认。
- 谁可以修改它?其他上下文不能仅凭自己的猜测改写支付事实。
- 接口使用哪些字段?字段语义和版本如何管理?
- 对方不可用时,调用方如何处理?
- 事件延迟、重复或乱序时,系统如何恢复?
防腐层:不让外部模型污染内部语言
外部支付渠道可能返回:
PROCESSING
ACCEPTED
COMPLETE
REJECTED
但这些字段的具体含义取决于渠道文档。某个渠道的 ACCEPTED 可能只表示接收请求,不代表资金已经退还。
**防腐层(Anti-Corruption Layer)**负责把外部模型翻译成内部概念,并隔离外部字段变化。例如:
渠道返回 ACCEPTED
↓ 按该渠道契约解释
内部状态:处理中
不能因为字段名字看起来像成功,就把它映射成退款成功。防腐层的作用不是额外包一层 HTTP 客户端,而是保护内部业务语义。
四、实体和值对象:区分身份与属性
DDD 的战术设计提供了一组建模工具。
实体:靠身份区分
退款申请有自己的申请 ID。即使审核状态、备注或渠道结果发生变化,它仍然是同一个申请。
RefundRequest
- refund_id
- payment_id
- amount
- status
这里 refund_id 是身份;不能仅因为两笔退款金额相同,就认为它们是同一个实体。
值对象:靠值表达概念
金额可以建模成 Money:
Money(amount_minor=1000, currency="USD")
金额的币种和数值共同决定其含义。1000 美分与 1000 日元不是同一个值;不同币种也不能直接相加。
使用整数最小单位或定点数只是表示方式的一部分,还需依据币种和业务明确精度、舍入、汇率与转换规则。值对象也不一定都要写成类:如果一个规则用简单、不可变的数据结构就能表达清楚,就不必为了目录整齐创建大量空对象。
五、聚合:真正要保护的是什么?
**聚合(Aggregate)**不是“把相关表放进一个文件夹”,而是一组需要围绕明确规则保持一致的领域对象。外部操作通过聚合根进入,由它保护聚合内部的不变量。
退款示例中,假设一笔支付只能退回不超过已支付的金额。我们设计一个“支付退款额度”聚合:
支付总额:10000
已确认退款:2000
处理中预留:3000
剩余可退:5000
不变量是:
已确认退款 + 处理中预留 ≤ 支付总额
为什么要把“处理中预留”算进去?
如果只计算已完成退款:
- 支付金额为 100。
- 请求 A 申请退款 80,尚未完成。
- 请求 B 同时申请退款 80。
- 两者都看到“已退款为 0”。
如果没有预留和并发控制,两次请求都可能被接受,最终超过可退额度。
需要强调:这是一种示例模型。真实业务可能允许或禁止部分退款、涉及撤销、费用、汇率等条件,必须重新建模。
聚合不是只靠内存对象就能保护并发
两个服务实例可能同时读取同一个聚合的旧状态,各自在内存中判断“余额足够”。如果持久化时没有并发控制,两次更新仍可能同时成功。
所以需要把领域规则与基础设施保证接起来,例如:
读取聚合及 version=8
→ 聚合判断是否允许预留
→ 在数据库事务中使用 version=8 条件更新
→ 只有一个并发请求成功,另一个重新读取并处理
也可以使用行锁或其他适合数据存储的方式。聚合描述要保护什么,事务和并发控制负责在真实多进程环境中落实它。
不能因为代码里写了 if 判断,就宣称不变量已经在生产环境得到保护。
六、一个最小领域模型:退款额度
下面的 Python 示例聚焦领域规则,可在内存中运行。金额使用整数最小单位,同一聚合只处理一种币种;它不是生产账务代码,也没有实现数据库事务、并发控制、授权、渠道接口和事件持久化。
from dataclasses import dataclass, field
@dataclass
class RefundReservation:
amount_minor: int
status: str # PENDING, UNKNOWN, SUCCEEDED, FAILED
@dataclass
class RefundCapacity:
payment_id: str
paid_minor: int
reservations: dict[str, RefundReservation] = field(default_factory=dict)
def occupied_minor(self) -> int:
return sum(
item.amount_minor
for item in self.reservations.values()
if item.status in {"PENDING", "UNKNOWN", "SUCCEEDED"}
)
def request(self, refund_id: str, amount_minor: int) -> None:
existing = self.reservations.get(refund_id)
if existing is not None:
if existing.amount_minor != amount_minor:
raise ValueError("same refund_id with different amount")
return # 同一个业务请求的重复提交
if amount_minor <= 0:
raise ValueError("amount must be positive")
if self.occupied_minor() + amount_minor > self.paid_minor:
raise ValueError("refund capacity exceeded")
self.reservations[refund_id] = RefundReservation(
amount_minor=amount_minor,
status="PENDING",
)
def mark_unknown(self, refund_id: str) -> None:
item = self.reservations[refund_id]
if item.status == "PENDING":
item.status = "UNKNOWN"
elif item.status != "UNKNOWN":
raise ValueError("cannot change a final result to unknown")
def confirm(self, refund_id: str, result: str) -> None:
if result not in {"SUCCEEDED", "FAILED"}:
raise ValueError("unsupported final result")
item = self.reservations[refund_id]
if item.status == result:
return # 重复的同一最终结果
if item.status not in {"PENDING", "UNKNOWN"}:
raise ValueError("conflicting final result")
item.status = result
这里有几个值得注意的设计选择:
- 同一个退款 ID、相同金额的重复请求不再次占用额度。
- 同一个退款 ID 对应不同金额时拒绝,不能悄悄把重试变成新请求。
- UNKNOWN 仍占用额度,因为渠道可能已经受理。
- FAILED 释放本例的预留额度;真实业务中必须以可靠失败判定和具体规则为前提。
- 已确认的最终状态不能被另一个冲突结果直接覆盖,应进入异常处理或对账。
实际系统还需处理对象持久化、原子提交、重复回调、时间戳、失败原因、渠道映射,以及受权限保护的业务操作。退款失败是否允许重新申请,也需要通过新的业务意图和规则明确。
七、领域服务与应用服务:谁负责什么?
不是所有业务规则都自然属于某个实体方法。
例如,退款资格可能需要结合:
- 订单是否处于允许退款的状态。
- 商品是否属于可退款类别。
- 订单是否超过业务规定的申请期限。
- 用户身份和退款发起人的权限。
如果规则需要多个领域概念,可以设计领域服务来表达这一判断,例如 RefundEligibilityPolicy。它仍然关注业务规则,而不是 HTTP、消息队列或 SQL。
应用服务则负责组织一次用例:
接收退款申请
→ 校验调用身份与授权
→ 查询必要的订单和支付事实
→ 使用领域规则判断资格
→ 加载退款额度聚合并申请预留
→ 在本地事务中保存聚合与待发布事件
→ 返回申请已接受,或明确说明失败
它可以调用仓储、领域服务和聚合;也负责协调事务边界。不要把这段流程理解成必须同步等待支付渠道完成。用户看到的“申请已接受”和“资金已退回”是不同状态。
领域服务、应用服务和基础设施服务的名称只是沟通工具。更重要的是能否回答:规则由谁决定,谁负责调度,谁负责保存和调用外部系统。
八、仓储与数据库:领域模型不能脱离现实
仓储(Repository)提供按领域身份加载和保存聚合的接口。例如:
load_refund_capacity(payment_id)
save_refund_capacity(capacity, expected_version)
它可以隐藏具体数据库查询,但不能隐藏所有存储限制。
实际设计必须考虑:
- 聚合修改是否在一个事务中提交。
- 乐观锁或行锁如何阻止并发覆盖。
- 事务失败后,领域事件是否还会被发布。
- 查询模型和领域模型是否必须相同。
- 分片后,同一聚合是否仍落在可保证一致性的边界内。
仓储也不必为每一张表都创建一个接口。数据库表是持久化结构;聚合是业务一致性边界,两者未必一一对应。
如果系统只是简单的信息维护,一套清晰的 SQL 和服务函数可能比引入多个抽象层更容易维护。DDD 的价值取决于它是否帮助表达和保护真实复杂度。
九、领域事件、集成事件与 Outbox
退款过程中可能发生:
RefundRequested
RefundAccepted
RefundOutcomeUnknown
RefundSucceeded
RefundFailed
这些事件描述的是业务中发生了什么,不是“数据库某一行被更新”。
例如:
- RefundRequested:退款申请已经创建。
- RefundAccepted:本例中表示请求通过内部检查并预留额度。
- RefundSucceeded:获得符合系统定义的渠道成功确认。
- RefundOutcomeUnknown:当前还不能确定渠道结果。
事件命名必须与真实语义一致。HTTP 请求返回 200,不一定等于资金已经退回;渠道已接收,也不一定等于已完成。
领域事件与集成事件
可以把领域事件理解为一个上下文内值得其他部分知道的业务事实;集成事件则是向其他上下文或外部系统发布的已提交事实。实际实现方式可以不同,但跨进程传播尤其需要考虑提交与消息发送之间的故障窗口。Microsoft:Domain events
如果先提交退款记录、再发送消息:
数据库提交成功
→ 服务崩溃
→ 消息没有发出
下游可能永远不知道退款已经申请。
一种常见办法是事务 Outbox:在同一个本地事务中保存业务状态和待发布事件,再由后台发送。它不保证消息只投递一次,因此消费者仍需按稳定事件 ID 幂等处理。
支付渠道请求超时则要进入 UNKNOWN:先查询渠道、等待回调或进入对账;不能默认失败后生成一笔新的退款。这里的保障来自领域状态机、渠道契约和运行时恢复共同配合,而不是事件名称本身。
十、DDD 不等于微服务,也不等于大量目录
常见误解是:
“有 DDD,就必须拆成很多微服务。”
限界上下文是一种模型和语言的边界;微服务是一种部署和运行方式。二者可以对应,但不是同义词。
一个团队完全可以先构建模块化单体:
app/
ordering/
refunds/
payments/
accounting/
每个模块拥有清楚的职责、接口和数据访问规则。未来只有在独立扩展、团队自治、故障隔离或部署节奏确实需要时,再考虑将某个上下文拆成服务。
反过来,如果四个微服务都依赖同一张可随意修改的订单表,或者每次修改都需要四个团队同步上线,那么部署数量增加并不自动带来清晰的领域边界。
也不必为每个简单功能都创建:
entity → aggregate → factory → repository → domain service
如果这些对象没有表达业务规则,只是把字段从一层复制到另一层,它们可能只增加理解成本。
DDD 的核心判断是:模型是否让业务规则更清楚,让正确行为更容易发生,让错误行为更难发生。
十一、DDD、SDD 和 AI Coding 怎么结合?
DDD 与 SDD 解决不同的问题:
| 方法 | 主要关注 |
|---|---|
| DDD | 业务概念、语言、模型与边界如何组织 |
| SDD | 本次变更的预期、约束与验收如何表达并指导交付 |
| AI Coding | 如何使用 Agent 分析、实现和验证工作 |
可以把它们组合起来:
DDD:理解退款领域,定义上下文、语言和不变量
↓
SDD:为“增加部分退款”写目标、范围和验收条件
↓
AI Coding:分析仓库、提出实现方案、完成修改与检查
↓
团队验证:检查规则、并发、外部失败与用户可观察结果
例如,给 Agent 的任务可以这样描述:
请先阅读退款、支付和账务相关代码,识别已有的领域边界。
目标:增加部分退款能力。
请先输出:
1. 现有业务事实与代码证据。
2. 退款申请、渠道指令和账务记录之间的职责。
3. 已确认退款与处理中预留如何影响可退额度。
4. 并发请求和渠道超时可能导致的风险。
5. 可验证的验收标准,以及仍需业务确认的问题。
不要把推测写成已经实现的能力。
在业务规则未明确前,不要假定渠道超时等于失败。
Agent 可以帮助梳理模型、发现冲突和生成代码,但不能代替业务负责人决定退款资格,更不能把它生成的状态图当作现有系统的事实。
十二、如何验证模型真的保护了业务规则?
领域模型要经得起真实场景和失败路径,而不只是通过一次正常退款。
| 场景 | 应验证什么 |
|---|---|
| 已支付 100,申请退款 60 | 接受并占用 60 的额度 |
| 已占用 60,再申请 50 | 本例中应拒绝超过可退额度 |
| 同一退款 ID 重试,金额不变 | 不再次占用额度 |
| 同一退款 ID 重试,金额变化 | 明确冲突 |
| 请求超时,结果未知 | 不把额度立即释放 |
| 渠道确认失败 | 按可信结果与业务规则处理预留 |
| 两个实例并发退款 | 数据库并发控制仍能保护额度不变量 |
| 退款事件重复投递 | 下游不重复执行有效业务动作 |
| 事件延迟或乱序 | 状态机拒绝非法覆盖,并可对账 |
单元测试可以验证内存中的聚合行为;数据库集成测试和故障演练才能检查事务、并发和消息处理。真实渠道的语义则需要依据渠道文档与受控环境验证。
DDD 的价值不在于是否创建了名为 Aggregate 的类,而在于团队能否准确解释并验证这些规则。
十三、常见误区
1. 只有类图,没有业务讨论
模型中有 Order、Refund、Payment,但没有与业务人员讨论“申请已接受”和“退款已成功”的区别。这样的类图没有解决核心问题。
2. 统一一个巨大模型
为了“统一”,让订单、支付、退款和物流共享同一个对象。最终每个团队的变化都会牵动其他上下文。
3. 聚合太大
把一个用户的所有订单、支付、退款和账务流水都放进一个聚合,会增加加载成本和并发竞争。聚合边界应由必须同步保护的不变量驱动,而不是由对象之间“看起来相关”驱动。
4. 所有逻辑都塞进实体
数据库访问、发邮件和 HTTP 调用不应为了“领域驱动”而强行放进领域对象。模型需要表达业务规则,也需要与基础设施保持清晰关系。
5. 只谈最终一致,不谈恢复
事件异步传播后,下游可能延迟、重复或失败。没有幂等、监控、重试、补偿和对账,最终一致只是一个愿望。
6. 模型和代码长期脱节
业务规则已经改变,文档仍在描述旧状态。需要让领域语言、接口和测试随业务演进,并明确哪些资料是当前事实。
7. 简单问题过度建模
如果系统只是少量数据的后台维护,完整 DDD 模式可能不划算。先识别真实复杂度,再决定使用哪些工具。
结语
DDD 不是从创建领域目录开始,而是从理解业务中的差异和约束开始。
退款案例里,真正困难的部分并不是如何调用一个支付 API,而是区分申请与资金结果,定义可退额度,处理并发和未知状态,并让不同上下文对同一个事件保持清楚的理解。
当团队能够用共同语言回答:
谁拥有什么事实?哪些规则必须始终成立?出现失败时系统处于什么状态?谁负责让它恢复?
领域模型才开始产生价值。
参考资料
- Martin Fowler:Domain-Driven Design:DDD 的核心思路与适用领域。
- Martin Fowler:Bounded Context:模型与语言的边界。
- Microsoft:Domain analysis:领域分析与上下文关系。
- Microsoft:Tactical DDD:实体、值对象、聚合及领域事件。
- Microsoft:Domain events:领域事件与事务边界。
- Martin Fowler:Anemic Domain Model:缺少领域行为的模型问题。