In this lesson, we’ll design the checkout system for a grocery store as an object-oriented design problem: we name the classes, say what each is responsible for, and defend the boundaries between them. By the end you’ll be able to represent money so a receipt reconciles to the cent, decide the order discounts apply in, and defend both choices out loud in an interview.
The objects are the easy part. The weight sits on two decisions: how money is represented, and how the order of discount application is decided. Get those wrong and the total is off by real money, or it cannot be explained at all.
What the system takes and produces
Input. A list of scanned items. Each is a SKU (stock keeping unit, the store’s unique product code, the thing the barcode encodes) together with a quantity. Plus a few facts about the shopper: whether they are a loyalty member, and which coupons they hold.
Output. A quote: a subtotal, an itemised list of every discount with a human-readable label and an exact amount, the tax, and a final total.
The one property that output must have is that subtotal minus the listed discounts equals the total, exactly, with no tolerance. A checkout whose total cannot be explained line by line is broken even when the number is right. Everything else in the design follows from that requirement.
Two terms recur throughout:
- Stacking means applying more than one promotion to the same purchase (two coupons on one cart, or a member discount on top of a sale price). Allowing it needs a rule for which combinations are legal, separate from the rule for what order they run in.
- A mill is a thousandth of a currency unit, one tenth of a cent. It is used for prices genuinely finer than a cent, such as fuel or goods sold by weight.
The Strategy pattern (one interface, many interchangeable implementations chosen at run time) is used throughout for promotions; the OOP fundamentals chapter develops it with its costs, and the elevator chapter applies the same pattern to dispatch.
Decisions that change the object model
Some product questions change the classes you draw; most do not. The five below each force a different design.
| Decision | Why it changes the model |
|---|---|
| Promotions store-wide, or per-item? | Per-item promotions apply to one receipt line; order-level promotions apply to the whole subtotal. Mixing both into one interface creates the ordering bug below |
| Can promotions stack? | If yes, you need explicit precedence and an exclusivity rule. If no, you need a “best price wins” evaluator: a different algorithm (try all, keep the cheapest) |
| Weighed items? | Bananas at $2.99/kg mean quantity is not a whole number, and unit_price * qty stops being exact. Price per gram in integer mills, or price the weighed line at scan time and store the resulting cents |
| Register, or whole store? | The register is cart plus pricing plus payment. The store adds inventory, replenishment, and the oversell race |
| Returns? | Returns force per-line discount attribution, which forces an allocation function. Adding it later is a schema change, not a code change |
“Do promotions stack, and in what order?” is the one that matters most. It sounds like a product detail, but it is really the core of the object model.
Actors, and what each pulls into the design
Shopper scans items, sees a running total, pays
Cashier voids a line, applies a manual override, opens the drawer
Manager creates a promotion effective Fri-Sun, sets member pricing
System decrements inventory on sale, flags oversell, closes the till
Auditor asks why receipt #4471 charged $19.00 and not $20.00
Each actor drags one requirement into the model:
- Shopper needs a running total before payment, so pricing must be cheap enough to re-run on every scan.
- Cashier voids lines and applies overrides, so the cart must stay editable up to the moment of payment.
- Manager creates promotions with effective dates, so a promotion is stored data, not deployed code.
- System decrements inventory and closes the till. It is the only actor that touches shared state, and so the only one with a concurrency problem.
- Auditor needs any total explained line by line. That forces the discount log to be a real object, not a running integer that gets decremented and forgotten. A store that cannot answer “why did this say $19.00?” cannot settle a dispute, pass an audit, or process a return.
Money is the first design decision
The auditor’s requirement (a ledger that sums exactly) settles the first decision before any class is drawn: money is a whole number of the smallest currency unit (cents for the dollar, whole yen for the yen) carried with a currency tag so amounts in different currencies cannot be added by accident.
A floating-point number stores a value with a decimal point as a fixed number of binary digits. Most decimal fractions, including 0.10 and 0.07, have no exact binary representation, so what gets stored is the nearest value that does. Four failures follow directly, and each of these assertions passes:
total = 0.0
for _ in range(10): # ten 10-cent items
total += 0.10
assert repr(total) == "0.9999999999999999" # ten dimes are not a dollar
# Rounding at the end does not save you: the stored value of 2.675 is
# already below true 2.675, so round() correctly rounds down.
assert round(2.675, 2) == 2.67
# The dollars-to-cents conversion silently loses a cent: 1.15 * 100 is
# stored just below 115, and int() truncates toward zero.
assert int(1.15 * 100) == 114
s = 0.0
for _ in range(100): # a hundred 7-cent items
s += 0.07
assert repr(s) == "7.000000000000009" # drifts UP, where case 1 drifted down
The last point is the killer: the error has no consistent direction, so no fudge factor corrects it. The fix removes the whole class of failure in one line, because integers are exact, associative, and orderable:
assert sum([10] * 10) == 100
The failure is not about magnitude. The difference between 0.9999999999999999 and 1.0 is a hundredth of a cent, which no customer notices. The failure is that the ledger no longer sums. A floating-point total turns “line items add up to the total” into a tolerance check, and at that point reconciliation can no longer tell a rounding artefact from a genuinely missing cent. Telling those apart is the entire job of reconciliation.
Decimal (Python’s base-ten arbitrary-precision type) is the acceptable second answer. Reach for it when unit prices are genuinely finer than a cent, such as fuel or deli goods sold by the gram. It is slower than integer arithmetic and still requires you to pin down a rounding mode, so it buys exactness only if you configure it.
Rounding is now a decision you make on purpose
Choosing integers does not make rounding disappear; it makes it explicit. Take 20% off a $19.99 item: the exact discount is 399.8 cents, not a whole number, so something has to give.
1999 * 20 // 100 = 399 floor: discount $3.99, customer pays $16.00
round-half-up = 400 discount $4.00, customer pays $15.99
(// is floor division: divide and round down.) Rounding the discount down rounds what the customer pays up. One cent, either way. The two policies disagree on about half of discounted lines, by one cent each, so the drift is roughly half a cent per line. For a store ringing a million discounted lines a year, that is on the order of $5,000 a year decided by a rounding mode nobody wrote down.
So state the rule (say, “discounts round in the customer’s favour, tax rounds half-up”) and put it in exactly one function. That makes it auditable, and makes changing it one edit.
Core objects, and why those
Ten objects. The column that matters is the third: knowing why Cart does not have a total() method.
| Object | Responsibility | Why not the obvious alternative |
|---|---|---|
Catalog | SKU → Product lookup | Not a dict on the register. A catalog is loaded, versioned, and swapped; the products in it outlive any one snapshot |
Product | SKU, name, unit price, tax class | Immutable. Prices change by creating a new record, because last Tuesday’s receipt must still reprice |
LineItem | product + quantity | Not “a list of Product repeated N times”. Quantity is needed for buy-2-get-1 and weighed goods |
Cart | The lines, nothing else | A cart that knows its own total is the mistake. Totals depend on promotions, membership, and the date |
PricingEngine | lines + promotions → total + discount log | The separate object is what lets you reprice a historical order for a return |
Promotion | one rule, one stage | Strategy: one interface, one class per rule |
Discount | label, amount, source | Exists so the receipt can be explained. Without it the total is an unauditable scalar |
Quote | subtotal, discount log, total | Frozen, and self-validating: a quote that does not reconcile cannot be constructed |
Inventory | stock levels, atomic reservation | The check and the decrement must be one step (see the oversell race below) |
Order | frozen snapshot: lines, discounts, tax, payments | A cart mutates; an order never does |
Immutable means the fields can never change after construction; anything holding a reference keeps seeing the same values. Mutable is the opposite. The design puts these objects deliberately on opposite sides of that line: Product, Quote, Discount, and Order are immutable; Cart and Inventory are not.
Making Cart and Order the same class is the most common structural error here. They have opposite mutability requirements: a cart exists to be edited, an order exists to be a permanent record. Merge them and you get a total that changes after the receipt has printed, because a promotion expired or a price updated in between.
Class diagram
The notation is UML (Unified Modeling Language, the standard set of symbols for class relationships). The feature that dominates is the fan of four arrows into Promotion at the bottom: that fan is the Strategy pattern.
classDiagram
class Catalog {
+find(sku) Product
}
class Product {
+str sku
+int unit_price
+TaxClass tax_class
}
class Cart {
+add(sku, qty)
+lines() List
}
class LineItem {
+int qty
+gross() int
}
class PricingEngine {
+price(lines) Quote
}
class Quote {
+int subtotal
+int total
}
class Discount {
+str label
+int amount
}
class Promotion {
<<interface>>
+Stage stage
+apply(lines, running) Discount
}
class PercentOff
class AmountOff
class BuyNGetOneFree
class Bundle
class Inventory {
+reserve(sku, qty) bool
}
class Order {
+settle(payment)
}
Catalog "1" o-- "0..*" Product : indexes, does not own
Cart "1" *-- "0..*" LineItem : lines die with the cart
LineItem "1" --> "1" Product : refers to
PricingEngine "1" o-- "0..*" Promotion : rule set
PricingEngine ..> Quote : produces
Quote "1" *-- "0..*" Discount : audit trail
Order "1" *-- "1" Quote : frozen at settle
Order "1" --> "1" Inventory : decrements
Promotion <|.. PercentOff
Promotion <|.. AmountOff
Promotion <|.. BuyNGetOneFree
Promotion <|.. Bundle
The links make different claims about ownership and lifetime:
- Composition (
*--, filled diamond at the owner): the part dies with the whole and nothing else may hold a lasting reference.Cart *-- LineItem(a “three apples” line means nothing outside its cart),Quote *-- Discount(the audit trail belongs to the quote),Order *-- Quote(one quote, frozen at payment, never recomputed). - Aggregation (
o--, hollow diamond): grouped but not owned.Catalog o-- ProductandPricingEngine o-- Promotion, because products and promotions outlive any one catalog or engine and are managed on their own schedule. - Association (
-->, plain reference): used, not owned.LineItem --> Productis whyProductmust be immutable, since many lines across many historical orders point at the same record.Order --> Inventorybecause inventory is store-wide. - Dependency (
..>, weakest): mentioned, not stored.PricingEngine ..> Quote(it produces a quote and holds nothing), which is what keeps the engine stateless and safe to share. <|..is realization: the four promotion classes implement thePromotioninterface.
Two things are absent on purpose, and their absence is the design. Cart has no total() method, because a total is a function of promotions, membership, and today’s date, none of which the cart knows. And there is no arrow from Cart to Promotion at all, because a cart is just a list of what the shopper picked up and has no opinion about what it costs.
Decision 1: promotions are Strategies, and they do not commute
Two operations commute when applying them in either order gives the same result, the way addition does. Discounts do not. That is the decision this problem turns on.
The easy half is one rule per class behind a shared interface (the Strategy pattern), so the code that applies promotions never needs to know which rule it holds. The hard half is that applying the same two promotions in a different order produces a different total.
Take three items at $10.00, a $30.00 subtotal, with two order-level promotions: 20% off (PercentOff(20)) and $5.00 off (AmountOff(500)). Every figure is in cents.
percent first: 3000 - (3000*20//100=600) = 2400, then 2400 - 500 = 1900
amount first: 3000 - 500 = 2500, then 2500 - (2500*20//100=500) = 2000
The mechanism: a percentage is worth whatever the running subtotal is when it fires. Applied first, 20% is computed on $30.00 and is worth $6.00; applied after the coupon, on $25.00, it is worth $5.00. The fixed coupon is worth $5.00 wherever it lands. So the same cart with the same two promotions is $19.00 or $20.00, decided by nothing but the order of a Python list.
Neither answer is “the bug”: both are policies real stores run. Percentage-before-fixed is customer-favourable; fixed-before-percentage is store-favourable. Adding a third promotion widens the gap instead of averaging it out. The defect is not the number that came out; it is that the order of a list decided the number, which means nobody decided it deliberately.
The fix: order is a declared property of the rule
Do not let list order decide anything. Give every promotion a stage (a number saying which phase of pricing it belongs to) and have the engine sort by stage before it applies anything.
flowchart TD
S["Subtotal"] --> L["LINE (10)<br/>per-item: buy-2-get-1, bundles, member unit price"]
L --> P["ORDER_PERCENT (20)<br/>percentage off the running subtotal"]
P --> F["ORDER_FIXED (30)<br/>fixed-amount coupons, applied last"]
F --> Y["LOYALTY (40)<br/>points redemption, after everything cash-like"]
Y --> Q["Quote<br/>subtotal, discount log, total"]
The numbers 10, 20, 30, 40 are gaps, not a sequence, so a new stage can be inserted later without renumbering. With the engine sorting by stage, the total no longer depends on the order in which a manager happened to create the promotions. Within a single stage the order is still undecided, so pick a documented tiebreak (largest discount first is usual) and write it down.
What Strategy buys is that adding a new promotion type is one new class plus a stage assignment; PricingEngine and every existing promotion are untouched. That matters because new promotion types arrive continuously and a pricing engine is the last thing anyone wants to redeploy.
What it costs is two things. The store’s pricing rules are no longer readable in one file; they are spread across one class per rule plus a stage table. And a promotion cannot see what any other promotion did, except through the running subtotal it is handed. That limit bites on a rule as ordinary as “20% off, but not on already-discounted items”, which needs per-line state the (lines, running) arguments do not carry. Fixing it means passing a richer context object, priced in the extensions below.
Working Python: the pricing core
This is the whole pricing engine. It keeps a deliberately buggy price_in_list_order alongside the correct engine so the two behaviours can be asserted against each other, not merely described.
A few Python constructs carry the design:
@dataclassgenerates the constructor, equality, and string representation from the declared fields.@dataclass(frozen=True)additionally makes the object immutable, which is howProduct,Discount, andQuoteenforce immutability.ABCand@abstractmethodmakePromotionimpossible to instantiate directly and force every subclass to supply anapply.__init_subclass__runs as each subclass is defined.Promotionuses it to check the subclass actually set astage, becausestage: Stagein the base is a bare annotation binding no value; without the check, a promotion that forgot its stage would fail at the till instead of at deploy.@propertyletsline.grossread like a field while being computed.EnumgivesStage.ORDER_PERCENTa readable name.Cents = intis a type alias, pure documentation, so every money signature says so.
Data objects
The class to read closely is Quote, whose __post_init__ refuses to construct a quote whose discounts do not add up: the auditor’s requirement turned into code that cannot be bypassed.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Sequence, Tuple
Cents = int
class Stage(Enum):
LINE = 10
ORDER_PERCENT = 20
ORDER_FIXED = 30
@dataclass(frozen=True)
class Product:
sku: str
name: str
unit_price: Cents
@dataclass
class LineItem:
product: Product
qty: int
@property
def gross(self) -> Cents:
return self.product.unit_price * self.qty
@dataclass(frozen=True)
class Discount:
label: str
amount: Cents # positive = money off
@dataclass(frozen=True)
class Quote:
subtotal: Cents
discounts: Tuple[Discount, ...] # a tuple, so the log cannot be edited
total: Cents
def __post_init__(self) -> None:
if self.subtotal - sum(d.amount for d in self.discounts) != self.total:
raise ValueError("quote does not reconcile")
if not 0 <= self.total <= self.subtotal:
raise ValueError(f"total {self.total} outside [0, {self.subtotal}]")
Promotions
Promotion is the interface; the three classes after it are the Strategy implementations. Each has a stage, a label, and an apply returning either a Discount or None. No promotion knows about any other, and none knows when it will run. That is the whole point of the pattern.
class Promotion(ABC):
"""One pricing rule. `stage` fixes when it runs, independent of list order."""
stage: Stage
label: str
def __init_subclass__(cls, **kw):
super().__init_subclass__(**kw)
if not isinstance(getattr(cls, "stage", None), Stage):
raise TypeError(f"{cls.__name__} must declare a Stage; "
"`stage: Stage` is an annotation, not a promise")
@abstractmethod
def apply(self, lines: Sequence[LineItem], running: Cents) -> Optional[Discount]:
...
@dataclass
class PercentOff(Promotion):
pct: int
label: str = "percent off order"
stage: Stage = Stage.ORDER_PERCENT
def apply(self, lines, running):
off = running * self.pct // 100 # floor: discount rounds DOWN here;
return Discount(self.label, off) if off else None # pick a direction, document it
@dataclass
class AmountOff(Promotion):
amount: Cents
label: str = "amount off order"
stage: Stage = Stage.ORDER_FIXED
def apply(self, lines, running):
off = min(self.amount, running) # never make the total negative
return Discount(self.label, off) if off else None
@dataclass
class BuyNGetOneFree(Promotion):
sku: str
n: int = 2
label: str = "buy 2 get 1 free"
stage: Stage = Stage.LINE
def apply(self, lines, running):
rows = [l for l in lines if l.product.sku == self.sku]
qty = sum(l.qty for l in rows)
free = qty // (self.n + 1)
price = rows[0].product.unit_price if rows else 0
return Discount(self.label, free * price) if free else None
The engine, and the buggy engine
PricingEngine.price sorts by stage; price_in_list_order honours the order of the list it was handed. The second is the defect from Decision 1, kept so it can be asserted, not just described.
class PricingEngine:
def __init__(self, promotions: Sequence[Promotion]):
self.promotions = list(promotions)
def price(self, lines: Sequence[LineItem]) -> Quote:
subtotal = sum(l.gross for l in lines)
running, applied = subtotal, []
for p in sorted(self.promotions, key=lambda p: p.stage.value):
d = p.apply(lines, running)
if d:
applied.append(d)
running -= d.amount
return Quote(subtotal, tuple(applied), running)
def price_in_list_order(lines, promos) -> Cents:
"""The buggy version: honours list order instead of stage."""
running = sum(l.gross for l in lines)
for p in promos:
d = p.apply(lines, running)
if d:
running -= d.amount
return running
The one line that is the fix is sorted(self.promotions, key=lambda p: p.stage.value): the engine sorts a copy by stage on every call, so the list it was constructed with is never consulted for ordering. min(self.amount, running) in AmountOff stops a $5.00 coupon on a $3.00 cart from producing a negative total.
Running it
The bug and the fix, side by side, on three $10.00 apples:
APPLES = Product("APL", "Apples", 1000)
cart = [LineItem(APPLES, 3)]
pct, amt, b2g1 = PercentOff(20), AmountOff(500), BuyNGetOneFree("APL")
# The bug: list order decides the total.
assert price_in_list_order(cart, [pct, amt]) == 1900
assert price_in_list_order(cart, [amt, pct]) == 2000
assert price_in_list_order(cart, [amt, pct, b2g1]) == 1000 # a third promo widens the gap
# The fix: stage order wins, list order is irrelevant.
engine_a = PricingEngine([pct, amt, b2g1])
engine_b = PricingEngine([b2g1, amt, pct])
assert engine_a.price(cart).total == engine_b.price(cart).total == 1100
# The total is explainable: each discount carries a label and an exact amount.
q = engine_a.price(cart)
assert [(d.label, d.amount) for d in q.discounts] == [
("buy 2 get 1 free", 1000), ("percent off order", 400), ("amount off order", 500)]
assert q.subtotal - sum(d.amount for d in q.discounts) == q.total
Two invariants live in Quote.__post_init__, and both matter:
- Reconciliation: subtotal minus the logged discounts equals the total, exactly. An invariant is a statement true at every observable moment, not just at the end of a happy path. Put it in the constructor and every quote that has ever existed satisfies it, because one that violates it cannot be constructed. That also requires the object to be closed: freezing the class stops
q.total = 0, and making the log aTuplestopsq.discounts.clear(). An invariant enforced by a method while its state is a public mutable field is only a convention. - Plausibility:
0 <= total <= subtotal. Reconciliation alone is not enough.PercentOff(200)yieldssubtotal=3000, discounts=(6000,), total=-3000: it reconciles perfectly and hands the shopper $30.PercentOff(-50)reconciles too, as a surcharge in the discount column. The bounds check refuses both. A ledger that sums is not the same claim as a ledger that is sane; you need both.
Decision 2: inventory, and the oversell race
This is the one place two things happen at once. Pricing is single-threaded and safe by construction. Inventory is not.
A race condition is a bug where the result depends on the relative timing of two concurrent operations. Here it is the classic one: two registers scan the last unit of a SKU at the same moment, both reading stock == 1 before either writes. (A thread is an independently scheduled line of execution inside one program.)
sequenceDiagram
participant A as Register A
participant S as Stock (qty=1)
participant B as Register B
A->>S: read qty (=1)
B->>S: read qty (=1)
A->>S: 1 >= 1, write qty = 0
B->>S: 1 >= 1, write qty = 0
Note over S: one unit, sold twice, counter says 0 not -1
Both registers decide against the same stale read, so both sell. The fix is to make read-decide-write one indivisible step. RacyInventory splits the read and the write; SafeInventory puts both under one lock. A lock is an object only one thread can hold at a time.
import threading, time
from typing import Dict
class RacyInventory:
def __init__(self, stock: Dict[str, int]):
self.stock = dict(stock)
def reserve(self, sku: str, qty: int) -> bool:
have = self.stock[sku] # READ
if have < qty:
return False
time.sleep(0.0005) # the window: any work at all fits here
self.stock[sku] = have - qty # WRITE, using a stale `have`
return True
class SafeInventory:
def __init__(self, stock: Dict[str, int]):
self.stock = dict(stock)
self._lock = threading.Lock()
def reserve(self, sku: str, qty: int) -> bool:
with self._lock: # check and decrement are one step
if self.stock[sku] < qty:
return False
self.stock[sku] -= qty
return True
Fire twenty threads at five units of stock and the racy version sells more than existed, with a counter that no longer agrees with its own sales:
def hammer(inv, registers=20):
sold = []
ts = [threading.Thread(target=lambda: inv.reserve("APL", 1) and sold.append(1))
for _ in range(registers)]
for t in ts: t.start()
for t in ts: t.join()
return len(sold), inv.stock["APL"]
racy_sold, racy_left = hammer(RacyInventory({"APL": 5}))
safe_sold, safe_left = hammer(SafeInventory({"APL": 5}))
assert racy_sold > 5 and racy_sold != 5 - racy_left # oversold, counter disagrees
assert safe_sold == 5 and safe_sold == 5 - safe_left # sold exactly the stock
The time.sleep is not rigging the demo; it widens a window that exists anyway. Any real work between read and write (a log line, a network call, the OS scheduling another thread) opens the same gap. Because the exact racy figure varies run to run, a flaky assertion is worthless in a test suite; the same interleaving performed by hand fails identically on every machine:
inv = RacyInventory({"APL": 1})
a_have = inv.stock["APL"] # A reads 1
b_have = inv.stock["APL"] # B reads 1, before A writes
inv.stock["APL"] = a_have - 1 # A commits
inv.stock["APL"] = b_have - 1 # B commits over the top
assert inv.stock["APL"] == 0 # two sales, one unit, counter says zero not -1
The precise name for the race is “read, decide, write, with a gap in the middle.” The fix is that the check and the decrement become one indivisible step, not merely that “we added a lock”: the lock is a Python-specific implementation of a general requirement. At store scale it is not a lock at all but a single SQL statement:
UPDATE stock SET qty = qty - 1 WHERE sku = ? AND qty >= 1
followed by checking the affected row count; zero rows means insufficient stock, refuse the sale. The database executes the condition and the change atomically (no other transaction can observe a half-finished version). A separate SELECT to check first is fine for showing the shopper a number on screen, and never a correctness mechanism.
One reframe is worth volunteering: a supermarket does not need to prevent overselling at the register, because the goods are already physically in the cart. The real invariant a store cares about is inventory accuracy, so that recorded stock matches the shelf and replenishment works. The reservation model, holding stock before payment, belongs to online order-and-collect, not to the till.
Extensions
Three natural follow-ups, each priced in edits.
Member-only pricing
Promotions need facts the current signature does not carry: membership, loyalty tier, today’s date. So apply(lines, running) becomes apply(lines, running, ctx), where ctx is a context object. That signature change touches every promotion class, and it is the one genuinely expensive edit here. Cart, the engine’s ordering logic, Order, and the receipt are all untouched, because none of them looks inside a promotion.
Had apply taken a frozen PricingContext(is_member, tier, day_of_week, coupon_codes) from the start, member pricing would have been one new field and zero edits elsewhere. The lesson: pass a context object to a Strategy, never a bag of positional arguments, because the argument list is exactly the part you cannot extend without touching every implementation.
Coupons that stack with some promotions but not others
A Coupon is a promotion carrying a code, at the same stage as AmountOff. On top of that, every promotion gains an exclusivity group: a tag such that any two promotions sharing a tag are mutually exclusive and only the best applies. The stage machinery is untouched, because stacking and ordering are two different questions (may these run together? versus which runs first?), and keeping them as two independent fields is what makes this cheap.
from typing import Dict, List, Sequence
def resolve_exclusivity(candidates: Sequence, groups: Dict[str, str]) -> List:
"""Keep only the largest discount within each exclusivity group.
`candidates` are (promotion, Discount) pairs already evaluated."""
best: Dict[str, tuple] = {}
free: List[tuple] = []
for promo, disc in candidates:
g = groups.get(promo.label)
if g is None: # ungrouped: always kept
free.append((promo, disc))
elif g not in best or disc.amount > best[g][1].amount: # largest wins
best[g] = (promo, disc)
return free + list(best.values())
What it costs: evaluation stops being a single pass. To know which of two mutually exclusive promotions is bigger, you evaluate both against the same running subtotal, discard one, then continue: with k promotions in a group, k evaluations instead of 1. If exclusivity groups spanned stages you would have to try every combination of which to keep, 2^n for n promotions, which you cannot do at a till. So cap it: exclusivity applies within a single stage only.
Returns
The Order must attribute order-level discounts back down to individual lines, because a customer returns one item, not a fraction of a subtotal. Refunding the full $10.00 for an apple that cost $6.33 after its share of discounts would let a shopper profit by buying a discounted cart and returning part of it. Promotions, pricing, and the engine are untouched, because the attribution runs exactly once, at payment.
The general problem is splitting a whole number of cents across lines in proportion to their sizes, so the parts sum to the original exactly. The standard answer is largest remainder (the same arithmetic as seat apportionment in legislatures):
from typing import List
def allocate(total: int, weights: List[int]) -> List[int]:
"""Split `total` across `weights` in integer cents; sums exactly.
Floor everyone, then hand leftover cents to the biggest discarded fractions."""
w = sum(weights)
if w <= 0:
raise ValueError(f"cannot allocate {total} across weights summing to {w}")
base = [total * x // w for x in weights]
leftover = total - sum(base)
order = sorted(range(len(weights)),
key=lambda i: (-(total * weights[i] % w), i))
for i in order[:leftover]:
base[i] += 1
return base
assert allocate(500, [1000, 1000, 1000]) == [167, 167, 166] # $5.00 three ways
assert sum(allocate(500, [1999, 500, 501])) == 500 # uneven, still exact
Read it in three steps: floor every share (total * x // w, at or below the true share); count the leftover (total - sum(base), a few cents); hand them out one each, largest remainder first. total * x % w is exactly the numerator of the discarded fraction over the shared denominator w, and a shared denominator is what makes remainders comparable across lines of different sizes. The trailing i in the sort key breaks ties by position so the result is deterministic.
The guard for weights summing to zero is not defensive noise. Returning a list of zeros there would produce a receipt that has silently lost the money. Zero weights are not exotic: one $0.00 giveaway line produces one, a voided cart produces all of them. A function whose whole purpose is that the parts sum to the whole must not have a branch where they do not, so the case with no proportional answer raises.
Worked on the percentage-first cart ($30.00, $11.00 of discounts, three $10.00 apples), one apple returned:
shares = allocate(1100, [1000, 1000, 1000])
assert shares == [367, 367, 366] # each apple's share of the discounts
assert 1000 - shares[0] == 633 # refund $6.33, not the $10.00 sticker
Refund the sticker instead and the store pays $3.67 for the privilege of the return. Returns are a design test, not a feature: if the allocation was never stored, you cannot recompute it later, because by then promotions may have expired and prices may have changed. So the order freezes the allocation, not the rule that produced it: the same reason Order holds a Quote by composition and Product prices are immutable.
Two more design choices worth stating
- Do not make
Cataloga Singleton. A Singleton (a class permitting exactly one global instance) is a global variable with better manners: it hides the dependency and makes two tests running different catalogs in one process impossible. Construct it once at startup and pass it in: dependency injection. The OOP fundamentals chapter covers this. - Tax goes on the post-discount total, per tax class, through the same integer path. Groceries are frequently zero-rated (taxed at 0%, which is different from exempt) and prepared food is not, so tax class is a
TaxClassfield onProduct, never anifstatement buried in checkout.
What the class structure assumes
Every boundary above answers one question: what is expected to change, and what is not?
Assumed to vary, so made cheap to change: promotion types (each its own class behind a shared interface); promotion instances (data a manager creates, not code a developer deploys); prices (a change creates a new product record, so old receipts still reprice); tax treatment (a TaxClass field, not a branch).
Assumed fixed, so hard-coded into the structure: one currency (Cents = int, not a Money class); whole-number quantities (LineItem.qty is an int); single-pass pricing (each promotion sees only the running subtotal, which is why “20% off items not already discounted” does not fit); single-threaded pricing (the only concurrency is in Inventory); one shopper, one register, one cart.
Change an assumption and the structure changes with it:
| If this had been assumed instead | The structure that follows |
|---|---|
| Multiple currencies | Cents = int becomes a Money value object carrying amount and currency, with addition that refuses to mix them. Every engine signature changes, so this must be decided on day one |
| Promotions must see per-line state | apply gains a ctx carrying per-line discount history, the engine makes two passes, and the single-pass ordering argument has to be redone |
| Only one promotion may ever apply | The Stage machinery disappears, replaced by “evaluate all, keep the cheapest total”: a different algorithm with no ordering question |
| Register is offline-first, syncs later | Inventory stops being authoritative at the till; reservations become optimistic and reconciled afterwards, Order needs a device-local id to deduplicate, and the oversell race moves from a lock to a merge policy |
Two terms there: a value object has no identity of its own and is compared entirely by its fields, so two Money(500, 'USD') are the same amount, which is what lets you pass one around freely. Optimistic means the till assumes success and reconciles afterwards; pessimistic takes a lock first and blocks until sure. An offline register has no choice, because there is nothing to take a lock on.
Conclusion
Ten objects, two decisions.
- Money is an integer count of the smallest currency unit, with a currency tag; never float. Float breaks the one property the whole design exists for: that line items sum to the total exactly. Rounding stays, but as one documented rule in one function.
- Promotions are Strategies (one class per rule) and they do not commute. Order is a declared
stageon each promotion, sorted by the engine, not an accident of list order. The same cart with the same two promotions is $19.00 or $20.00 depending on that order. - The quote carries its own audit trail and validates itself. Subtotal minus logged discounts equals total (reconciliation), and
0 <= total <= subtotal(plausibility); both invariants live in the constructor of a frozen object, so a broken quote cannot exist. - Cart and Order are opposite classes: one mutable with no total, one a frozen record holding the quote and the per-line discount allocation. Merging them lets a total change after the receipt prints.
- Inventory’s oversell is a read-decide-write race; the fix is one atomic check-and-decrement (a lock in memory, a conditional
UPDATEwith a row-count check in SQL). Stacking, member pricing, and returns are all cheap only because ordering, eligibility, and allocation were kept as separate concerns from the start.
flowchart TD
Shopper["Shopper scans items"] --> Cart["Cart: LineItems, mutable, no total()"]
Catalog["Catalog: SKU to Product, versioned"] -.-> Cart
Cart --> Engine["PricingEngine: sort promotions by stage, apply, log each discount"]
Promos["Promotions: one class per rule, each with a stage"] -.-> Engine
Engine --> Quote["Quote: subtotal - discounts = total, frozen, self-checking"]
Quote --> Order["Order: frozen snapshot at payment, holds per-line allocation"]
Order --> Inventory["Inventory: atomic check-and-decrement"]
Summary
| Money | Integer minor units plus a currency tag. Decimal if you need sub-cent unit prices. Never float |
| Rounding | One function, one documented direction: discounts to the customer, tax half-up |
| Promotions | Strategy, one class per rule, with an explicit Stage so order is declared, not accidental |
| The number | Same cart, same two promos: $19.00 percentage-first vs $20.00 fixed-first |
| Stacking | Separate from ordering: exclusivity groups, resolved within a stage, best discount wins |
| Cart vs Order | Cart mutates and has no total. Order is frozen and holds the quote, the log, and the allocation |
| Inventory | Check-and-decrement is one atomic operation. SELECT then UPDATE is the oversell |
| Returns | Need per-line discount allocation, stored at settle. Largest-remainder split so cents reconcile |
| Do not | Let Cart hold a total, let list order decide pricing, or make Catalog a Singleton |
Further reading
- David Goldberg, “What Every Computer Scientist Should Know About Floating-Point Arithmetic”: why decimal fractions cannot be stored exactly in binary.
- Python
decimalmodule documentation: the base-ten alternative, and how to set a rounding mode. - Martin Fowler, Patterns of Enterprise Application Architecture: the Money pattern (amount plus currency as a value object).
- Gamma, Helm, Johnson, Vlissides, Design Patterns: the Strategy pattern.
- “Largest remainder method” (also called Hamilton’s method): the standard integer apportionment used by
allocate.
Next: Tic-Tac-Toe is the opposite failure mode: the design is genuinely small, and the way to fail it is to make it big.