# 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.