InterviewPrepKit

Home / Learn / Object-Oriented Design

09 — Grocery Store System

The prompt: design the checkout system for a grocery store.

This is an object-oriented design round. You name the classes the system needs, state what each is responsible for, and defend the boundaries between them.

The system is a supermarket checkout: ten objects, or fourteen boxes on the diagram once the four kinds of promotion are drawn separately. 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.

Input. A list of scanned items. Each one is a SKUstock keeping unit, the store’s unique code for a product, the thing the barcode encodes — together with a quantity. Alongside that, 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 applied with a human-readable label and an exact amount, the tax, and a final total.

The critical property of that output is that the 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.

The objects fall out quickly. The two things that decide the round are whether you represent money as an integer, and whether you noticed that promotions do not commute.

Two operations commute when applying them in either order gives the same result, the way addition does. Discounts do not, and Decision 1 promotions are strategies and they do not commute shows the dollar of difference that follows.

Three related chapters are linked for more depth; none is required:

1. Clarifying questions that change the design

Ask a question only if its answer changes the object model. The five questions below pass that test. Each one forces a different design; the point of asking is to hear which.

QuestionWhy it changes the design
Are promotions store-wide, or per-item?Per-item promotions apply to one line of the receipt; order-level promotions apply to the whole subtotal. Mixing both into one interface is what creates the ordering bug in Decision 1 promotions are strategies and they do not commute
Can promotions stack?If yes, you need an explicit precedence and an exclusivity rule. If no, you need a “best price wins” evaluator, which is a different algorithm (try all, keep the cheapest)
Weighed items?Bananas at $2.99/kg means 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
Is this the register, or the whole store?The register is cart plus pricing plus payment. The store adds inventory, replenishment, and the oversell race in Decision 2 inventory and the oversell race
Returns?Returns force per-line discount attribution, which forces the allocation function in Now support returns. Designing without it and adding it later is a schema change, not a code change

Two terms in that table do work later in the chapter, so pin them down now:

“Do promotions stack, and in what order?” is the question that matters most. It sounds like a product detail. It is the core of the object model.

2. Actors and use cases

Name who touches the system and what each needs from it. Four of the five below are routine; the fifth changes 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

The auditor is not decoration

The auditor is on the list deliberately. A checkout system whose total cannot be explained line by line is broken even when the number is right.

A store that cannot answer “why did this receipt say $19.00?” cannot settle a dispute, pass an audit, or process a return. That requirement forces the discount log to be a real object in the design, rather than a running integer that gets decremented and forgotten.

What the other four pull into the model

Each of the remaining actors drags one requirement into the design:

3. Money is the first design decision

The auditor’s requirement — a ledger that sums exactly — settles the first design decision before a single class is drawn: money is a whole number of cents. Four failures make the case against floating point, and then two rounding decisions remain that a cents-based design forces you to make on purpose.

A floating-point number is how computers normally store values with a decimal point: 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

Each assertion below is written as an executable claim: run the block and it passes, which means every failure really happens.

# Each of these is a floating-point failure, not a contrived one.
total = 0.0
for _ in range(10):        # ten 10-cent items
    total += 0.10
assert total != 1.0 and repr(total) == "0.9999999999999999"

# "Just round at the end" does not save you: the binary value of 2.675 is
# already below the true 2.675, so round() correctly rounds it down.
assert round(2.675, 2) == 2.67

# The dollars-to-cents conversion silently loses a cent.
assert int(1.15 * 100) == 114 and int(4.35 * 100) == 434

s = 0.0
for _ in range(100):       # a hundred 7-cent items
    s += 0.07
assert repr(s) == "7.000000000000009"

Two pieces of Python vocabulary make that block readable:

And here is what each of the four failures shows:

  1. Ten dimes do not make a dollar. Adding 0.10 ten times lands on 0.9999999999999999.
  2. Rounding at the end does not save you. round(2.675, 2) returns 2.67 — not because round is broken, but because the value actually stored is fractionally below 2.675, and round is doing exactly the right thing to the value it was handed.
  3. The dollars-to-cents conversion loses a cent. int() truncates toward zero, and 1.15 * 100 is stored as slightly less than 115, so it becomes 114.
  4. The error has no consistent direction. A hundred 7-cent items drift upward to 7.000000000000009, where the first case drifted downward. You cannot correct for it with a fudge factor.

The fix

Integers. One line, and the whole class of failure is gone:

# Integers. Exact, associative, orderable.
assert sum([10] * 10) == 100

Money is an integer count of the smallest currency unit. Cents for the US dollar, whole yen for the Japanese yen — plus a currency tag alongside the number, so that two amounts in different currencies cannot be added by accident.

Decimal — Python’s arbitrary-precision decimal type, which stores digits in base ten rather than base two — 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 it still requires you to pin down a rounding mode explicitly, so it buys you exactness only if you configure it.

