是否应为退款边界情况复杂化 aggregate root?
结论
建议继续把 Payment 作为独立的 aggregate root,并由原始 Payment 管理“可退款额度”。退款仍是独立的 Payment aggregate,授权、结算、开票等生命周期无需放进庞大的 PaymentGroup。
可以把“创建退款”分成两个职责:
- 原始 Payment 以强一致方式检查并预留退款额度。
- 预留成功后,创建独立的退款 Payment。
这样既能在单个 aggregate 内保证退款总额约束,也不必让普通支付和退款操作都经过 PaymentGroup。
为什么不建议直接使用 PaymentGroup
aggregate 的边界应由必须保持强一致的 invariant 决定。但存在关联约束,并不等于相关对象的完整生命周期都要放进同一个 aggregate。
这里需要强一致保证的是:
已预留的退款金额 + 新退款金额 <= 原始支付金额
PaymentAuthorized、PaymentSettled、PaymentInvoiced 等状态转换通常只影响某一笔支付。如果为了一个频率相对较低的退款额度约束,让所有操作都加载整个支付组,会产生不少额外成本:
- aggregate 会随退款数量不断增大。
- 每笔退款的状态变化都会竞争同一个 event stream。
- 互不相关的退款也会发生并发冲突。
- 外部回调需要额外定位 PaymentGroup。
- aggregate 的接口会逐渐围绕容器设计,而不是围绕实际业务操作设计。
除非大部分业务操作都要求原始支付和所有退款原子更新,否则 PaymentGroup 通常太大。
推荐的模型
可以让原始 Payment 成为退款额度的唯一所有者,但不负责退款的完整生命周期。
原始 Payment 只需记录以下信息:
originalAmount
reservedRefundAmount
completedRefundAmount
refundReservations
收到退款请求后,命令必须发送给原始 Payment:
ReserveRefund(originalPaymentId, refundId, amount)
原始 Payment 检查额度,然后产生事件:
RefundReserved(originalPaymentId, refundId, amount)
事件处理器或 process manager 再创建独立的退款 aggregate:
CreateRefund(refundId, originalPaymentId, amount)
退款创建后,可以像普通 Payment 一样独立处理授权、结算和开票:
RefundCreated
PaymentAuthorized
PaymentSettled
PaymentInvoiced
外部系统回调仍可通过 externalId -> paymentId 投影直接找到对应退款,无需加载原始 Payment 或 PaymentGroup。
示例代码
下面是简化后的伪代码。金额使用正数表示需要退还的绝对金额,以免在额度计算中混用正负数。
final class Payment {
private Money settledAmount;
private Money reservedRefundAmount;
private Set<RefundId> refundIds;
public List<Event> reserveRefund(
RefundId refundId,
Money amount
) {
if (refundIds.contains(refundId)) {
return List.of(); // 幂等重试
}
if (!amount.isPositive()) {
throw new DomainException("Refund amount must be positive");
}
if (!amount.currency().equals(settledAmount.currency())) {
throw new DomainException("Currency mismatch");
}
Money available = settledAmount.subtract(reservedRefundAmount);
if (amount.isGreaterThan(available)) {
throw new DomainException("Refund exceeds available amount");
}
return List.of(new RefundReserved(
id,
refundId,
amount
));
}
private void apply(RefundReserved event) {
refundIds.add(event.refundId());
reservedRefundAmount =
reservedRefundAmount.add(event.amount());
}
}
事件处理器负责创建退款 aggregate:
final class RefundReservationHandler {
private final RefundRepository refunds;
public void on(RefundReserved event) {
if (refunds.exists(event.refundId())) {
return;
}
Refund refund = Refund.create(
event.refundId(),
event.originalPaymentId(),
event.amount().negate()
);
refunds.save(refund);
}
}
如果退款无法创建或已明确取消,需要释放预留额度:
public List<Event> releaseRefund(
RefundId refundId,
Money amount
) {
if (!refundIds.contains(refundId)) {
return List.of();
}
return List.of(new RefundReservationReleased(
id,
refundId,
amount
));
}
实际实现时,不能只根据调用方传入的 amount 释放额度。原始 Payment 应保存每个 refundId 对应的预留金额,防止释放错误的金额。
如何处理并发退款
假设原始 Payment 金额为 100,同时收到两笔金额为 80 的退款请求。两条命令都必须基于同一个 Payment stream 的版本提交。
例如,两条命令都读取到版本 12:
Command A: reserve 80, append with expectedVersion = 12
Command B: reserve 80, append with expectedVersion = 12
一条命令提交成功后,stream 版本变为 13。另一条命令会因 optimistic concurrency control 失败。重新加载 aggregate 后,可退款额度只剩 20,因此这笔退款会被拒绝。
这种方式无需悲观锁,也不用查询可能存在延迟的退款列表投影,就能保证额度约束。
不要采用以下校验方式:
查询所有退款的 read model
→ 计算退款总额
→ 创建新退款
read model 通常是最终一致的。并发请求可能同时读到旧数据,进而产生超额退款。
跨 aggregate 的一致性怎么处理
RefundReserved 和退款 aggregate 的创建通常无法在同一个 aggregate transaction 中完成,因此需要可靠的消息机制,例如:
- transactional outbox;
- event store subscription;
- 支持至少一次投递的 process manager。
消费者必须通过 refundId 保证幂等。即使消息被重复投递,也不能创建第二笔退款。
同时还要明确不同情况下的失败语义:
- 暂时创建失败:保留额度并重试。
- 业务上取消退款:释放额度。
- 退款结算失败但允许重试:通常继续占用额度。
- 退款永久失败:根据业务规则释放额度。
- 退款已经对外执行成功:即使内部后续处理失败,也不能随意释放额度。
预留额度不是缓存,而是原始 Payment aggregate 的业务状态。
是否可以在一个事务中写两个 aggregate
“一个事务只能修改一个 aggregate”是一项有用的设计原则,但不是绝对的技术限制。
如果 event store 支持原子追加多个 stream,可以同时:
- 在原始 Payment stream 中记录额度占用;
- 在 Refund stream 中记录退款创建。
不过,这会增加 aggregate 之间的事务耦合,也可能限制系统的分区和扩展方式。除非系统要求退款必须在同步请求中原子创建,否则“额度预留 + 可靠异步创建”通常更灵活。
如果业务要求退款请求立即返回一个已成功创建的退款对象,也可以先生成 refundId,完成额度预留后立即返回“已接受”,再异步推进退款生命周期。
什么时候应该选择 PaymentGroup
如果有很多强一致规则需要同时读取或修改原始支付和退款,例如:
- 任一退款处于处理中时,原始 Payment 禁止执行某些状态转换;
- 原始支付关闭时必须原子取消全部退款;
- 每次结算都要同步更新整个支付组的状态;
- 退款之间存在复杂且频繁的顺序约束;
- 支付组规模有严格上限,不会无限增长;
此时,PaymentGroup 才可能是合适的 consistency boundary。
即便如此,仍要评估 event stream 的大小、并发热点和外部 ID 的定位成本。建立 externalId -> paymentGroupId 索引投影可以解决查找问题,但无法消除所有退款共享同一个 stream 带来的写竞争。
注意事项
首先要明确“退款总额”包含哪些状态。通常,已申请、处理中和已完成的退款都应占用额度,不能只统计已结算退款,否则多笔并发退款仍可能超过原始支付金额。
其次,建议在领域内部用正数表示 refundAmount,通过交易类型区分 PAYMENT 和 REFUND。只有生成会计分录或资金流水时,才确定借贷方向或正负号。单纯依靠负数区分退款,容易让比较、求和和校验代码出现符号错误。
最后,需要明确原始支付金额、可退款基数和币种。手续费、汇率变化、部分捕获、撤销、拒付和退款通常属于不同的业务概念,不能默认使用完全相同的额度规则。
更合适的边界是让原始 Payment 负责退款额度这一项 invariant,由退款 Payment 管理自身生命周期。这样既能保留强一致约束,也不会让 PaymentGroup 增加大多数日常操作的复杂度。
备注:内容仅供参考。
