79 lines
3.6 KiB
Markdown
79 lines
3.6 KiB
Markdown
# ADR-0018: Orders snapshot everything they display
|
|
|
|
- **Status:** Accepted
|
|
- **Date:** 2026-08-12
|
|
|
|
## Context
|
|
|
|
An order line points at a variant. The obvious implementation renders the order
|
|
by joining through that pointer: read the variant, read its product, show the
|
|
name and the price.
|
|
|
|
That works until the catalog moves, which it does constantly. In this system
|
|
alone, a merchandiser can rename a product, reprice a variant, archive a
|
|
colourway, retire an option value or unpublish the whole product — and ADR-0016
|
|
guarantees the variant row survives precisely so those links do not break. But
|
|
surviving is not the same as being unchanged. An order rendered by join shows
|
|
today's name at today's price for something bought last March.
|
|
|
|
That is not a display bug. It is a receipt that disagrees with what the customer
|
|
paid, which is a dispute, a refund calculation and possibly a legal record.
|
|
|
|
## Decision
|
|
|
|
**An order stores its own copy of everything it displays.** `OrderLine` carries
|
|
`productName`, `variantTitle`, `sku`, `imageUrl`, `unitAmount`, `quantity` and
|
|
`lineAmount`. `Order` carries the full shipping address as columns rather than a
|
|
foreign key into the customer's address book, plus every money component:
|
|
subtotal, discount, shipping, tax and total.
|
|
|
|
**`OrderLine.variantId` is kept, and is `onDelete: SetNull`.** It exists for
|
|
reporting, returns and restocking — never for rendering. Losing the reporting
|
|
link is survivable; losing the order line is not.
|
|
|
|
**The snapshot is taken in the shopper's language.** The cart composes a variant
|
|
title from translated option values, and that composed string is what gets
|
|
frozen. An order is a record of what was agreed, and what was agreed was shown
|
|
in Vietnamese or English.
|
|
|
|
**Money components are stored even when zero.** `discountAmount` and
|
|
`shippingAmount` are 0 until M7 and M9, but they are columns rather than absent
|
|
fields, so a total is always the sum of parts someone can name.
|
|
|
|
## Consequences
|
|
|
|
An order stays readable and correct forever, through any catalog change. Reading
|
|
one touches two tables and no catalog joins, which also makes the order history
|
|
cheap.
|
|
|
|
Duplication is real: a product name lives once in the catalog and once per order
|
|
line that ever contained it. That is the point — they are different facts that
|
|
happen to share a value today.
|
|
|
|
Corrections become explicit. Fixing a typo in a product name does not
|
|
retroactively edit anyone's receipt, and if an order genuinely needs amending,
|
|
that has to be a deliberate operation with its own audit entry rather than a
|
|
silent side effect of a catalog edit.
|
|
|
|
Analytics that groups by product must group on `variantId`, not on the snapshot
|
|
name, or a renamed product will appear as two products.
|
|
|
|
## Alternatives considered
|
|
|
|
**Join to the catalog at read time.** Simplest, no duplication, and wrong for
|
|
the reason above. Rejected.
|
|
|
|
**Snapshot into a JSON blob.** Fewer columns, and gives up every guarantee the
|
|
database offers — no types, no constraints, no indexing, and a schema that
|
|
drifts silently. Rejected; the fields are known and stable.
|
|
|
|
**Version the catalog and point at a version.** Fully correct and considerably
|
|
more machinery: every product write creates an immutable revision, and every
|
|
read has to resolve one. Worth it for a system where catalog history is itself a
|
|
product. Here it would be a large amount of infrastructure to avoid copying
|
|
seven columns.
|
|
|
|
**Snapshot only price, join the rest.** The hybrid is the worst option: it
|
|
implies the other fields are safe to join when they are not, and the day someone
|
|
renames a product the receipts change without anyone noticing.
|