PAYATHON 2026

为何要在 domain model 中区分 shopping cart 与 order invoice?

支付小周

结论

应将 ShoppingCart、Order 和 Invoice 分开建模。它们虽然都包含商品、数量和金额,但记录的是不同阶段的业务事实:

  • ShoppingCart 表示用户的购买意向,可以频繁修改,也可以随时放弃。
  • Order 表示商家已经接受或正在处理的交易承诺,需要保留价格、商品配置和履约状态等历史快照。
  • Invoice 是财务凭证,用于记录应收、已收、退款和税务信息,通常不能随着购物车或履约操作任意变化。

是否在数据库中使用不同的 tables,只是实现层面的选择。三者需要分开的根本原因,是它们有不同的生命周期、不变量、权限和审计要求。

确认后的 order 即使允许增删或替换商品,也不会因此变回 cart。这说明订单领域需要支持明确的 Order Amendment,并在变更时重新执行定价、库存、配送和支付差额等规则。

ShoppingCart 与 Order 的区别

ShoppingCart 表达的是意向

购物车中的数据通常是临时的:

  • 商品可以自由添加、删除和修改数量;
  • 当前价格可能随促销、会员等级或地区变化;
  • 库存不足时可以提示用户,但不一定立即拒绝操作;
  • 商品配置可以暂时不完整;
  • 购物车可以过期、合并或被遗弃;
  • 一般不需要完整记录每一次修改。

所以,购物车可以采用相对宽松的约束。例如,用户可以先把暂时缺货的商品放进购物车,等到结算时再确认库存。

Order 表达的是交易承诺

订单创建后,系统通常需要明确回答这些问题:

  • 用户购买的是哪一种商品配置;
  • 成交时的单价、折扣、税费和运费是多少;
  • 当时承诺的配送时间是什么;
  • 库存是否已经锁定或扣减;
  • 已经支付多少,是否需要补款或退款;
  • 哪些商品已经拣货、发货、取消或退货;
  • 某次修改由谁在什么时间完成,修改原因是什么。

订单中的商品行不能只引用商品目录的“当前状态”,否则商品改名、调价或更换配置后,历史订单也会随之变化。

订单通常需要保存快照:

productId: SKU-1001
productName: 无线键盘
selectedOptions:
  layout: US
  color: black
unitPrice: 399.00
discount: 40.00
tax: 32.31
promisedShipDate: 2026-09-05

其中,productName、unitPrice 和 promisedShipDate 都是成交时已经确定的事实,不应被商品目录后续的变化覆盖。

Invoice 也不等同于 Order

订单描述“卖什么以及如何履约”,发票描述“应收多少钱以及如何入账”。两者经常不是一一对应的:

  • 一个 order 可能分批发货,并产生多张 invoice;
  • 多个 orders 可能按月汇总到一张 invoice;
  • 取消部分商品后,可能需要开具 credit note,而不是直接修改原 invoice;
  • 订单可以先确认,invoice 等到发货后再生成;
  • invoice 可能需要不可变的编号、税率、开票主体和账单地址;
  • payment、refund 与 invoice 的状态不一定和履约状态同步。

如果把“已购买的 cart”直接称为 invoice,很容易混淆交易、履约和财务这三个边界。

在非常简单的业务中,例如用户付款后立即获得数字商品,而且完全不需要税务发票,Order 和内部账单记录可以暂时使用相近的存储结构。但从领域语义看,仍不应将它们视为同一个对象。

分离后能获得什么

不变量更清晰

购物车可以允许:

quantity > availableStock

因为用户此时可能只是在表达购买意向。

确认订单时则可能要求:

quantity <= reservableStock

发票有另一套规则,例如:

invoiceTotal = lineTotal + tax + shipping - credit

如果把这些状态塞进同一个对象,方法内部很容易堆积大量判断:

if (status == CART) {
    // ...
} else if (status == PURCHASED) {
    // ...
} else if (status == PAID) {
    // ...
}

业务逐渐扩展后,“状态”会决定对象允许执行哪些操作。此时,一个对象实际上已经承载了多个生命周期,只是模型没有把它们明确表达出来。

降低误操作风险

如果 order 和 cart 共用完全相同的修改接口,下面的调用可能直接改动已付款订单的价格:

order.changeQuantity(lineId, 10);

但在真实业务中,这项操作可能还需要:

  1. 检查订单是否已经进入拣货;
  2. 检查新增数量的库存;
  3. 按当前规则重新报价;
  4. 判断原促销是否仍然有效;
  5. 计算补款或退款;
  6. 更新配送承诺;
  7. 记录修改人和修改原因;
  8. 通知仓库或供应商。

这已经不是普通的购物车数量修改。

保存历史事实