Why the size of the error is not the point

The failure is not about magnitude. The difference between 0.9999999999999999 and 1.0 is a hundredth of a cent, which no customer would notice.

The failure is that the ledger no longer sums. Payments, line items, discounts and tax must add up exactly. A floating-point total turns that equality into a tolerance check — “these agree to within a hundredth of a cent” — and at that point reconciliation can no longer distinguish a rounding artefact from a genuinely missing cent. Distinguishing those two is the entire job of reconciliation.

Where the cents actually go

Choosing integers does not make rounding go away. It makes rounding a decision you make on purpose. Two of those decisions have to be stated explicitly, because the default is a policy nobody chose.

Here is the first, worked in full. Take 20% off a $19.99 item: the exact discount is 399.8 cents, which is not a whole number of cents, so something has to give.

20% off a $19.99 item
1999 * 20 // 100   = 399          floor: discount 3.99, customer pays 16.00
round-half-up                     discount 4.00, customer pays 15.99

The // in that first line is Python’s floor division: divide, then round down to the nearest whole number. So 1999 * 20 // 100 computes 39,980 divided by 100 and discards the remainder, giving 399 rather than 399.8.

Rounding down the discount means rounding up what the customer pays. Round half-up instead and the discount becomes 400 and the customer pays a cent less.

One cent, in whichever direction you pick. That matters at volume. The two policies disagree on roughly half of all discounted lines, and by one cent when they do, so the expected drift is half a cent per line:

1,000,000 * 0.005    = 5000

$5,000 a year for a store ringing a million discounted lines — roughly 2,700 a day — decided by a rounding mode nobody wrote down.

State the rule explicitly — “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 rather than a search across the codebase.

4. Core objects, and why those

Ten objects, and for each the plausible alternative it was chosen over. The third column is the one that matters: an interviewer expects Product and Cart, and is listening for whether you can say why Cart does not have a total() method.

ObjectResponsibilityWhy not the obvious alternative
CatalogSKU -> Product lookupNot a dict on the register. A catalog is loaded, versioned and swapped; the products in it outlive any one snapshot
ProductSKU, name, unit price, tax classImmutable. Prices change by creating a new price record, because a receipt from last Tuesday must still reprice. Note that frozen=True buys one line of depth — object.__setattr__ walks straight past it — so it documents intent and stops accidents, not a determined caller
LineItemproduct + quantityNot “a list of Product repeated N times”. Quantity is needed for buy-2-get-1 and for weighed goods
CartThe lines, nothing elseA cart that knows its own total is the mistake. Totals depend on promotions, membership, and the date; that is not the cart’s job
PricingEngineLines + promotions -> total + discount logThe separate object is what lets you reprice a historical order for a return
PromotionOne rule, one stageStrategy — one interface, one class per rule (Decision 1 promotions are strategies and they do not commute)
DiscountLabel, amount, sourceExists so the receipt can be explained. Without it the total is an unauditable scalar
QuoteSubtotal, the discount log, totalThe object the auditor argument is for. Frozen, and it validates itself: a quote that does not reconcile cannot be constructed (Working python)
InventoryStock levels, atomic reservation — atomic meaning the whole operation happens or none of it does, with no state in between that anything else can observeThe check and the decrement must be one step (Decision 2 inventory and the oversell race)
OrderFrozen snapshot: lines, discounts, tax, paymentsDistinct from Cart. A cart mutates; an order never does

The mutable/immutable line

Two words in that table are load-bearing:

The design puts the ten objects deliberately on opposite sides of that line. Product, Quote, Discount and Order are immutable. Cart and Inventory are not.

Cart and Order being the same class is the most common structural error in this round. They have exactly opposite mutability requirements — a cart exists to be edited, an order exists to be a permanent record.

Merging them produces one specific bug: a total that changes after the receipt has been printed, because a promotion expired or a price was updated in between.

5. Class diagram

The notation is UML — the Unified Modeling Language, the standard set of symbols for drawing class relationships — and every symbol makes a claim about ownership and lifetime.

Fourteen boxes. One feature dominates: the fan of four arrows at the bottom, from PercentOff, AmountOff, BuyNGetOneFree and Bundle up into Promotion. That fan is the Strategy pattern.

