Skip to content

Design an online auction

TL;DR

  • You build listings with a start and reserve price, bids that must clear an increment, proxy bidding up to a maximum, live notifications, automatic closing and payment on win.
  • Three decisions carry the interview: every bid is a maximum, which turns proxy resolution into one sort rather than a bidding loop; one lock per auction plus a version check so concurrent bids cannot interleave; and anti-sniping with a bounded number of extensions so the auction actually ends.
  • Observer for watchers, State for the auction lifecycle, Strategy for increments and closing, Mediator for the wiring. Singleton for the scheduler is discussed and deliberately not used.

Problem statement

"Design an online auction site. Sellers list items with a start price, a secret reserve and a closing time. Buyers bid, and a bid must beat the current price by at least the increment. Buyers can also set a maximum and let the system bid on their behalf. Watchers get notified when the price moves and when they are outbid. When the clock runs out the highest bid wins if it meets the reserve, and the winner is charged. Focus on the classes, the bidding rule, and what happens when two bids arrive at the same instant — or at the closing instant."

Requirements

Functional

  • Listings with an item, start price, reserve price, start time and end time.
  • A bid must be at least the current price plus the increment; the first bid pays the start price.
  • Proxy bidding: a bidder states a maximum and the system bids the minimum needed on their behalf.
  • Notifications to watchers when the price moves, and to a bidder the moment they are outbid.
  • Automatic close at the end time: highest bidder wins if the reserve is met, otherwise the listing goes unsold.
  • Anti-sniping: a bid near the end pushes the end out, a bounded number of times.
  • Payment on win, through a gateway interface.
  • Full bid history; the seller may withdraw a listing only before the first bid.

Non-functional and constraints

  • Concurrent bids on the same listing must produce one leader and a price that only ever rises.
  • Proxy resolution must terminate. Two proxies must not be able to bid against each other forever.
  • Money is common.Money (integer cents).
  • Time is injected, so closing behaviour is testable to the second without sleeping.
  • In-memory, single process; the store is behind a repository you could back with SQL.

Out of scope: search and browse, shipping and taxes, seller ratings, Dutch and sealed-bid formats (a follow-up), shill-bid detection beyond the seller-cannot-bid rule.

Clarifying questions and assumptions

Question to ask Assumption taken here
Does a bid mean "pay this" or "pay up to this"? Up to this. Every bid is a maximum, which is what makes manual and proxy bidding the same code path.
What does the first bidder pay? The start price, not their maximum — the same rule real marketplaces use, and the reason proxy bidding is worth having.
What happens on a tie of maximums? The bidder who committed to that maximum first wins, at that maximum. AutoBid.sequence is the tie-break.
Is the reserve visible? No. Bidders see only whether it has been met; the class exposes reserve_met(), not the number.
Is a bid at exactly the closing instant accepted? No: the check is now < ends_at. Pick a side of the boundary and say it out loud, because the interviewer will ask.
Can anti-sniping extend forever? No. AntiSnipeExtension has a max_extensions bound; without it two bidders can keep an auction open indefinitely.
Do we need a background thread for closing? No. CloseScheduler is polled by tick(now), so tests control time exactly; production swaps in a timer wheel.

Core entities and relationships

  • Auction — the aggregate: item, seller, start and reserve price, window, status, current price, leader, bid count, extension count, and a version that changes on every accepted bid.
  • AutoBid — one row per bidder per auction: their maximum plus the sequence in which they committed to it. A manual bid creates or raises one; there is no separate "manual bid" type.
  • Bid — the history row: who led, at what price, when, and whether a proxy raised it rather than the bidder.
  • AuctionStatus with AUCTION_TRANSITIONS — six states, and CLOSED genuinely means "bidding over, settlement pending".
  • IncrementStrategy (FixedIncrement, TieredIncrement, PercentIncrement) and ClosingPolicy (HardClose, AntiSnipeExtension), bundled into AuctionRules.
  • BidValidator — every refusal reason in one place, in the order it should be checked.
  • BidService resolves proxies; AuctionService lists, opens, cancels and settles; CloseScheduler knows what is due; NotificationService is the observer; PaymentGateway is the protocol for charging the winner.
  • AuctionHouse is the Mediator: the only object that knows all of the above.

Multiplicities: auction 1 -> * bids, auction 1 -> * auto-bids (at most one per bidder), auction 1 -> 1 item, house 1 -> * listeners.

Class diagram

The domain: a listing, the maxima behind it, and the history it leaves.

classDiagram
    class Auction {
        +id: str
        +start_price: Money
        +reserve_price: Money
        +starts_at: float
        +ends_at: float
        +status: AuctionStatus
        +current_price: Money
        +leader_id: str
        +bid_count: int
        +extension_count: int
        +version: int
        +transition_to(status)
        +is_live(now) bool
        +reserve_met() bool
    }
    class Item {
        +id: str
        +title: str
    }
    class Seller {
        +id: str
        +name: str
    }
    class Bidder {
        +id: str
        +name: str
    }
    class AutoBid {
        +bidder_id: str
        +maximum: Money
        +sequence: int
        +raise_to(maximum, at)
    }
    class Bid {
        +id: str
        +bidder_id: str
        +amount: Money
        +at: float
        +automatic: bool
    }
    class AuctionEvent {
        +kind: EventKind
        +auction_id: str
        +actor_id: str
        +message: str
    }
    class AuctionStatus {
        <<enumeration>>
        SCHEDULED
        OPEN
        CLOSED
        SOLD
        UNSOLD
        CANCELLED
    }
    class EventKind {
        <<enumeration>>
        BID_PLACED
        OUTBID
        EXTENDED
        CLOSED
        SOLD
    }
    Auction "1" *-- "1" Item
    Auction --> Seller
    Auction --> AuctionStatus
    Auction "1" o-- "many" AutoBid
    Auction "1" o-- "many" Bid
    AutoBid --> Bidder
    Bid --> Bidder
    AuctionEvent --> EventKind

