← 返回文章列表

系统设计 / 2026-10-30

DDD 开发实战:从业务语言到领域模型,如何设计一个可靠的退款系统

2026-10-30约 18 分钟阅读

使用退款案例讲解限界上下文、聚合、不变量、事件与领域建模实践。

日期: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

不变量是:

已确认退款 + 处理中预留 ≤ 支付总额

为什么要把“处理中预留”算进去?

如果只计算已完成退款:

  1. 支付金额为 100。
  2. 请求 A 申请退款 80,尚未完成。
  3. 请求 B 同时申请退款 80。
  4. 两者都看到“已退款为 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

这里有几个值得注意的设计选择:

  1. 同一个退款 ID、相同金额的重复请求不再次占用额度。
  2. 同一个退款 ID 对应不同金额时拒绝,不能悄悄把重试变成新请求。
  3. UNKNOWN 仍占用额度,因为渠道可能已经受理。
  4. FAILED 释放本例的预留额度;真实业务中必须以可靠失败判定和具体规则为前提。
  5. 已确认的最终状态不能被另一个冲突结果直接覆盖,应进入异常处理或对账。

实际系统还需处理对象持久化、原子提交、重复回调、时间戳、失败原因、渠道映射,以及受权限保护的业务操作。退款失败是否允许重新申请,也需要通过新的业务意图和规则明确。


七、领域服务与应用服务:谁负责什么?

不是所有业务规则都自然属于某个实体方法。

例如,退款资格可能需要结合:

  • 订单是否处于允许退款的状态。
  • 商品是否属于可退款类别。
  • 订单是否超过业务规定的申请期限。
  • 用户身份和退款发起人的权限。

如果规则需要多个领域概念,可以设计领域服务来表达这一判断,例如 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,而是区分申请与资金结果,定义可退额度,处理并发和未知状态,并让不同上下文对同一个事件保持清楚的理解。

当团队能够用共同语言回答:

谁拥有什么事实?哪些规则必须始终成立?出现失败时系统处于什么状态?谁负责让它恢复?

领域模型才开始产生价值。

参考资料

延伸阅读:应用 SDD 研发模式;金融系统为什么要分库分表?;理解 Agent Loop。

KEEP READING

继续阅读.

Agent 与 AI · 2026-10-30Multi-Agent 原理与应用:从协作模式到生产系统设计研发实践 · 2026-10-29应用 SDD 研发模式:让需求、AI 实现和交付结果保持一致Agent 与 AI · 2026-10-29深入 RAG:知识接入、索引、召回、重排、上下文组装与效果评测