购物车通常只关心当前内容,订单和 invoice 还必须能够解释过去发生了什么。

复用同一条数据库记录,再把状态从 CART 改为 PURCHASED,在技术上不一定有错,但至少要解决这些问题:

  • 商品目录变化是否会影响历史数据;
  • 结算时使用的价格是否已经冻结;
  • 用户下单后继续购物时,如何创建新 cart;
  • 修改订单是否会破坏最初确认的内容;
  • invoice 是否能保持不可变;
  • 审计记录能否还原每一次变更。

一旦出现这些要求,单一 table 和单一对象通常会越来越难维护。

推荐的转换流程

结算不应只是设置一个布尔字段:

cart.setPurchased(true);

更合适的方式,是把结算建模为一次明确的转换:

ShoppingCart
    ↓ validate
    ↓ price
    ↓ reserve inventory
    ↓ capture customer and address snapshots
Order
    ↓ according to billing policy
Invoice

转换过程可以包含以下步骤:

  1. 校验商品配置是否完整;
  2. 读取并确认当前可售价格;
  3. 重新计算折扣、税费和运费;
  4. 检查或预留库存;
  5. 保存商品名称、配置、价格和配送承诺的快照;
  6. 创建具有独立 ID 的 Order;
  7. 按支付或开票策略创建 Invoice;
  8. 清空、关闭或保留原购物车。

创建 order 时复制必要数据并非无意义的重复,而是把“当前可变的信息”保存为“成交时的历史事实”。

如何避免重复代码

分开领域对象,不等于复制所有实现。可以复用值对象、计算组件和策略,不必强迫 cart 与 order 共用同一个生命周期。

复用值对象

public record Money(BigDecimal amount, Currency currency) {
    public Money add(Money other) {
        requireSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }

    public Money multiply(int quantity) {
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }

    private void requireSameCurrency(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
    }
}

public record ProductSelection(
    String productId,
    String sku,
    Map<String, String> options
) {}

Money 和 ProductSelection 可以同时用于购物车报价、订单价格快照和 invoice 计算。

复用定价服务

public interface PricingService {
    Quote quote(
        List<PricingItem> items,
        CustomerContext customer,
        ShippingAddress address
    );
}

ShoppingCart 可以调用它显示预估价格,结算服务也可以调用它生成最终报价。两者复用的是定价规则,不是对象状态。

订单创建后,通常应该保存 Quote 的结果。以后展示历史订单时,不能再次调用当前的定价服务来推算当时的成交金额。

分别表达商品行

购物车行和订单行虽然结构相似,职责却不相同:

public final class CartLine {
    private ProductSelection selection;
    private int quantity;

    public void changeQuantity(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        this.quantity = quantity;
    }
}
public final class OrderLine {
    private final String orderLineId;
    private final ProductSnapshot product;
    private final Money agreedUnitPrice;
    private int orderedQuantity;
    private int fulfilledQuantity;

    public void reduceUnfulfilledQuantity(int quantity) {
        int newQuantity = orderedQuantity - quantity;
        if (newQuantity < fulfilledQuantity) {
            throw new IllegalStateException("Fulfilled quantity cannot be removed");
        }
        orderedQuantity = newQuantity;
    }
}

字段相似,不代表行为相同。CartLine 主要记录用户的选择,OrderLine 还要维护成交价和履约进度。

确认后的订单如何修改

修改已确认的 order 是合理需求,但应该将其建模为订单变更,不能直接重新开放购物车接口。

一种直接的实现方式,是通过应用服务协调变更:

public final class OrderAmendmentService {
    private final InventoryService inventoryService;
    private final PricingService pricingService;
    private final PaymentService paymentService;

    public AmendmentResult replaceItem(
        Order order,
        String orderLineId,
        ProductSelection replacement,
        UserId operator,
        String reason
    ) {
        order.assertAmendable();

        Availability availability =
            inventoryService.check(replacement, 1);

        if (!availability.isAvailable()) {
            throw new IllegalStateException("Replacement is unavailable");
        }

        Quote quote = pricingService.quoteReplacement(order, orderLineId, replacement);

        OrderAmendment amendment = order.replaceUnfulfilledItem(
            orderLineId,
            replacement,
            quote,
            operator,
            reason
        );

        Money difference = amendment.priceDifference();

        if (difference.amount().signum() > 0) {
            paymentService.requestAdditionalPayment(order.id(), difference);
        } else if (difference.amount().signum() < 0) {
            paymentService.refund(order.id(), difference.multiply(-1));
        }

        return AmendmentResult.from(amendment);
    }
}

具体采用哪一种类结构并不是重点。重要的是,订单修改必须经过订单自身的约束。

