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

3.6 KiB

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.