classDiagram
    class Catalog {
        +find(sku) Product
    }
    class Product {
        +str sku
        +int unit_price_cents
        +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 tax
        +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 notation, symbol by symbol

UML uses five kinds of link here, and each one is a different claim:

Reading the diagram, arrow by arrow

Each arrow is a decision, so take them one at a time.

Catalog "1" o-- "0..*" Product is aggregation on purpose, and the label says indexes, does not own. A catalog is a lookup table from SKU to product. Products outlive any one catalog snapshot, and deleting a catalog must not invalidate the products that a printed receipt still refers to. Composition here would be a lie about lifetime.

Cart "1" *-- "0..*" LineItem is composition, labelled lines die with the cart. A line item is meaningless outside the cart it belongs to — “three apples” is not a thing that exists on its own — so the cart owns it outright, and nothing else may keep a reference once the cart is abandoned.

LineItem "1" --> "1" Product is a plain association: the line refers to a product it does not own. This is the arrow that makes immutability of Product matter, because many line items, across many carts and many historical orders, all point at the same product record.

PricingEngine "1" o-- "0..*" Promotion is aggregation, labelled rule set. Promotions are created and expired by a manager on their own schedule, so they exist independently of any engine that happens to be evaluating them.

PricingEngine ..> Quote is a dependency rather than a field: the engine produces a quote and hands it back, holding nothing afterwards. That is what makes the engine stateless and safe to share.

Quote "1" *-- "0..*" Discount is composition, labelled audit trail. This is the arrow the auditor from Actors and use cases put there. Each Discount carries a label and an amount, so the quote does not just say what the total is — it says how it got there.

Order "1" *-- "1" Quote is composition with multiplicity exactly one, labelled frozen at settle: an order owns precisely one quote, captured at the moment of payment and never recomputed.

Order "1" --> "1" Inventory is an association rather than composition, labelled decrements, because inventory is a store-wide object that every order talks to and no order owns.

What the diagram deliberately leaves out

Two things are absent, and their absence is the design.

Cart has no total() method — its only members are add and lines — because a total is a function of promotions, membership and today’s date, none of which the cart knows about.

And there is no arrow from Cart to Promotion at all, because a cart is a list of things a shopper picked up and has no opinion about what they cost.

What is drawn versus what is coded

What is drawn and what is built are not the same set, and saying so is part of the answer.

Of the fourteen boxes above, Working python implements nine — Product, LineItem, Discount, Quote, Promotion, PercentOff, AmountOff, BuyNGetOneFree and PricingEngine — which is the pricing core, the half the interview is decided on. Decision 2 inventory and the oversell race then implements Inventory twice, to make one point about locking.

Four boxes are never written as code at all: Catalog, Cart, Order and Bundle. They are design, and the chapter says so rather than pretending otherwise.

Three smaller details in the diagram run ahead of the code, and it is worth knowing which:

Draw the full model, then say which part of it you are about to write. A diagram is a claim about structure; a listing is a claim about behaviour. The two are allowed to differ, as long as you point at the gap.

6. Decision 1 — promotions are Strategies, and they do not commute

The straightforward half of the promotion design is one rule per class. The hard half — deciding what order the promotions run in — changes the customer’s bill by a dollar on a thirty-dollar cart. The fix is to make order a declared property of each rule rather than an accident of how a list was built.

The easy half: one rule, one class

Each promotion is one object with one method behind a shared interface — the Strategy pattern — so the code that applies promotions never needs to know which rule it has.

The load-bearing part is that applying the same two promotions in a different order produces a different total.

The hard half: two promotions, two totals

Take a cart of three items at $10.00 each, so a subtotal of $30.00, with two promotions both active:

Both orderings are below. Every figure is in cents, so 3000 means $30.00. The last line of each differs by 100 cents.

percent first:  3000 * 20 // 100 = 600
                3000 - 600       = 2400
                2400 - 500       = 1900

amount first:   3000 - 500       = 2500
                2500 * 20 // 100 = 500
                2500 - 500       = 2000

The mechanism is that a percentage is worth whatever the running subtotal is when it fires. Applied first, the 20% is computed on the full $30.00 and is worth $6.00. Applied after the coupon, it is computed on $25.00 and is worth only $5.00. The fixed $5.00 coupon, by contrast, is worth $5.00 wherever it lands.

$19.00 or $20.00 on the same cart with the same two promotions. A dollar of difference, decided by nothing but the order of a Python list.

That dollar as a fraction of the cart:

2000 - 1900     = 100
100 / 3000      = 0.033

3.3% of the cart — and there is no defensible sense in which either answer is “the bug”. Both are policies that real stores really run. Percentage-before-fixed is customer-favourable; fixed-before-percentage is store-favourable.

The design defect is not the number that came out. It is that the number was chosen by list order, which is to say by nobody.

A third promotion widens the gap

Adding a rule does not average the difference out. It widens it.

BuyNGetOneFree here means every third unit of a given product is free, priced at that product’s unit price, so three $10.00 apples yield a $10.00 discount. Two orderings of the same three promotions:

b2g1, percent, amount:   3000 - 1000 = 2000
                         2000 -  400 = 1600
                         1600 -  500 = 1100

amount, percent, b2g1:   3000 -  500 = 2500
                         2500 -  500 = 2000
                         2000 - 1000 = 1000

$11.00 versus $10.00 on a $30.00 cart, from three promotions that every party agrees should apply.

The fix: order is a declared property of the rule, not of the list

Do not let the sequence of a list 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.

Four stages cover a supermarket:

LINE           = 10    per-item: buy-2-get-1, bundle, member unit price
ORDER_PERCENT  = 20    percentage off the running subtotal
ORDER_FIXED    = 30    fixed-amount coupons, applied last
LOYALTY        = 40    points redemption, after everything cash-like

The numbers 10, 20, 30 and 40 are gaps rather than a sequence, which leaves room to insert a stage later without renumbering the existing ones.

With the engine sorting by stage before applying anything, the total no longer depends on the order in which a manager happened to create the promotions. Note that the stage order for the three-promotion cart above is exactly b2g1, percent, amount — the first column — so the engine’s answer is $11.00.

Within a single stage the order is still undecided, so pick a documented tiebreak — largest discount first is the usual choice — and say so out loud. “And what decides the order within a stage?” is the follow-up question every time.

What Strategy buys, and what it costs

What it buys is that adding a new kind of promotion is one new class plus a stage assignment. PricingEngine does not change, and neither does any existing promotion. In a pricing system this is worth a great deal: new promotion types arrive continuously, and pricing engines are the last thing anyone wants to redeploy.

What it costs is two things.

First, you cannot learn the store’s pricing rules by reading one file, because they are spread across one class per rule plus a stage table.

Second, a promotion cannot see what any other promotion did, except indirectly through the running subtotal it is handed. That limit is real, and it bites on a rule as ordinary as “20% off, but not on items that are already discounted”, which needs per-line state that the (lines, running) argument list does not carry. Fixing it means passing a richer context object instead, which is the extension priced in Now add member only pricing.

7. Working Python

This is the whole pricing engine, split into four parts. It shows the bug and the fix side by side: price_in_list_order is kept in the file so the two behaviours can be asserted against each other rather than described.

The Python constructs used

Seven constructs carry the design:

One more, and it is a piece of documentation rather than a construct: Cents = int is a type alias. It changes nothing at run time and exists purely so that every signature dealing in money says so.

Part 1 — the data objects

The five classes below hold data and nothing else. The one to read closely is Quote, whose __post_init__ refuses to construct a quote whose discounts do not add up — that is the auditor’s requirement from Actors and use cases 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 List, 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}]")

