PAYATHON 2026

如何在 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 不能只根据自己的数据判断是否允许退款。

退款处理步骤

建议采用预留、执行、确认的流程。

  1. 创建 Refund 请求和拆分方案。
  2. 使用 refundId 向 Payment 聚合申请额度。
  3. Payment 聚合检查总可退额度及每个 payee 的可退额度。
  4. 检查通过后,Payment 在同一事务内记录退款预留。
  5. 事务提交后,再调用外部支付渠道。
  6. 渠道退款成功后,将预留转为已退款。
  7. 渠道退款失败或业务取消时,释放预留额度。

调用支付渠道之前,必须先持久化额度预留。数据库事务无法覆盖外部支付渠道,不能把网络调用当作普通的原子事务处理。

代码示例

以下示例省略了持久化和事件基础设施,只展示聚合如何维护不变量。

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 状态变更、通知、账务流水和读模型更新,但不能先放任额度超发,再依靠事件最终修正。
  • 只有业务明确接受超额后的补偿处理时,才适合采用纯最终一致性。支付和退款额度通常不适合这一前提。

备注:内容仅供参考。