Files
web_sport/docs/adr/0018-orders-snapshot-everything-they-display.md
T

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.