InterviewPrepKit

Home / Cheat Sheet / Object-Oriented Design

Cheat sheet

How to design a grocery-store checkout

Read the full lesson →

Ten objects and two decisions: how money is represented, and the order discounts apply in. Get either wrong and the total is off by real money or cannot be explained line by line.

The one requirement

  • Output is a quote: subtotal, an itemised discount log (label + exact amount), tax, total.
  • Invariant that drives everything: subtotal minus listed discounts equals total, exactly, no tolerance. A total you cannot explain line by line is broken even when the number is right.
  • Stacking = applying more than one promotion to a cart. SKU = the store’s unique product code. Mill = one thousandth of a currency unit (a tenth of a cent), for sub-cent prices like fuel or weighed goods.

Money: integers, never float

  • Money = whole number of the smallest unit (cents), carried with a currency tag so different currencies cannot be added by accident.
  • Float has no exact binary form for 0.10 or 0.07, so the ledger stops summing. The error has no consistent direction, so no fudge factor fixes it.
    • Ten 0.10 adds to 0.9999999999999999; round(2.675, 2) == 2.67; int(1.15 * 100) == 114; a hundred 0.07 drifts UP to 7.000000000000009.
  • Decimal is the acceptable second answer: use only for genuinely sub-cent unit prices; slower, and still needs a rounding mode pinned down.
  • Rounding does not vanish, it becomes explicit. State one rule in one function (e.g. discounts round in the customer’s favour, tax half-up). 20% off $19.99 = 399.8c: floor gives $3.99 off, half-up gives $4.00. About half of discounted lines disagree by a cent.

Core objects (10)

ObjectResponsibilityMutability
CatalogSKU → Product lookup, versioned
ProductSKU, name, unit price, tax classimmutable (new record on price change)
LineItemproduct + quantitymutable
Cartthe lines, nothing else — no total()mutable
PricingEnginelines + promotions → total + discount logstateless
Promotionone rule, one stage (Strategy)
Discountlabel, amount, sourceimmutable
Quotesubtotal, discount log, total; self-validatingfrozen
Inventorystock levels, atomic reservationmutable
Orderfrozen snapshot: lines, discounts, tax, paymentsimmutable
  • Cart has no total: a total depends on promotions, membership, and the date, none of which the cart knows.
  • Cart vs Order is the common structural error: opposite mutability. Merge them and a printed receipt’s total can still change.

Decision 1: promotions are Strategies and do not commute

  • Discounts do not commute: 3 x $10 with PercentOff(20) + AmountOff(500) = $19.00 percentage-first vs $20.00 fixed-first. A percentage is worth whatever the running subtotal is when it fires; the fixed coupon is worth the same anywhere.
  • The bug is not the number, it’s that list order decided it and nobody chose deliberately.
  • Fix: give every promotion a stage (a number for its pricing phase); the engine sorts by stage before applying. One line: sorted(self.promotions, key=lambda p: p.stage.value).
  • Gaps not sequence (10, 20, 30, 40) so new stages insert without renumbering. Within a stage, pick a documented tiebreak (largest discount first).
  • Cost: rules spread across one class per rule + a stage table, and a promotion sees only the running subtotal, so “20% off items not already discounted” needs a richer context object.
Subtotal
  -> LINE (10)          per-item: buy-2-get-1, bundles, member unit price
  -> ORDER_PERCENT (20) % off the running subtotal
  -> ORDER_FIXED (30)   fixed-amount coupons, applied last
  -> LOYALTY (40)       points redemption
  -> Quote              subtotal, discount log, total

Quote: two invariants in the constructor

  • Reconciliation: subtotal − logged discounts == total, exactly.
  • Plausibility: 0 <= total <= subtotal. Reconciliation alone passes PercentOff(200) (total −3000, hands the shopper $30) or a negative surcharge; the bounds check refuses both.
  • Enforced in __post_init__ of a frozen object with a tuple log, so a broken quote cannot be constructed and cannot be mutated afterward.

Decision 2: inventory oversell race

  • Race condition: two registers read stock == 1 before either writes, both sell the last unit. Read-decide-write with a gap.
  • Fix: make check-and-decrement one indivisible step.
    • In memory: a threading.Lock around read + write.
    • At store scale: UPDATE stock SET qty = qty - 1 WHERE sku = ? AND qty >= 1, then check the affected row count (zero = refuse). The DB does it atomically.
  • A separate SELECT to display a number is fine, never a correctness mechanism.
  • Reframe: a supermarket does not prevent oversell at the till (goods are already in the cart); the real invariant is inventory accuracy. Reservation-before-payment belongs to online order-and-collect.

Extensions, priced in edits

  • Member pricing: apply(lines, running)apply(lines, running, ctx); changes every promotion class. Lesson: pass a frozen context object to a Strategy, never positional args.
  • Stacking: add an exclusivity group tag per promotion; two sharing a tag are mutually exclusive, best applies. Kept separate from stage (may they run together? vs which first?). Resolve within a single stage only (cross-stage is 2^n).
  • Returns: attribute order-level discounts down to lines so a returned apple refunds its post-discount price ($6.33, not the $10.00 sticker). Use largest remainder: floor each share, hand leftover cents to the biggest discarded fractions, deterministic tiebreak by index. Store the allocation at settle (prices/promos may change later). Raise if weights sum to zero.

Gotchas

  • Do not let Cart hold a total.
  • Do not let list order decide pricing; declare a Stage.
  • Do not make Catalog a Singleton; construct once and inject.
  • Tax goes on the post-discount total, per TaxClass field on Product (groceries often zero-rated), through the same integer path, never a buried if.
  • Assumed fixed and hard-coded: one currency (Cents = int), whole quantities, single-pass pricing, single-threaded pricing. Multiple currencies means a Money value object decided on day one.
Want the full picture? The lesson has the derivations, worked examples, and diagrams this card compresses into bullets. Read the full lesson →
Report a bug