如何在 Domain-Driven Design 中维护跨聚合不变量?
结论
这三个规则都依赖同一笔 Payment 的额度,属于同一个一致性边界,应由以 Payment 为根的聚合强一致地执行。
Refund 可以有独立的业务流程,也可以作为单独的聚合,但它不能自行判断退款额度是否可用。创建或确认 Refund 前,必须先向对应的 Payment 聚合申请额度。Payment 聚合需要在同一个事务中检查并扣减总可退金额及各 payee 的可退金额。
不要让 Domain Service 同时读取 Payment 和 Refund 两个聚合,再分别写入。这种先检查再写入的方式存在并发竞争,无法真正维护不变量。领域事件适合通知后续流程,不适合单独承担不可超额的硬约束。
为什么这些规则属于 Payment 聚合
聚合边界取决于哪些状态必须在一次事务中保持一致,而不是对象叫什么,或者数据存在哪张表中。
这三个约束可以写成:
sum(paymentSplits) <= payment.totalAmount
sum(confirmedRefundSplits) + proposedRefundAmount
<= payment.totalAmount
refundedAmount(payee) + proposedRefundAmount(payee)
<= paidAmount(payee)
也可以改写为余额模型:
payment.refundableAmount
= payment.totalAmount - payment.totalRefundedAmount
payee.refundableAmount
= payee.paidAmount - payee.refundedAmount
批准退款时,需要同时修改:
- Payment 的累计退款金额;
- 对应 payee 的累计退款金额;
- 退款申请的幂等状态,如果业务需要记录。
这些数据必须基于同一个版本完成检查和更新,因此应放在同一个强一致聚合中。
通常需要维护的不是”单个 RefundSplit 不超过原 PaymentSplit”,而是:
某 payee 的所有有效 RefundSplit 累计值
不得超过该 payee 的 PaymentSplit 金额
否则,即使每笔退款单独看都合法,累计后仍可能超额。
推荐的聚合划分
模型可以分为两个边界。
Payment 聚合:管理资金分配和可退款余额
Payment 聚合负责:
- total payment amount;
- 各 payee 的已支付金额;
- total refunded amount;
- 各 payee 的已退款金额或已预留金额;
- 检查并预留退款额度;
- 释放失败或取消的退款预留;
- 确认退款完成。
概念结构如下:
Payment
├── totalAmount
├── totalRefundedAmount
├── totalReservedRefundAmount
└── PayeeAllocation[]
├── payeeId
├── paidAmount
├── refundedAmount
└── reservedRefundAmount
PaymentSplit 可以建模为 Payment 聚合内部的实体或值对象。也可以使用更准确的名字 PayeeAllocation,因为它表示 Payment 内部的额度分配,而不只是一条数据库明细。
Refund 聚合:管理退款流程
如果退款有独立的生命周期,例如:
Requested -> Reserved -> Submitted -> Succeeded
└──────> Failed
Refund 适合作为单独的聚合,负责:
- 退款单状态;
- 支付渠道请求编号;
- 渠道调用和重试;
- 审批信息;
- 失败原因;
- 退款拆分明细;
- 幂等处理。
不过,Refund 聚合中的 RefundSplit 记录的是退款意图或执行结果。额度仍归 Payment 聚合管理,Refund 不能只根据自己的数据判断是否允许退款。
退款处理步骤
建议采用预留、执行、确认的流程。
- 创建 Refund 请求和拆分方案。
- 使用
refundId向 Payment 聚合申请额度。 - Payment 聚合检查总可退额度及每个 payee 的可退额度。
- 检查通过后,Payment 在同一事务内记录退款预留。
- 事务提交后,再调用外部支付渠道。
- 渠道退款成功后,将预留转为已退款。
- 渠道退款失败或业务取消时,释放预留额度。
调用支付渠道之前,必须先持久化额度预留。数据库事务无法覆盖外部支付渠道,不能把网络调用当作普通的原子事务处理。
代码示例
以下示例省略了持久化和事件基础设施,只展示聚合如何维护不变量。
public final class Payment {
private final PaymentId id;
private final Money totalAmount;
private final Map<PayeeId, PayeeAllocation> allocations;
private final Map<RefundId, RefundReservation> reservations;
private Money totalRefundedAmount;
private Money totalReservedRefundAmount;
public void allocate(PayeeId payeeId, Money amount) {
requirePositive(amount);
Money allocatedAmount = allocations.values().stream()
.map(PayeeAllocation::paidAmount)
.reduce(Money.zero(totalAmount.currency()), Money::add);
if (allocatedAmount.add(amount).isGreaterThan(totalAmount)) {
throw new PaymentAllocationExceeded();
}
allocations.merge(
payeeId,
PayeeAllocation.create(payeeId, amount),
(current, ignored) -> current.increasePaidAmount(amount)
);
}
public void reserveRefund(
RefundId refundId,
Map<PayeeId, Money> requestedSplits
) {
if (reservations.containsKey(refundId)) {
return; // 幂等重试
}
Money requestedTotal = requestedSplits.values().stream()
.reduce(Money.zero(totalAmount.currency()), Money::add);
requirePositive(requestedTotal);
Money totalAvailable = totalAmount
.subtract(totalRefundedAmount)
.subtract(totalReservedRefundAmount);
if (requestedTotal.isGreaterThan(totalAvailable)) {
throw new RefundAmountExceeded();
}
for (Map.Entry<PayeeId, Money> entry : requestedSplits.entrySet()) {
PayeeAllocation allocation = allocations.get(entry.getKey());
if (allocation == null) {
throw new UnknownPayee(entry.getKey());
}
if (entry.getValue().isGreaterThan(allocation.refundableAmount())) {
throw new PayeeRefundAmountExceeded(entry.getKey());
}
}
for (Map.Entry<PayeeId, Money> entry : requestedSplits.entrySet()) {
allocations.get(entry.getKey())
.reserveRefund(entry.getValue());
}
reservations.put(
refundId,
RefundReservation.reserved(refundId, requestedSplits)
);
totalReservedRefundAmount =
totalReservedRefundAmount.add(requestedTotal);
}
public void confirmRefund(RefundId refundId) {
RefundReservation reservation = requireReserved(refundId);
for (Map.Entry<PayeeId, Money> entry
: reservation.splits().entrySet()) {
allocations.get(entry.getKey())
.confirmRefund(entry.getValue());
}
totalReservedRefundAmount =
totalReservedRefundAmount.subtract(reservation.totalAmount());
totalRefundedAmount =
totalRefundedAmount.add(reservation.totalAmount());
reservation.markConfirmed();
}
public void releaseRefund(RefundId refundId) {
RefundReservation reservation = requireReserved(refundId);
for (Map.Entry<PayeeId, Money> entry
: reservation.splits().entrySet()) {
allocations.get(entry.getKey())
.releaseRefund(entry.getValue());
}
totalReservedRefundAmount =
totalReservedRefundAmount.subtract(reservation.totalAmount());
reservation.markReleased();
}
}
Payee 级别的分配实体负责维护自己的局部约束:
public final class PayeeAllocation {
private final PayeeId payeeId;
private Money paidAmount;
private Money refundedAmount;
private Money reservedRefundAmount;
public Money refundableAmount() {
return paidAmount
.subtract(refundedAmount)
.subtract(reservedRefundAmount);
}
public void reserveRefund(Money amount) {
if (amount.isGreaterThan(refundableAmount())) {
throw new PayeeRefundAmountExceeded(payeeId);
}
reservedRefundAmount = reservedRefundAmount.add(amount);
}
public void confirmRefund(Money amount) {
if (amount.isGreaterThan(reservedRefundAmount)) {
throw new InvalidRefundReservation();
}
reservedRefundAmount = reservedRefundAmount.subtract(amount);
refundedAmount = refundedAmount.add(amount);
}
public void releaseRefund(Money amount) {
if (amount.isGreaterThan(reservedRefundAmount)) {
throw new InvalidRefundReservation();
}
reservedRefundAmount = reservedRefundAmount.subtract(amount);
}
}
应用服务负责协调两个聚合和外部支付渠道,但不应复制额度判断规则:
public void executeRefund(CreateRefundCommand command) {
Refund refund = Refund.request(
command.refundId(),
command.paymentId(),
command.splits()
);
refundRepository.save(refund);
Payment payment = paymentRepository.get(command.paymentId());
payment.reserveRefund(command.refundId(), command.splits());
paymentRepository.save(payment);
outbox.publish(new RefundReserved(command.refundId()));
}
后续处理器调用支付渠道,再根据结果执行 confirmRefund 或 releaseRefund。生产系统应使用 Outbox、持久化任务或等效机制,防止事务提交后消息丢失。
Domain Service 应该承担什么职责
Domain Service 可以计算没有自然实体归属的退款方案,例如:
RefundPlan plan = refundAllocationService.allocate(
requestedAmount,
payment.refundableAllocations(),
allocationPolicy
);
它还可以处理按比例退款、舍入差额或 payee 优先级,但是否允许占用额度,最终仍由下面的调用决定:
payment.reserveRefund(refundId, plan.splits());
Domain Service 本身不是一致性边界。即使它同时读取两个聚合并完成校验,只要提交时分别更新两个聚合,并发请求就可能读取到相同余额,并同时通过检查。
并发控制不可省略
只把逻辑放进 Payment 聚合还不够。Repository 必须阻止两个调用基于同一个旧版本同时提交。
常见做法是使用乐观锁:
UPDATE payment
SET total_reserved_refund_amount = ?,
version = version + 1
WHERE payment_id = ?
AND version = ?;
如果更新行数为 0,说明 Payment 已被其他事务修改。此时需要重新加载聚合,再次执行命令,第二个请求也可能因为余额不足而失败。
也可以使用数据库行锁,但锁的范围、等待时间和死锁风险需要根据实际吞吐量评估。无论采用哪种方式,额度检查和额度占用都必须在同一个事务中完成。
是否可以把 Payment 和 Refund 全部放进一个聚合
可以,但未必适合。
如果每笔 Payment 只有少量退款,而且退款记录必须与 Payment 在一次事务中完整创建,可以把 Refund 作为 Payment 的内部实体:
Payment
├── PaymentSplit[]
└── Refund[]
└── RefundSplit[]
这种设计很直接,所有规则都由 Payment 根执行。但退款次数较多、流程复杂,或者需要独立查询和重试时,聚合会持续膨胀,加载成本和并发冲突也会增加。
更实用的折中方式是:
- Payment 聚合只保存执行约束所需的余额、分配和预留;
- Refund 聚合保存完整的退款流程和审计信息;
- 两者通过
paymentId、refundId和领域事件关联; - 额度预留始终由 Payment 聚合原子完成。
聚合不必把所有历史明细都加载进内存。只要能可靠维护不变量,Payment 可以只保存累计金额、payee 余额和当前有效预留,完整流水则放在独立的明细模型或审计表中。
注意事项
Money必须包含币种,并使用整数最小货币单位或可靠的十进制定点类型,不能使用二进制浮点数。- 同一 Payment 下的 split 应使用相同币种。
- 需要明确
requested、reserved、succeeded、failed等状态中,哪些金额会占用可退款额度。 refundId应作为幂等键,防止消息重试或接口重试重复扣减额度。- 修改退款拆分时,不要直接覆盖旧预留,应先受控地释放旧预留,再重新预留。
- 如果 PaymentSplit 在退款发生后仍允许修改,需要增加一条规则:payee 的已支付金额不得低于其已退款金额与预留金额之和。
- 领域事件可以驱动 Refund 状态变更、通知、账务流水和读模型更新,但不能先放任额度超发,再依靠事件最终修正。
- 只有业务明确接受超额后的补偿处理时,才适合采用纯最终一致性。支付和退款额度通常不适合这一前提。
备注:内容仅供参考。