The services: two pluggable policies, one validator, and the mediator that owns the wiring.

classDiagram
    class IncrementStrategy {
        <<interface>>
        +minimum_next(current) Money
    }
    class FixedIncrement
    class TieredIncrement
    class PercentIncrement
    class ClosingPolicy {
        <<interface>>
        +next_end_time(auction, bid_at) float
    }
    class HardClose
    class AntiSnipeExtension {
        +window_seconds: float
        +max_extensions: int
    }
    class AuctionRules {
        +increment: IncrementStrategy
        +closing: ClosingPolicy
        +default() AuctionRules
    }
    class BidValidator {
        +minimum_next(auction) Money
        +check(auction, bidder_id, maximum, now)
    }
    class BidService {
        +place_bid(auction_id, bidder_id, maximum) tuple
    }
    class AuctionService {
        +list_item(...) Auction
        +open_auction(auction_id) Auction
        +cancel(auction_id, seller_id) tuple
        +close(auction_id) tuple
    }
    class CloseScheduler {
        +due(now) List~str~
    }
    class AuctionStore {
        +locked(auction_id)
        +get(auction_id) Auction
        +proxies(auction_id) Dict~str,AutoBid~
        +commit(auction, expected_version, proxies, bid) Auction
    }
    class AuctionListener {
        <<interface>>
        +on_event(event)
    }
    class NotificationService {
        +watch(auction_id, user_id)
        +on_event(event)
        +inbox(user_id) List~AuctionEvent~
    }
    class PaymentGateway {
        <<interface>>
        +charge(bidder_id, amount) bool
    }
    class AlwaysApprovesGateway
    class AuctionHouse {
        +place_bid(auction_id, bidder_id, maximum) Auction
        +cancel(auction_id, seller_id) Auction
        +tick(now) List~Auction~
        +history(auction_id) List~Bid~
    }
    IncrementStrategy <|.. FixedIncrement
    IncrementStrategy <|.. TieredIncrement
    IncrementStrategy <|.. PercentIncrement
    ClosingPolicy <|.. HardClose
    ClosingPolicy <|.. AntiSnipeExtension
    AuctionRules --> IncrementStrategy
    AuctionRules --> ClosingPolicy
    BidValidator --> AuctionRules
    BidService --> BidValidator
    BidService --> AuctionStore
    AuctionService --> AuctionStore
    AuctionService --> PaymentGateway
    CloseScheduler --> AuctionStore
    PaymentGateway <|.. AlwaysApprovesGateway
    AuctionListener <|.. NotificationService
    AuctionHouse --> BidService
    AuctionHouse --> AuctionService
    AuctionHouse --> CloseScheduler
    AuctionHouse --> "many" AuctionListener : notifies

Design patterns applied

Pattern Where Why it earns its place
Strategy IncrementStrategy, ClosingPolicy The two rules an auction house actually argues about. "Charity auctions extend for five minutes" is a constructor argument, not a code change.
State (transition table) AuctionStatus + AUCTION_TRANSITIONS Six states with explicit legal moves. CLOSED -> SOLD and CLOSED -> UNSOLD are the only ways out of CLOSED, which is what makes settlement restartable.
Observer AuctionListener / NotificationService Watchers, the outbid bidder, an analytics sink and an anti-shill monitor all attach the same way, and the bidder's thread never waits for them.
Mediator AuctionHouse BidService does not know notifications exist; CloseScheduler does not know how to close anything. All the cross-talk lives in one class, which is the difference between a mediator and a god object: it wires, it does not compute.
Repository with CAS AuctionStore.get / commit Read a copy, mutate it, hand it back with the version you read. The version check makes the "did I lose a race?" question explicit rather than implicit.
Polymorphism over conditionals AutoBid for manual and proxy bids alike There is no if is_proxy anywhere, because there is no such distinction in the model.

What was deliberately not used: Singleton for CloseScheduler. It is the reflex answer — "there is only one scheduler" — but one instance created in main and injected gives you the same guarantee, lets tests run a dozen independent auction houses in one process, and turns "shard the scheduler by region" into a second object instead of a redesign. Say that out loud; it is the same judgement call as the parking lot's ParkingLot. Also no Command object for bids: a bid cannot be undone, so an undo stack would be a pattern applied to a domain that forbids it.

Key flows

Placing a bid: validate, record the maximum, resolve, extend, notify.

sequenceDiagram
    autonumber
    actor C as Carol
    participant H as AuctionHouse
    participant B as BidService
    participant K as AuctionStore
    participant V as BidValidator
    participant R as AuctionRules
    participant N as NotificationService
    C->>H: place_bid(auction, carol, max 260.00)
    H->>B: place_bid
    B->>K: locked(auction) then get (version v)
    B->>V: check status, window, seller, minimum next
    V-->>B: ok
    B->>K: proxies(auction)
    B->>B: record carol's maximum, sort by maximum then sequence
    B->>R: increment.minimum_next(runner_up maximum)
    R-->>B: contested price
    B->>B: price is min(leader maximum, contested)
    B->>R: closing.next_end_time(auction, now)
    alt inside the anti-snipe window
        R-->>B: a later end time
        B->>B: extend, extension_count + 1
    end
    B->>K: commit(auction, v, proxies, bid)
    K-->>B: version v+1
    B-->>H: auction and events
    H-)N: bid_placed, extended, outbid

Auction lifecycle. CLOSED is a real state, not a synonym for finished: the gateway is called after the close commits, so a crash mid-settlement leaves an auction the scheduler can pick up again.

stateDiagram-v2
    [*] --> SCHEDULED : list_item
    SCHEDULED --> OPEN : open_auction
    SCHEDULED --> CANCELLED : seller withdraws
    OPEN --> CANCELLED : withdrawn before the first bid
    OPEN --> CLOSED : the clock runs out
    CLOSED --> SOLD : reserve met and payment taken
    CLOSED --> UNSOLD : reserve missed or payment failed
    SOLD --> [*]
    UNSOLD --> [*]
    CANCELLED --> [*]