Stage carries only three of the four stages listed in The fix order is a declared property of the rule not of the list; LOYALTY is not implemented, because none of the three promotions below needs it.

Part 2 — the promotions

Promotion is the interface, and the three classes after it are the Strategy implementations. Each one is a class with a stage, a label, and an apply that returns either a Discount or None.

Notice that no promotion knows about any other promotion, and none of them 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: the discount rounds DOWN, so
                                           # the store keeps the cent. §3
                                           # recommends the other direction;
                                           # pick one and write it down.
        return Discount(self.label, off) if off else None

@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

Part 3 — the engine, and the buggy engine

Two functions that do the same job in two different orders. PricingEngine.price sorts by stage; price_in_list_order honours the order of the list it was handed. The second one is the defect from Decision 1 promotions are strategies and they do not commute, kept in the file so that Part 4 can assert what it does rather than describe it.

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. Kept to prove it."""
    running = sum(l.gross for l in lines)
    for p in promos:
        d = p.apply(lines, running)
        if d:
            running -= d.amount
    return running

Part 4 — running it

The cart is the one from Decision 1 promotions are strategies and they do not commute: three apples at $10.00 each. The first three assertions reproduce the exact figures worked by hand there, which is the bug. The last two show the fix: two engines holding the same promotions in different list orders produce the same total.

APPLES = Product("APL", "Apples", 1000)
cart = [LineItem(APPLES, 3)]
pct, amt, b2g1 = PercentOff(20), AmountOff(500), BuyNGetOneFree("APL")

# The bug, demonstrated.
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

# 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   # $11.00

Next, the auditor’s requirement. The quote does not just carry a number; it carries the three discounts that produced it, in the order they fired, each with a label and an exact amount. The last line is the invariant the whole design exists for.

# And the total is explainable, which is what the auditor asked for.
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

An audit trail you can edit is not an audit trail. The loop below tries both ways of tampering with a quote after the fact — reassigning the total, and swapping an entry in the discount log — and asserts that each one raises.

# The quote cannot be edited after the fact: the log is a tuple on a frozen
# object, so neither half of the invariant can be walked back.
for mutate in (lambda: setattr(q, "total", 0),
               lambda: q.discounts.__setitem__(0, Discount("x", 0))):
    try:
        mutate()
        raise AssertionError("the audit trail was editable")
    except (AttributeError, TypeError):
        pass

Now the second half of __post_init__. Each of the three promotion sets below produces a quote that reconciles perfectly and is still nonsense — a negative total hands the shopper money, and a negative percentage invents a surcharge. The 0 <= total <= subtotal check is what refuses them.

# Reconciliation is not plausibility. Each of these reconciles perfectly and
# each is a promotion that pays the shopper or invents a surcharge; the
# `0 <= total <= subtotal` half of __post_init__ is what refuses them.
for broken in ([PercentOff(200)],                       # total -3000
               [BuyNGetOneFree("APL", n=0)] * 2,        # total -3000
               [PercentOff(-50)]):                      # total  4500, a surcharge
    try:
        PricingEngine(broken).price(cart)
        raise AssertionError(f"{broken} produced a quote")
    except ValueError:
        pass

Finally, the __init_subclass__ hook. MemberPrice below is a perfectly well-formed promotion that forgot one thing — its stage — and Python refuses to define the class at all.

# A promotion that forgets its stage is refused at class-definition time,
# not at the till.
try:
    class MemberPrice(Promotion):
        label = "member price"
        def apply(self, lines, running):
            return None
    raise AssertionError("a stageless promotion was defined")
except TypeError as e:
    assert "must declare a Stage" in str(e)

Three details worth a second look

sorted(self.promotions, key=lambda p: p.stage.value) is the entire fix from The fix order is a declared property of the rule not of the list. The engine sorts a copy by stage value on every call, so the list it was constructed with is never consulted for ordering.

min(self.amount, running) in AmountOff is what stops a $5.00 coupon on a $3.00 cart from producing a negative total, which would otherwise be a refund the store never agreed to.

running * self.pct // 100 uses floor division, which is the rounding policy from Where the cents actually go written into one line: the discount rounds down, so the customer pays the extra cent. That is the opposite of the “discounts round in the customer’s favour” rule §3 recommends saying out loud.

The mismatch is deliberate, and it is the point. Floor division is the default you get by not choosing; the comment on the line says which direction it actually goes; and the fix is one edit in one function. Whichever direction you pick, the failure is picking it by accident.

Why the invariant lives in the constructor

The assertion q.subtotal - sum(d.amount for d in q.discounts) == q.total is the property the whole design exists to guarantee: the subtotal minus the logged discounts equals the total, exactly, with no tolerance.

An invariant is a statement that must be true at every observable moment, not merely at the end of a happy path. You cannot write that assertion at all with floating-point money — which is the argument of Money is the first design decision, arriving as code.

Which is why the invariant does not live only in the demo. An invariant asserted once, in a test, holds for the one path the test walked. The same statement in Quote.__post_init__ holds for every quote that has ever existed, because a Quote that violates it cannot be constructed.

The object it guards has to be closed too. An earlier version of this listing was a plain mutable @dataclass holding a plain List[Discount], so q.total = 0 and q.discounts.clear() both went straight through and left an audit trail that reconciled with nothing. Freezing the class stops the first; making the log a Tuple stops the second.

An invariant enforced by a method, while the state it protects is a public mutable field, is a convention rather than an invariant.

Reconciliation is not plausibility

Note what reconciliation does not cover, because it is narrower than it sounds:

Before the 0 <= total <= subtotal line was added, the clamp min(self.amount, running) in AmountOff was the only bound on a discount’s magnitude anywhere in the file, and no promotion validated its own parameters at all.

Reconciliation and plausibility are two invariants and you need both. A ledger that sums is not the same claim as a ledger that is sane.

8. Decision 2 — inventory, and the oversell race

This is the one place in the design where two things happen at once, and the fix is more specific than “add a lock”.

Pricing is single-threaded and therefore safe by construction. Inventory is not.

A race condition is a bug where the result depends on the relative timing of two concurrent operations. The one here is the classic: two registers scan the last unit of a SKU at the same moment, and both read stock == 1 before either of them writes. A thread is an independently scheduled line of execution inside one program, which is how the code below simulates twenty registers.

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 of 1, so both sell. The fix is to make read-decide-write one indivisible step.

The race, executing

Two inventories with the same interface. In RacyInventory.reserve the read and the write are separate statements with a gap between them; in SafeInventory.reserve they are inside one with self._lock: block. hammer fires twenty threads at whichever one it is given and reports two numbers: how many sales succeeded, and what the counter says afterwards.

import threading
import 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:
    """Check and decrement under one lock. The check alone is worthless."""

    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:
            if self.stock[sku] < qty:
                return False
            self.stock[sku] -= qty
            return True

def hammer(inv, registers: int = 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"]

Now run twenty registers against five units of stock, twice. The two assertions on the racy version say the two things that are actually wrong: it sold more than existed, and its counter no longer agrees with its own sales.

racy_sold, racy_left = hammer(RacyInventory({"APL": 5}))
safe_sold, safe_left = hammer(SafeInventory({"APL": 5}))

assert racy_sold > 5                      # sold more than existed
assert racy_sold != 5 - racy_left         # ...and the counter disagrees with the sales
assert safe_sold == 5 and safe_left == 0  # sold exactly the stock
assert safe_sold == 5 - safe_left         # ...and the counter agrees with the sales

The same interleaving again, this time performed by hand with no threads and no sleep. Two reads of 1, then two writes of 0: one unit, sold twice, and a counter that says zero rather than -1.

# Deterministic version: the same interleaving, performed by hand.
inv = RacyInventory({"APL": 1})
a_have = inv.stock["APL"]        # register A reads 1
b_have = inv.stock["APL"]        # register 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 sleep is not a cheat

The time.sleep(0.0005) in RacyInventory is not the bug, and it is not rigging the demo. It widens a window that exists anyway.

Any real work at all between the read and the write opens the same gap: a log line, a network call, or simply the operating system deciding to run a different thread for a moment.

A lock is the standard remedy: an object only one thread can hold at a time. The with self._lock: block in SafeInventory guarantees that no other thread can be between its own read and write while this one is.

Why there are two demonstrations

Twenty registers, five units in stock: the racy version sells all twenty and leaves the counter somewhere around 4, because most of those threads wrote a decrement computed from the same stale read of 5.

The exact figure varies run to run, which is why the assertion is on the disagreement — twenty sales against a counter that barely moved — and not on a particular number. A defect demonstrated by a flaky assertion is a defect nobody will keep in the suite, which is why the hand-performed version is there too. It fails the same way on every machine, for ever.

Name the race precisely as “read, decide, write, with a gap in the middle”. The fix is that the check and the decrement become one indivisible step, not that “we added a lock”. The distinction matters because the lock is a Python-specific implementation of a general requirement, and the interviewer is testing whether you know which is which.

At store scale it is not a lock at all

It is a single SQL statement:

UPDATE stock SET qty = qty - 1 WHERE sku = ? AND qty >= 1

Followed by checking how many rows it actually affected. Zero rows means there was not enough stock, and the sale must be refused.

The shape is identical to SafeInventory: the condition and the change are one statement that the database executes atomically, meaning no other transaction can observe or interleave with a half-finished version of it.

A separate SELECT to check availability first is perfectly fine for showing the shopper a number on a screen, and is never a correctness mechanism.

The reframe worth volunteering

The other half of the answer changes the question. A supermarket does not need to prevent overselling at the register, because the goods are already physically in the shopper’s cart, so refusing the sale would be absurd.

The real invariant a store cares about is inventory accuracy: that the recorded stock level matches the shelf, so replenishment works.

The reservation model, where stock is held before it is paid for, belongs to online order-and-collect rather than to the till. Saying which of those two systems you are building is worth more in the interview than the lock is.

9. Extension scenarios

Three follow-ups an interviewer is likely to reach for, each priced in edits: what changes, what does not, and, for the first, why the cost was avoidable.

“Now add member-only pricing”

What changes. Promotions need access to facts about the shopper that the current signature does not carry: whether they are a member, which loyalty tier they are on, and what today’s date is.

So apply(lines, running) becomes apply(lines, running, ctx), where ctx is a context object holding those facts. That is a signature change touching every promotion class, and it is the one genuinely expensive edit in this design.

What does not change. Cart, the ordering logic inside PricingEngine, Order, and the receipt are all untouched, because none of them ever looks inside a promotion.

Why the cost was avoidable. Had apply taken a frozen PricingContext(is_member, tier, day_of_week, coupon_codes) from the very first version, member pricing would have been one new field on an existing object and zero edits anywhere else.

This is the design lesson worth stating out loud in the interview: pass a context object to a Strategy, never a bag of positional arguments, because the argument list is precisely the part you cannot extend without touching every implementation.

“Now add coupons that stack with some promotions but not others”

What changes. Two things.

A Coupon is a promotion carrying a code, at the same stage as AmountOff, which is ORDER_FIXED. 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 of them applies.

What does not change. The stage machinery, which already answers “in what order”. Stacking and ordering are two different questions — may these run together? versus which runs first? — and keeping them as two independent fields is exactly what makes this extension cheap instead of a rewrite.

The function below is the exclusivity half. It takes promotions that have already been evaluated and drops the losers within each group:

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:
            free.append((promo, disc))
        elif g not in best or disc.amount > best[g][1].amount:
            best[g] = (promo, disc)
    return free + list(best.values())

It keeps two collections. free holds promotions that belong to no group and are therefore always applied. best is a dictionary holding the largest discount seen so far for each group tag.

groups.get(promo.label) returning None is what marks a promotion as ungrouped, and disc.amount > best[g][1].amount is the “largest discount wins” tiebreak — made explicit, rather than left to iteration order.

What it costs. Evaluation stops being a single pass. To know which of two mutually exclusive promotions is bigger, you have to evaluate both against the same running subtotal, throw one away, and only then continue. The engine becomes evaluate-all-then-select, once per stage. With k mutually exclusive promotions in one group that is k evaluations where there was 1.

And if exclusivity groups were allowed to span stages, you would have to try every combination of which promotions to keep, which is 2^n for n promotions and is not a thing you can do at a till.

So cap it: exclusivity applies within a single stage only. State the cap and state why, because an interviewer who knows the combinatorics is waiting for it.

“Now support returns”

What changes. 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 only cost $6.33 after its share of the discounts would let a shopper profit by buying a discounted cart and returning part of it. So this is a new function plus a new field on the frozen order.

What does not change. Promotions, pricing, and the engine are untouched, because the attribution runs exactly once, at the moment of payment.

Splitting cents so they still sum

The function below solves the general problem: split a whole number of cents across several lines in proportion to their sizes, such that the parts sum to the original exactly.

The method is largest remainder, and it is worth naming, because it is the standard answer to this class of problem — including seat apportionment in legislatures, which is the same arithmetic.

from typing import List

def allocate(total: int, weights: List[int]) -> List[int]:
    """Split `total` across `weights` in integer cents. Sums exactly.

    Largest-remainder: floor everyone, then hand the leftover cents to the
    lines with the biggest discarded fraction. Naive rounding loses or
    invents cents, and a receipt that does not reconcile fails audit.
    """
    w = sum(weights)
    if w <= 0:
        raise ValueError(
            f"cannot allocate {total} across weights summing to {w}: "
            "there is no proportional split, and returning zeros would "
            "silently drop the money this function exists to conserve")
    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

# $5.00 off, three lines of $10.00: 500/3 = 166.67 each, which is not an integer.
assert allocate(500, [1000, 1000, 1000]) == [167, 167, 166]
assert sum(allocate(500, [1000, 1000, 1000])) == 500

# Uneven lines, still exact.
assert sum(allocate(500, [1999, 500, 501])) == 500
assert allocate(333, [1, 1, 1]) == [111, 111, 111]

# A $0.00 giveaway line is a zero weight, and it must not swallow the split.
assert sum(allocate(500, [1000, 0, 1000])) == 500

# Every weight zero is not a split at all, and returning zeros would lose $5.00.
for bad in ([0, 0, 0], []):
    try:
        allocate(500, bad)
        raise AssertionError(f"allocate(500, {bad}) returned instead of raising")
    except ValueError:
        pass

Read allocate in three steps:

  1. Floor every share. total * x // w gives each line the floor of its exact share, which is at or below the true share and therefore leaves some cents unassigned.
  2. Count what is left over. leftover = total - sum(base), a small whole number of cents.
  3. Hand them out, one each, largest remainder first. That is what sorted(..., key=lambda i: (-(total * weights[i] % w), i)) computes. % is the remainder operator, the negation sorts largest-remainder first, and the trailing i breaks ties by line position so the result is deterministic rather than dependent on Python’s sort internals.

The step usually left out is why that remainder is the right thing to sort on. Because total * x // w is the floor of total * x / w, the value total * x % w is exactly the numerator of the fraction that was discarded, over the shared denominator w. A shared denominator is what makes the remainders comparable across lines of different sizes.

The assertions show it splitting $5.00 three ways as 167, 167 and 166, and summing to exactly 500 in every case.

Why the zero-weight case raises

The guard on the first line is the same argument as the docstring’s last sentence.

The obvious way to handle weights summing to zero is to return a list of zeros. That is what this function did, until someone noticed that allocate(500, [0, 0, 0]) then returns a receipt that has lost $5.00 — silently, with no exception and no log line.

Zero weights are not exotic: one $0.00 giveaway line in the cart produces one, and a voided cart produces all of them. A function whose entire purpose is that the parts sum to the whole must not have a branch in which they do not, so the case that has no proportional answer raises rather than inventing one.

The refund, worked

Take the two-promotion cart from Decision 1 promotions are strategies and they do not commute: three $10.00 apples, percentage first, $11.00 of discounts, $19.00 to pay. Now the shopper returns one apple.

# $30.00 cart, $11.00 of discounts (the percentage-first $19.00 total),
# three lines of $10.00. What is one apple worth on the way back?
shares = allocate(1100, [1000, 1000, 1000])
assert shares == [367, 367, 366]      # each apple's share of the discounts
assert sum(shares) == 1100            # and they still sum to the whole
assert 1000 - shares[0] == 633        # refund $6.33, not the $10.00 sticker

The refund is $6.33, not $10.00, and the three shares still add to exactly the $11.00 that was discounted. Refund the sticker price instead and the store pays $3.67 for the privilege of the return.

Why returns are a design test rather than a feature. If the allocation was never stored, you cannot recompute it later, because by then the promotions may have expired and the prices may have changed.

The order must freeze the allocation, not the rule that produced it. That is the same reason Order holds a Quote by composition, and the same reason Product prices are immutable: three decisions in this design that all exist to make a historical receipt still true.

10. What interviewers probe

Eight questions, and the answer to each that ends the line of enquiry. Every answer leads with a number or a mechanism.

ProbeAnswer that lands
“Why not float for money?”It does not sum. 10 x 0.10 != 1.0, int(1.15 * 100) == 114. Integer minor units, one currency tag, one rounding function
“Two promotions, which applies first?”$19.00 vs $20.00 on the same $30.00 cart. Order is a declared stage on the promotion, not the list index
“Where does the cent go when you take 20% off $19.99?”399 or 400. Pick one, write it down, put it in one function. Half a cent per line is $5,000 a year for a store ringing a million discounted lines, roughly 2,700 a day
“How do you stop overselling?”Check and decrement in one atomic step. In SQL, a conditional UPDATE with a row-count check, not SELECT then UPDATE
“Singleton for the Catalog?”No. A Singleton — a class that permits exactly one instance, reachable globally — is a global variable with better manners: it hides the dependency, and it makes two tests running different catalogs in one process impossible. Construct it once in main and pass it in, which is dependency injection (Singleton the honest answer)
“Can the cart compute its own total?”No. The total depends on promotions, membership, and the date, none of which the cart owns. PricingEngine.price(cart)
“How do you reprice a six-month-old order?”You do not. You stored a frozen Quote with per-line discount allocation, because rules and prices have both changed since
“Tax?”On the post-discount total, per tax class, computed by the same integer path. Groceries are frequently zero-rated — taxed at 0%, which is different from exempt — and prepared food is not, which is a TaxClass field on Product, never an if statement in checkout

11. What the class structure assumes

Every class boundary above answers one question: what is expected to change, and what is not? An object model is only as good as that judgement, so it is worth spelling out: what the design treats as varying, what it treats as fixed, and what a different call would have produced.

What is assumed to vary, and therefore made cheap to change

What is assumed fixed, and therefore hard-coded into the structure

What a different assumption would have produced

These are the four alternatives most likely to be probed; the right column is the edit you would be signing up for.

If this had been assumed insteadThe structure that follows
Multiple currenciesCents = int becomes a Money value object carrying an amount and a currency code, with addition that refuses to mix them. Every signature in the engine changes, which is why the decision has to be made on day one
Promotions must see per-line state, not just a running subtotalapply(lines, running) becomes apply(lines, running, ctx) where ctx carries per-line discount history. The engine then makes two passes — one to compute eligibility, one to apply — and the single-pass ordering argument in Decision 1 promotions are strategies and they do not commute has to be redone
Only one promotion may ever applyThe Stage machinery disappears entirely and is replaced by “evaluate all, keep the cheapest total”, which is a different algorithm with a different cost: n evaluations instead of n applications, and no ordering question at all
The register is offline-first and syncs laterInventory stops being authoritative at the till. Reservations become optimistic and reconciled afterwards, Order needs a device-local identifier to deduplicate on sync, and the oversell race of Decision 2 inventory and the oversell race moves from a lock to a merge policy

Two terms in that table are jargon, and both are worth being able to define on the spot:

One sentence that makes this visible to an interviewer: “I have assumed one currency, whole-number quantities, and that a promotion only ever needs the running subtotal. The first two are cheap to state and expensive to retrofit, so I would confirm them now. The third is the one I would expect to be wrong first, because ‘no double discounts’ is a rule every store eventually wants, and it is the reason I would pass a context object rather than positional arguments even before anyone asks for it.”

12. Cheat sheet

Everything above compressed to what you would want in front of you before the interview.

MoneyInteger minor units plus a currency tag. Decimal if you need sub-cent unit prices. Never float
RoundingOne function, one documented direction. Discounts to the customer, tax half-up
PromotionsStrategy, one class per rule, with an explicit Stage so the order is declared and not accidental
The numberSame cart, same two promos: $19.00 percentage-first vs $20.00 fixed-first
StackingA separate concern from ordering: exclusivity groups, resolved within a stage, best discount wins
Cart vs OrderCart mutates and has no total. Order is frozen and holds the quote, the discount log, and the per-line allocation
InventoryCheck-and-decrement is one atomic operation. SELECT then UPDATE is the oversell
ReturnsNeed per-line discount allocation, stored at settle. Largest-remainder split so cents reconcile
AssumesOne currency, integer quantities, single-pass pricing, one shopper per cart (What the class structure assumes)
Do notLet Cart hold a total, let list order decide pricing, or make Catalog a Singleton

Next: 10 — Tic-Tac-Toe Game is the opposite failure mode — the design is genuinely small, and the way to fail it is to make it big.