为何要在 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);
但在真实业务中,这项操作可能还需要:
- 检查订单是否已经进入拣货;
- 检查新增数量的库存;
- 按当前规则重新报价;
- 判断原促销是否仍然有效;
- 计算补款或退款;
- 更新配送承诺;
- 记录修改人和修改原因;
- 通知仓库或供应商。
这已经不是普通的购物车数量修改。
保存历史事实
购物车通常只关心当前内容,订单和 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
转换过程可以包含以下步骤:
- 校验商品配置是否完整;
- 读取并确认当前可售价格;
- 重新计算折扣、税费和运费;
- 检查或预留库存;
- 保存商品名称、配置、价格和配送承诺的快照;
- 创建具有独立 ID 的
Order; - 按支付或开票策略创建
Invoice; - 清空、关闭或保留原购物车。
创建 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,也不要让调用方将三者视为同一种可以任意修改的记录。以后拆分存储结构时,这层边界可以减少对业务代码的影响。
判断是否需要拆分,可以考虑三个问题:
- 结算后是否必须保留成交时的价格、配置和承诺?
- 确认后的修改是否需要经过库存、支付、履约或审计流程?
- 财务金额是否可能与当前订单内容不同,例如出现分批开票、退款或补款?
只要其中一个答案是“是”,将 cart、order 和 invoice 分开建模通常就有明显收益。
这样做并不是为了形式上的“领域驱动设计”,而是为了让购买意向、交易承诺和财务事实分别遵守各自的规则。确认后的订单仍然可以修改,但修改应是一项明确、可校验、可审计的业务操作,不能继续把订单当作一辆尚未结算的购物车。
备注:内容仅供参考。