Implementation

Write the vocabulary, then the aggregate, then the two policies, then the bidding algorithm — which is the part the interviewer is waiting for.

The enums carry the transition table, and AutoBid carries the modelling decision that makes everything else simple.

code/lld/online_auction/models.py — enums and transitions
class AuctionStatus(StrEnum):
    SCHEDULED = "scheduled"  # created, not yet open for bids
    OPEN = "open"  # accepting bids
    CLOSED = "closed"  # the clock ran out, settlement not finished
    SOLD = "sold"  # reserve met and payment taken
    UNSOLD = "unsold"  # reserve not met, or the winner's payment failed
    CANCELLED = "cancelled"  # pulled by the seller before the first bid


class EventKind(StrEnum):
    BID_PLACED = "bid_placed"
    OUTBID = "outbid"
    EXTENDED = "extended"
    CLOSED = "closed"
    SOLD = "sold"
    UNSOLD = "unsold"
    CANCELLED = "cancelled"


TERMINAL_STATUSES = frozenset({AuctionStatus.SOLD, AuctionStatus.UNSOLD, AuctionStatus.CANCELLED})

AUCTION_TRANSITIONS: Mapping[AuctionStatus, frozenset[AuctionStatus]] = {
    AuctionStatus.SCHEDULED: frozenset({AuctionStatus.OPEN, AuctionStatus.CANCELLED}),
    AuctionStatus.OPEN: frozenset({AuctionStatus.CLOSED, AuctionStatus.CANCELLED}),
    AuctionStatus.CLOSED: frozenset({AuctionStatus.SOLD, AuctionStatus.UNSOLD}),
    AuctionStatus.SOLD: frozenset(),
    AuctionStatus.UNSOLD: frozenset(),
    AuctionStatus.CANCELLED: frozenset(),
}
code/lld/online_auction/models.py — errors
class BidTooLowError(ValidationError):
    """The offered maximum does not reach the minimum next bid."""


class AuctionClosedError(InvalidStateError):
    """The auction is not open, or the bid arrived after the closing instant."""


class NotCancellableError(ConflictError):
    """A seller cannot pull an auction once somebody has bid on it."""


class UnknownEntityError(NotFoundError):
    """No such auction, bidder or bid."""
code/lld/online_auction/models.py — entities
@dataclass(frozen=True, slots=True)
class Seller:
    id: str
    name: str


@dataclass(frozen=True, slots=True)
class Bidder:
    id: str
    name: str


@dataclass(frozen=True, slots=True)
class Item:
    id: str
    title: str
    description: str


@dataclass(frozen=True, slots=True)
class Bid:
    """A row in the history: who was leading, at what price, at what moment."""

    id: str
    auction_id: str
    bidder_id: str
    amount: Money
    at: float
    automatic: bool  # True when a proxy raised the price without the bidder acting


@dataclass(slots=True)
class AutoBid:
    """One bidder's standing instruction: never pay more than ``maximum``.

    ``sequence`` is the arrival order and is the tie-break: when two bidders
    share the same maximum, the one who committed to it first wins.
    """

    auction_id: str
    bidder_id: str
    maximum: Money
    sequence: int
    at: float

    def raise_to(self, maximum: Money, at: float) -> None:
        if maximum <= self.maximum:
            raise BidTooLowError(f"{self.bidder_id} already committed up to {self.maximum}")
        self.maximum = maximum
        self.at = at

    def copy(self) -> AutoBid:
        return AutoBid(self.auction_id, self.bidder_id, self.maximum, self.sequence, self.at)


@dataclass(slots=True)
class Auction:
    """The aggregate. ``version`` changes on every accepted bid: it is the CAS token."""

    id: str
    item: Item
    seller_id: str
    start_price: Money
    reserve_price: Money
    starts_at: float
    ends_at: float
    status: AuctionStatus = AuctionStatus.SCHEDULED
    current_price: Money = Money(0)
    leader_id: str | None = None
    bid_count: int = 0
    extension_count: int = 0
    winner_id: str | None = None
    final_price: Money | None = None
    version: int = 0

    def transition_to(self, status: AuctionStatus) -> None:
        if status not in AUCTION_TRANSITIONS[self.status]:
            raise AuctionClosedError(f"auction {self.id}: {self.status} cannot become {status}")
        self.status = status

    def is_live(self, now: float) -> bool:
        return self.status is AuctionStatus.OPEN and self.starts_at <= now < self.ends_at

    def reserve_met(self) -> bool:
        return self.current_price >= self.reserve_price

    def copy(self) -> Auction:
        return Auction(
            self.id, self.item, self.seller_id, self.start_price, self.reserve_price, self.starts_at,
            self.ends_at, self.status, self.current_price, self.leader_id, self.bid_count,
            self.extension_count, self.winner_id, self.final_price, self.version,
        )


@dataclass(frozen=True, slots=True)
class AuctionEvent:
    """What listeners receive. Frozen, so a watcher cannot rewrite the record."""

    kind: EventKind
    auction_id: str
    actor_id: str
    message: str
    at: float

Increments are banded in real marketplaces because a fixed step is either absurd at a dollar or invisible at a thousand. Closing is a policy for the same reason.

code/lld/online_auction/strategies.py — increments
class IncrementStrategy(Protocol):
    """Given the current price, what is the smallest acceptable next maximum?"""

    def minimum_next(self, current: Money) -> Money: ...


class FixedIncrement:
    """One step for every price. Simple, and wrong for a catalogue spanning 1 to 10 000."""

    def __init__(self, step: Money) -> None:
        if step.cents <= 0:
            raise ValidationError("increment must be positive")
        self._step = step

    def minimum_next(self, current: Money) -> Money:
        return current + self._step