常见规则包括:

  • 已发货的商品不能直接删除;
  • 已拣货商品可能需要先撤销仓库任务;
  • 替换商品需要重新检查库存;
  • 新增商品可能使用当前价格,而不是原下单价格;
  • 修改后的配送时间需要重新承诺;
  • 已开具的 invoice 不能直接覆盖,应补开 invoice 或 credit note;
  • 价格变化需要补款或退款;
  • 每次修改都应留下审计记录。

使用 Amendment 保存变更历史

审计要求较高的系统可以明确记录订单修订:

public record OrderAmendment(
    String amendmentId,
    String orderId,
    Instant occurredAt,
    UserId operator,
    String reason,
    List<OrderChange> changes,
    Money priceDifference
) {}

订单可以保存最新状态,方便日常查询,同时通过 OrderAmendment 保留完整的变化过程。

例如:

Order confirmed: 2 × SKU-A
Amendment #1: remove 1 × SKU-A
Amendment #2: add 1 × SKU-B
Invoice #1: original amount
Credit note #1: removed SKU-A
Invoice #2: added SKU-B

这种方式既支持下单后修改,也不会丢失最初确认的内容和后续财务轨迹。

并非每个系统都需要实现完整的 event sourcing。普通的订单表、订单行表和变更记录表,已经能够满足许多场景。

数据库必须完全分开吗

不一定。领域模型和关系数据库结构不需要一一对应。

可选方案包括:

独立 tables

shopping_carts
shopping_cart_lines
orders
order_lines
invoices
invoice_lines
order_amendments

这种结构约束清楚、查询直观,也不容易意外混用数据,对中大型系统通常更稳妥。

共用部分底层结构

商品选择、地址或金额明细可以共享持久化组件,但代码层仍应提供不同的 repository 和 domain object。

这种方案需要格外注意。底层共表,不代表上层也应该暴露一个能处理所有状态的通用对象。

单表加类型字段

简单系统也可以采用:

documents
document_lines

然后通过 document_type 区分 CART、ORDER 和 INVOICE。这种设计本身没有问题,但数据库很难分别表达不同类型要求的非空字段、唯一约束和生命周期规则。类型越来越多后,表中通常会出现大量只对部分状态有效的列。

是否拆表,应根据数据规模、查询方式和迁移成本决定。是否拆分领域概念,则取决于业务语义。这是两个不同的问题。

容易踩的坑

不要让 Order 继承 ShoppingCart

下面的继承看似可以复用代码,但语义通常不成立:

class Order extends ShoppingCart {
}

Order 不是一种特殊的 ShoppingCart。订单无法安全地支持购物车允许的全部行为,因此不符合可替换性。

更合适的方式是组合共享值对象,或者由结算服务将 ShoppingCart 转换为 Order。

不要把商品目录当作历史订单

订单应该保留关键快照,同时保存 productId 或 sku 以便追踪。如果只保留外键,并在查询时动态读取商品当前的名称、价格和选项,历史记录就会失真。

不要直接修改已开具的 Invoice

invoice 一旦成为正式财务凭证,订单变化通常应该通过补充 invoice、credit note 或退款记录来反映。具体处理方式取决于所在地区的税务和会计要求,不能只由通用领域模型决定。

不要为了“零重复”合并不同规则

少量结构相似并不可怕。为了复用几行增删代码,让购物车、订单和 invoice 共用一个充满状态判断的对象,反而更危险。

适合复用的是稳定的业务能力:

  • Money
  • 商品选项定义
  • 税费计算器
  • 定价策略
  • 库存查询
  • 地址校验
  • 配送报价

具有不同约束的状态转换则不应强行复用。

实际取舍

极小型系统可以先使用较简单的实现,但至少应在接口层区分三种对象:

interface CartRepository {
    ShoppingCart find(CartId id);
}

interface OrderRepository {
    Order find(OrderId id);
}

interface InvoiceRepository {
    Invoice find(InvoiceId id);
}

即使暂时共用 tables,也不要让调用方将三者视为同一种可以任意修改的记录。以后拆分存储结构时,这层边界可以减少对业务代码的影响。

判断是否需要拆分,可以考虑三个问题:

  1. 结算后是否必须保留成交时的价格、配置和承诺?
  2. 确认后的修改是否需要经过库存、支付、履约或审计流程?
  3. 财务金额是否可能与当前订单内容不同,例如出现分批开票、退款或补款?

只要其中一个答案是“是”,将 cart、order 和 invoice 分开建模通常就有明显收益。

这样做并不是为了形式上的“领域驱动设计”,而是为了让购买意向、交易承诺和财务事实分别遵守各自的规则。确认后的订单仍然可以修改,但修改应是一项明确、可校验、可审计的业务操作,不能继续把订单当作一辆尚未结算的购物车。

备注:内容仅供参考。