class PercentIncrement:
    """A share of the current price with a floor, for categories with a wide range."""

    def __init__(self, basis_points: int, floor: Money) -> None:
        self._basis_points = basis_points
        self._floor = floor

    def minimum_next(self, current: Money) -> Money:
        step = Money(current.cents * self._basis_points // 10_000, current.currency)
        return current + (step if step > self._floor else self._floor)


# (upper bound in cents, step in cents); the last band has no upper bound.
DEFAULT_BANDS: tuple[tuple[int, int], ...] = (
    (100, 5),
    (500, 25),
    (2_500, 50),
    (10_000, 100),
    (25_000, 250),
    (50_000, 500),
)
DEFAULT_TOP_STEP = 1_000


class TieredIncrement:
    """Bands, the way real marketplaces do it: 5 cents at a dollar, 10 dollars at a thousand."""

    def __init__(
        self, bands: tuple[tuple[int, int], ...] = DEFAULT_BANDS, top_step: int = DEFAULT_TOP_STEP
    ) -> None:
        self._bands = bands
        self._top_step = top_step

    def minimum_next(self, current: Money) -> Money:
        for upper, step in self._bands:
            if current.cents < upper:
                return Money(current.cents + step, current.currency)
        return Money(current.cents + self._top_step, current.currency)
code/lld/online_auction/strategies.py — closing
class ClosingPolicy(Protocol):
    """Given a bid that has just been accepted, when should the auction now end?"""

    def next_end_time(self, auction: Auction, bid_at: float) -> float: ...


class HardClose:
    """The clock is the clock. Cheap, and the reason sniping works."""

    def next_end_time(self, auction: Auction, bid_at: float) -> float:
        return auction.ends_at


class AntiSnipeExtension:
    """A bid inside the window pushes the end out, up to a bounded number of times.

    The bound matters: without it two determined bidders can extend an auction
    indefinitely, and the auction never settles.
    """

    def __init__(self, window_seconds: float = 120.0, extension_seconds: float = 120.0, max_extensions: int = 5) -> None:
        self._window = window_seconds
        self._extension = extension_seconds
        self._max_extensions = max_extensions

    def next_end_time(self, auction: Auction, bid_at: float) -> float:
        inside_window = auction.ends_at - bid_at <= self._window
        if inside_window and auction.extension_count < self._max_extensions:
            return max(auction.ends_at, bid_at + self._extension)
        return auction.ends_at


@dataclass(frozen=True, slots=True)
class AuctionRules:
    """The pair of policies an auction runs under. Injected, never looked up globally."""

    increment: IncrementStrategy
    closing: ClosingPolicy

    @classmethod
    def default(cls) -> AuctionRules:
        return cls(TieredIncrement(), AntiSnipeExtension())

The store gives you a copy and takes back a version. Read commit carefully: it is a compare-and-set, and it is the assertion that the lock discipline holds.

code/lld/online_auction/store.py
class AuctionStore:
    """Auctions, their bid history and one proxy record per bidder per auction."""

    def __init__(self) -> None:
        self._registry_lock = threading.Lock()
        self._auctions: dict[str, Auction] = {}
        self._locks: dict[str, threading.RLock] = {}
        self._bids: dict[str, list[Bid]] = {}
        self._proxies: dict[str, dict[str, AutoBid]] = {}

    def add(self, auction: Auction) -> Auction:
        with self._registry_lock:
            self._auctions[auction.id] = auction
            self._locks[auction.id] = threading.RLock()
            self._bids[auction.id] = []
            self._proxies[auction.id] = {}
        return auction

    def get(self, auction_id: str) -> Auction:
        """A private copy. Mutate it, then hand it back to ``commit``."""
        with self._registry_lock:
            try:
                return self._auctions[auction_id].copy()
            except KeyError:
                raise UnknownEntityError(f"unknown auction {auction_id}") from None

    def proxies(self, auction_id: str) -> dict[str, AutoBid]:
        with self._registry_lock:
            return {bidder: proxy.copy() for bidder, proxy in self._proxies[auction_id].items()}

    def bids(self, auction_id: str) -> list[Bid]:
        with self._registry_lock:
            return list(self._bids[auction_id])

    def open_auction_ids(self) -> list[str]:
        with self._registry_lock:
            return sorted(self._auctions)

    @contextmanager
    def locked(self, auction_id: str) -> Iterator[None]:
        """The one lock that matters. Everything that touches an auction holds it."""
        with self._registry_lock:
            try:
                lock = self._locks[auction_id]
            except KeyError:
                raise UnknownEntityError(f"unknown auction {auction_id}") from None
        lock.acquire()
        try:
            yield
        finally:
            lock.release()

    def commit(
        self,
        auction: Auction,
        expected_version: int,
        proxies: dict[str, AutoBid] | None = None,
        bid: Bid | None = None,
    ) -> Auction:
        """Compare-and-set on the version, then publish auction, proxies and history."""
        with self._registry_lock:
            stored = self._auctions.get(auction.id)
            if stored is None:
                raise UnknownEntityError(f"unknown auction {auction.id}")
            if stored.version != expected_version:
                raise ConflictError(
                    f"auction {auction.id} moved from version {expected_version} to {stored.version}"
                )
            auction.version = expected_version + 1
            self._auctions[auction.id] = auction
            if proxies is not None:
                self._proxies[auction.id] = proxies
            if bid is not None:
                self._bids[auction.id].append(bid)
            return auction.copy()

Validation first, in a class of its own, so "why was my bid rejected?" has exactly one place to look.

code/lld/online_auction/services.py — validation
class BidValidator:
    """Every reason a bid is refused, in one place, in the order it should be checked."""

    def __init__(self, rules: AuctionRules) -> None:
        self._rules = rules

    def minimum_next(self, auction: Auction) -> Money:
        """The first bid pays the start price; after that, one increment over the leader."""
        if auction.bid_count == 0:
            return auction.start_price
        return self._rules.increment.minimum_next(auction.current_price)

    def check(self, auction: Auction, bidder_id: str, maximum: Money, now: float) -> None:
        if auction.status is not AuctionStatus.OPEN:
            raise AuctionClosedError(f"auction {auction.id} is {auction.status}")
        if now < auction.starts_at:
            raise AuctionClosedError(f"auction {auction.id} opens later")
        if now >= auction.ends_at:
            raise AuctionClosedError(f"auction {auction.id} closed at {auction.ends_at}")
        if bidder_id == auction.seller_id:
            raise ValidationError("a seller cannot bid on their own auction")
        if maximum.currency != auction.start_price.currency:
            raise ValidationError("bid currency does not match the listing")
        floor = self.minimum_next(auction)
        if maximum < floor:
            raise BidTooLowError(f"bid at least {floor}, got {maximum}")

BidService._resolve is the whole problem in eight lines. Sort the maxima, the top one leads, and it pays one increment over the runner-up, capped at its own maximum and floored at the current price.

code/lld/online_auction/services.py — bidding and proxy resolution
class BidService:
    """Places bids and resolves proxies.

    The resolution is deliberately not a loop. Every bidder is represented by a
    maximum, so the outcome is: the highest maximum leads, and pays one increment
    over the second-highest maximum, capped at its own maximum. That is one sort
    and two comparisons -- it always terminates, and two proxies cannot ping-pong.
    """

    def __init__(
        self,
        store: AuctionStore,
        rules: AuctionRules,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
    ) -> None:
        self._store = store
        self._rules = rules
        self._validator = BidValidator(rules)
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("B")

    def place_bid(self, auction_id: str, bidder_id: str, maximum: Money) -> tuple[Auction, list[AuctionEvent]]:
        now = self._clock.now()
        with self._store.locked(auction_id):
            auction = self._store.get(auction_id)
            version = auction.version
            self._validator.check(auction, bidder_id, maximum, now)
            proxies = self._store.proxies(auction_id)
            self._record_maximum(proxies, auction_id, bidder_id, maximum, now)

            previous_leader = auction.leader_id
            leader_id, price = self._resolve(auction, proxies)
            auction.current_price = price
            auction.leader_id = leader_id
            auction.bid_count += 1
            bid = Bid(self._ids.next_id(), auction_id, leader_id, price, now, automatic=leader_id != bidder_id)

            events = [
                AuctionEvent(EventKind.BID_PLACED, auction_id, bidder_id, f"{leader_id} leads at {price}", now)
            ]
            new_end = self._rules.closing.next_end_time(auction, now)
            if new_end > auction.ends_at:
                auction.ends_at = new_end
                auction.extension_count += 1
                events.append(
                    AuctionEvent(EventKind.EXTENDED, auction_id, bidder_id, f"extended to {new_end}", now)
                )
            if previous_leader is not None and previous_leader != leader_id:
                events.append(
                    AuctionEvent(EventKind.OUTBID, auction_id, previous_leader, f"outbid at {price}", now)
                )
            return self._store.commit(auction, version, proxies, bid), events

    @staticmethod
    def _record_maximum(
        proxies: dict[str, AutoBid], auction_id: str, bidder_id: str, maximum: Money, now: float
    ) -> None:
        """A manual bid is a proxy whose ceiling is the amount typed in."""
        existing = proxies.get(bidder_id)
        if existing is None:
            proxies[bidder_id] = AutoBid(auction_id, bidder_id, maximum, len(proxies) + 1, now)
        else:
            existing.raise_to(maximum, now)  # refuses a maximum that is not an increase

    def _resolve(self, auction: Auction, proxies: dict[str, AutoBid]) -> tuple[str, Money]:
        """Highest maximum wins; ties go to whoever committed first."""
        ordered = sorted(proxies.values(), key=lambda p: (-p.maximum.cents, p.sequence))
        leader = ordered[0]
        if len(ordered) == 1:
            price = auction.start_price
        else:
            contested = self._rules.increment.minimum_next(ordered[1].maximum)
            price = min(leader.maximum, contested)
        floor = max(auction.current_price, auction.start_price)
        return leader.bidder_id, max(price, floor)

Closing is two transactions with the gateway call between them, so no lock is held across a network call and a half-settled auction can be picked up again.

code/lld/online_auction/services.py — listing, closing and the scheduler
class AuctionService:
    """Listing, opening, cancelling and settling. Never touches proxies or increments."""

    def __init__(
        self,
        store: AuctionStore,
        gateway: PaymentGateway | None = None,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
    ) -> None:
        self._store = store
        self._gateway = gateway or AlwaysApprovesGateway()
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("A")

    def list_item(
        self,
        seller_id: str,
        item: Item,
        start_price: Money,
        reserve_price: Money,
        starts_at: float,
        ends_at: float,
    ) -> Auction:
        if ends_at <= starts_at:
            raise ValidationError("an auction must end after it starts")
        if start_price.cents <= 0 or reserve_price < start_price:
            raise ValidationError("reserve price must be at least the start price")
        auction = Auction(
            self._ids.next_id(), item, seller_id, start_price, reserve_price, starts_at, ends_at,
            current_price=start_price,
        )
        return self._store.add(auction)

    def open_auction(self, auction_id: str) -> Auction:
        with self._store.locked(auction_id):
            auction = self._store.get(auction_id)
            auction.transition_to(AuctionStatus.OPEN)
            return self._store.commit(auction, auction.version)

    def cancel(self, auction_id: str, seller_id: str) -> tuple[Auction, AuctionEvent]:
        """Only before the first bid: once someone has bid, the seller is committed."""
        with self._store.locked(auction_id):
            auction = self._store.get(auction_id)
            if auction.seller_id != seller_id:
                raise ValidationError("only the seller can cancel a listing")
            if auction.bid_count > 0:
                raise NotCancellableError(f"auction {auction.id} already has {auction.bid_count} bids")
            auction.transition_to(AuctionStatus.CANCELLED)
            now = self._clock.now()
            event = AuctionEvent(EventKind.CANCELLED, auction_id, seller_id, "listing withdrawn", now)
            return self._store.commit(auction, auction.version), event

    def close(self, auction_id: str) -> tuple[Auction, list[AuctionEvent]]:
        """Stop the clock, then settle. The gateway is called between two transactions."""
        now = self._clock.now()
        events: list[AuctionEvent] = []
        with self._store.locked(auction_id):
            auction = self._store.get(auction_id)
            if auction.status is AuctionStatus.OPEN:
                auction.transition_to(AuctionStatus.CLOSED)
                auction = self._store.commit(auction, auction.version)
                events.append(AuctionEvent(EventKind.CLOSED, auction_id, auction.seller_id, "bidding ended", now))
            elif auction.status is not AuctionStatus.CLOSED:
                raise AuctionClosedError(f"auction {auction_id} is {auction.status}")

        won = auction.leader_id is not None and auction.reserve_met()
        paid = won and self._gateway.charge(auction.leader_id or "", auction.current_price)

        with self._store.locked(auction_id):
            settled = self._store.get(auction_id)
            version = settled.version
            if paid:
                settled.winner_id = settled.leader_id
                settled.final_price = settled.current_price
                settled.transition_to(AuctionStatus.SOLD)
                events.append(
                    AuctionEvent(EventKind.SOLD, auction_id, settled.winner_id or "", f"sold at {settled.current_price}", now)
                )
            else:
                reason = "reserve not met" if not won else "payment failed"
                settled.transition_to(AuctionStatus.UNSOLD)
                events.append(AuctionEvent(EventKind.UNSOLD, auction_id, settled.seller_id, reason, now))
            return self._store.commit(settled, version), events


class CloseScheduler:
    """Knows which auctions are due. Deliberately not a Singleton: it is injected.

    Driven by ``tick(now)`` rather than a background thread, so tests and the demo
    control time exactly. A production version replaces ``due`` with a timer wheel
    or a delayed queue; nothing else changes.
    """

    def __init__(self, store: AuctionStore) -> None:
        self._store = store

    def due(self, now: float) -> list[str]:
        """Open auctions whose clock has run out, plus any left mid-settlement by a crash."""
        due: list[str] = []
        for auction_id in self._store.open_auction_ids():
            auction = self._store.get(auction_id)
            expired = auction.status is AuctionStatus.OPEN and now >= auction.ends_at
            if expired or auction.status is AuctionStatus.CLOSED:
                due.append(auction_id)
        return due
code/lld/online_auction/services.py — observer
class AuctionListener(Protocol):
    """Observer interface: anything that reacts to what happens in an auction."""

    def on_event(self, event: AuctionEvent) -> None: ...


class NotificationService:
    """Fans events out to the people watching that auction, plus the actor named on it."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._watchers: dict[str, set[str]] = {}
        self._inbox: dict[str, list[AuctionEvent]] = {}

    def watch(self, auction_id: str, user_id: str) -> None:
        with self._lock:
            self._watchers.setdefault(auction_id, set()).add(user_id)

    def on_event(self, event: AuctionEvent) -> None:
        with self._lock:
            recipients = set(self._watchers.get(event.auction_id, ()))
            if event.kind is EventKind.OUTBID:
                recipients = {event.actor_id}  # only the person who lost the lead
            for user_id in recipients:
                self._inbox.setdefault(user_id, []).append(event)

    def inbox(self, user_id: str) -> list[AuctionEvent]:
        with self._lock:
            return list(self._inbox.get(user_id, ()))


class PaymentGateway(Protocol):
    """Charging the winner is someone else's system; the auction only needs a verdict."""

    def charge(self, bidder_id: str, amount: Money) -> bool: ...


class AlwaysApprovesGateway:
    def charge(self, bidder_id: str, amount: Money) -> bool:
        return True
code/lld/online_auction/services.py — mediator
class AuctionHouse:
    """Mediator: the one object the app talks to, and the only one that knows the others.

    ``BidService`` does not know notifications exist; ``CloseScheduler`` does not
    know how to close anything; ``NotificationService`` does not know what a bid
    is. Adding an anti-shill detector or a second listener touches this class only.
    """

    def __init__(
        self,
        store: AuctionStore,
        auctions: AuctionService,
        bids: BidService,
        scheduler: CloseScheduler,
        listeners: Iterable[AuctionListener] = (),
        clock: Clock | None = None,
    ) -> None:
        self._store = store
        self._auctions = auctions
        self._bids = bids
        self._scheduler = scheduler
        self._listeners = list(listeners)
        self._clock = clock or SystemClock()

    def place_bid(self, auction_id: str, bidder_id: str, maximum: Money) -> Auction:
        auction, events = self._bids.place_bid(auction_id, bidder_id, maximum)
        self._publish(events)
        return auction

    def cancel(self, auction_id: str, seller_id: str) -> Auction:
        auction, event = self._auctions.cancel(auction_id, seller_id)
        self._publish([event])
        return auction

    def tick(self, now: float | None = None) -> list[Auction]:
        """Close everything whose clock has run out. Idempotent: nothing else is due after."""
        moment = self._clock.now() if now is None else now
        closed: list[Auction] = []
        for auction_id in self._scheduler.due(moment):
            auction, events = self._auctions.close(auction_id)
            self._publish(events)
            closed.append(auction)
        return closed

    def history(self, auction_id: str) -> list[Bid]:
        return self._store.bids(auction_id)

    def _publish(self, events: Iterable[AuctionEvent]) -> None:
        # Outside the auction lock: a slow listener must never block a bidder.
        for event in events:
            for listener in self._listeners:
                listener.on_event(event)

The demo runs one listing end to end: four maxima, a refused bid, a refused withdrawal, a snipe that extends the clock, and settlement.

code/lld/online_auction/demo.py
"""One listing: four proxy bidders, an outbid notice, an anti-snipe extension, a sale."""

from common import FakeClock, Money, SequentialIdGenerator
from lld.online_auction.models import BidTooLowError, Item, NotCancellableError
from lld.online_auction.services import (
    AuctionHouse,
    AuctionService,
    BidService,
    BidValidator,
    CloseScheduler,
    NotificationService,
)
from lld.online_auction.store import AuctionStore
from lld.online_auction.strategies import AuctionRules

HOUR = 3600.0


def main() -> None:
    clock = FakeClock(start=1_700_000_000)
    store = AuctionStore()
    rules = AuctionRules.default()
    notifications = NotificationService()
    auctions = AuctionService(store, clock=clock, ids=SequentialIdGenerator("A"))
    bids = BidService(store, rules, clock=clock, ids=SequentialIdGenerator("B"))
    house = AuctionHouse(store, auctions, bids, CloseScheduler(store), [notifications], clock=clock)
    validator = BidValidator(rules)

    item = Item("i-1", "Leica M6", "35mm rangefinder, 1987")
    auction = auctions.list_item("seller", item, Money.of("100.00"), Money.of("250.00"), clock.now(), clock.now() + HOUR)
    auctions.open_auction(auction.id)
    for watcher in ("alice", "bob", "carol"):
        notifications.watch(auction.id, watcher)
    print(f"{auction.id} {item.title}: start {auction.start_price}, reserve {auction.reserve_price}, minimum bid {validator.minimum_next(auction)}")

    for bidder, maximum in (("alice", "150.00"), ("bob", "200.00"), ("alice", "300.00"), ("carol", "260.00")):
        clock.advance(60)
        state = house.place_bid(auction.id, bidder, Money.of(maximum))
        print(f"{bidder} sets a maximum of {maximum} -> {state.leader_id} leads at {state.current_price}")
    print(f"reserve met: {store.get(auction.id).reserve_met()}, bob's inbox: {[e.kind.value for e in notifications.inbox('bob')]}")

    try:
        house.place_bid(auction.id, "dave", Money.of("261.00"))
    except BidTooLowError as exc:
        print(f"dave bids too little: {exc}")
    try:
        house.cancel(auction.id, "seller")
    except NotCancellableError as exc:
        print(f"seller cannot withdraw: {exc}")

    clock.set(store.get(auction.id).ends_at - 30)  # 30 s left: sniping territory
    state = house.place_bid(auction.id, "carol", Money.of("400.00"))
    print(f"carol snipes at 400.00 -> {state.leader_id} leads at {state.current_price}, extended {state.extension_count} time(s)")
    print(f"alice was told: {[e.message for e in notifications.inbox('alice') if e.kind.value == 'outbid']}")

    clock.set(state.ends_at - 1)
    print(f"one second left: tick closes {len(house.tick())} auction(s)")
    clock.advance(2)
    closed = house.tick()[0]
    print(f"{closed.status}: winner {closed.winner_id} at {closed.final_price} after {closed.bid_count} bids")
    print(f"history: {[f'{b.bidder_id} {b.amount}' for b in house.history(auction.id)]}")


if __name__ == "__main__":
    main()

Running python -m lld.online_auction.demo prints:

A-1 Leica M6: start 100.00 USD, reserve 250.00 USD, minimum bid 100.00 USD
alice sets a maximum of 150.00 -> alice leads at 100.00 USD
bob sets a maximum of 200.00 -> bob leads at 152.50 USD
alice sets a maximum of 300.00 -> alice leads at 202.50 USD
carol sets a maximum of 260.00 -> alice leads at 265.00 USD
reserve met: True, bob's inbox: ['bid_placed', 'bid_placed', 'bid_placed', 'outbid', 'bid_placed']
dave bids too little: bid at least 270.00 USD, got 261.00 USD
seller cannot withdraw: auction A-1 already has 4 bids
carol snipes at 400.00 -> carol leads at 305.00 USD, extended 1 time(s)
alice was told: ['outbid at 152.50 USD', 'outbid at 305.00 USD']
one second left: tick closes 0 auction(s)
sold: winner carol at 305.00 USD after 5 bids
history: ['alice 100.00 USD', 'bob 152.50 USD', 'alice 202.50 USD', 'alice 265.00 USD', 'carol 305.00 USD']

Line 5 is the one to point at: Carol committed 260.00 and lost, but her bid still moved the price to 265.00 — one 5.00 band step over her maximum, paid by Alice's proxy. That is proxy bidding working, and it is the behaviour candidates most often get wrong.

Concurrency and edge cases

Which lock protects what. AuctionStore.locked(auction_id) is a reentrant lock around one auction. Everything that touches that auction — placing a bid, cancelling, closing — holds it for the whole read-modify-write. Two listings never contend, because nothing in this design crosses auctions. AuctionStore._registry_lock guards the dictionaries and is held for a lookup; it is a leaf, and it is the only lock ever acquired underneath the auction lock, so there is no ordering problem to reason about.

Why the version is still there. commit(auction, expected_version) refuses to write if the stored version has moved. Under the lock it cannot have moved, so the check is an assertion rather than a retry loop — and it is exactly the mechanism you keep when you move to a shared database: drop the lock, retry on conflict, and the bidding code does not change. A test proves the check fires on a stale copy.

Concurrent bids. The critical section is a sort over the auction's proxies plus a dict write — microseconds, and an uncontended mutex is about 17 ns (see the latency cheatsheet). One lock can therefore pass far more bids per second than any single listing generates, so per-auction locking costs nothing and buys a price that only ever rises. The concurrency test fires 30 bids from 10 threads and asserts three invariants: one leader, that leader has the highest accepted maximum, and the recorded prices are non-decreasing.

Bids at the closing instant. The bid and the close take the same lock, so they serialise; the only question is which side of now < ends_at you put the boundary. This design rejects a bid at exactly ends_at. What you must not do is check the time outside the lock and act inside it — that is how a bid lands on an auction that has already been settled.

Proxy termination. The naive implementation is a loop: A's proxy outbids B, B's proxy outbids A, repeat. It does terminate, because each round raises the price by at least one increment and the maxima are finite — but you do not need the loop at all. Resolving from the maxima directly is O(n log n) once, with no intermediate history rows and no chance of an infinite exchange if someone later makes increments zero-valued.

Anti-sniping must be bounded. AntiSnipeExtension counts extensions and stops. Without the bound, two bidders with deep pockets keep an auction open forever and the seller never gets paid.

Common mistake

Treating a bid as "the amount the bidder pays" and then bolting proxy bidding on as a second mechanism that loops. You end up with two code paths, an ordering bug between them, and a bidding war that can spin. Model every bid as a maximum from the first minute, and proxy bidding stops being a feature and becomes the default behaviour of one sort.

Other edge cases handled: a bid below the minimum next (rejected, nothing written, version unchanged); the seller bidding on their own listing; a bid in a different currency; a bidder trying to lower their own maximum; a tie of maximums; a reserve that is never met; a payment that fails at settlement; a withdrawal after the first bid; a second tick after settlement returning nothing; an auction left CLOSED by a crash, which the scheduler picks up again.

Extensibility and follow-ups

  • Dutch and sealed-bid auctions: both are a different IncrementStrategy plus a different resolution rule. Extract _resolve into a PricingRule strategy and a Dutch auction becomes "the price falls until someone accepts", with the same store, scheduler and notifications.
  • Buy it now: a price on the auction and a buy_now method that takes the same lock, transitions straight to CLOSED, and settles. It composes with everything already here because the lock is the auction.
  • Shill-bid detection: a listener on the event stream that flags a bidder who repeatedly pushes a specific seller's prices and never wins. It attaches like any other AuctionListener and never touches BidService.
  • Escrow instead of a direct charge: PaymentGateway grows hold and release; close holds at settlement and releases on delivery confirmation. That is the payment gateway and wallet problem underneath.
  • Clock skew: every timestamp here comes from an injected Clock. In a distributed deployment the closing decision must be made by one authority — the scheduler's clock — and bid timestamps recorded by that same clock, never by the client's.
  • Scale: a hot listing is a hot key. The per-auction lock becomes a row lock or a single-partition actor; the notification fan-out becomes a topic per auction; and the scheduler becomes a timer wheel or a delayed queue. That is the point where this becomes an HLD conversation.

Interview tip

When you get to proxy bidding, resist the loop. Say "every bid is a maximum, so the leader is the highest maximum and the price is one increment over the second-highest" and write the four-line _resolve. It is shorter than the loop, obviously terminating, and it is exactly how the real systems work — which is a strong signal that you have thought about the domain and not just the code.

Tests

tests/test_online_auction.py has 16 cases. The three worth walking through are the anti-snipe bound, the concurrency invariants, and the closing-instant boundary:

code/lld/online_auction/tests/test_online_auction.py — anti-sniping
def test_anti_snipe_extends_the_close_but_only_a_bounded_number_of_times(clock: FakeClock) -> None:
    rules = AuctionRules(TieredIncrement(), AntiSnipeExtension(window_seconds=60, extension_seconds=60, max_extensions=2))
    rig = Rig(clock, rules)
    auction_id = rig.open_listing(start="100.00", reserve="100.00", duration=300)
    rig.house.place_bid(auction_id, "alice", Money.of("120.00"))

    for round_number in range(4):  # four snipes, but only two extensions are allowed
        clock.set(rig.store.get(auction_id).ends_at - 10)
        rig.house.place_bid(auction_id, "bob", Money.of(f"{200 + round_number * 50}.00"))

    auction = rig.store.get(auction_id)
    assert auction.extension_count == 2  # the bound is what makes the auction terminate
    clock.set(auction.ends_at + 1)
    assert rig.house.tick()[0].status is AuctionStatus.SOLD
code/lld/online_auction/tests/test_online_auction.py — concurrency
def test_concurrent_bids_leave_one_leader_and_a_price_that_only_goes_up(clock: FakeClock) -> None:
    rig = Rig(clock, AuctionRules(TieredIncrement(), HardClose()))
    auction_id = rig.open_listing(start="100.00", reserve="100.00")
    accepted: dict[str, Money] = {}
    guard = threading.Lock()

    def bid(i: int) -> None:
        maximum = Money.of(f"{200 + i * 10}.00")
        try:
            rig.house.place_bid(auction_id, f"bidder-{i:02d}", maximum)
        except BidTooLowError:
            return  # the price ran ahead while this thread was queued: a legitimate refusal
        with guard:
            accepted[f"bidder-{i:02d}"] = maximum

    with ThreadPoolExecutor(max_workers=10) as pool:
        list(pool.map(bid, range(30)))

    auction = rig.store.get(auction_id)
    prices = [b.amount.cents for b in rig.house.history(auction_id)]
    assert auction.leader_id == max(accepted, key=lambda b: accepted[b].cents)  # highest maximum wins
    assert auction.current_price <= accepted[auction.leader_id]  # never pays above its own ceiling
    assert prices == sorted(prices) and auction.bid_count == len(accepted)

The rest cover: the first bid paying the start price and a proxy paying one increment over the runner-up; a tie going to whoever committed first; a bid below the minimum and a seller bidding on their own listing, both leaving the auction untouched; a bid at exactly the closing instant refused while tick settles the auction; a reserve that is not met and a payment that fails, both ending unsold; withdrawal allowed only before the first bid; a stale version rejected by the compare-and-set; and all three increment strategies via parametrize. Run them with uv run pytest code/lld/online_auction -q.

45-minute pacing

Minutes What to do What to say or write
0-5 Clarify Does a bid mean "pay up to"? Is the reserve secret? Anti-sniping? What happens at exactly the end time? Out of scope: search, shipping, ratings.
5-11 Entities Auction, Item, Bid, AutoBid, and the sentence "every bid is a maximum". Draw the six-state lifecycle.
11-18 Class diagram Domain first, then hang IncrementStrategy and ClosingPolicy off AuctionRules and mark the per-auction lock.
18-33 Code BidValidator.check, then _resolve (the sort), then place_bid with the extension and the events. Narrate "validate, record, resolve, extend, commit, notify".
33-40 Concurrency and closing The auction lock, the version check, the closing-instant boundary, and why extensions are bounded.
40-45 Extensions Dutch auctions as a strategy, buy-it-now, shill detection as a listener, and the hand-off to a hot-key HLD discussion.