Engineering Atlas › Low-Level Design

Code an auction with bids and winners

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope for the interview: proxy (maximum) bidding, anti-sniping extensions, payments and shipping. The Design Online Auction System system design lesson covers proxy bids, soft close, partitioning and watcher fan-out. This lesson is about the object model and the in-process behaviour that sits inside one of those partitions.

Entities and relationships

The house composes auctions (they do not exist outside it). An auction aggregates its accepted bids. Bids reference users by id only, so deleting a user's profile never rewrites auction history.

Class diagram

classDiagram
    class AuctionHouse {
      -auctions: Map~AuctionId, Auction~
      -clock: Clock
      -hub: NotificationHub
      +create(seller, terms) AuctionId
      +bid(auctionId, bidder, amount) BidResult
      +cancel(auctionId, seller) CancelResult
      +closeDue() Settlement[]
      +status(auctionId) Status
    }
    class Auction {
      +id: AuctionId
      +seller: UserId
      +startPrice: int
      +reserve: int
      +startAt: int
      +endAt: int
      -high: int
      -leader: UserId
      -bids: Bid[]
      -outcome: Outcome
      +place(bidder, amount, now) BidResult
      +settle(now) Settlement
    }
    class Bid {
      +bidder: UserId
      +amount: int
      +at: int
      +seq: int
    }
    class IncrementPolicy {
      <<interface>>
      +minNext(high) int
    }
    class NotificationHub {
      +subscribe(observer)
      +publish(event)
    }
    class Clock {
      <<interface>>
      +now() int
    }
    AuctionHouse "1" *-- "many" Auction
    Auction "1" o-- "many" Bid
    Auction --> IncrementPolicy
    AuctionHouse --> Clock
    AuctionHouse --> NotificationHub
    FixedIncrement ..|> IncrementPolicy
    TieredIncrement ..|> IncrementPolicy

Key flows

A bid that takes the lead, followed later by settlement. Notice that the hub publishes after the auction releases its lock.

sequenceDiagram
    participant B as Bidder
    participant H as AuctionHouse
    participant A as Auction
    participant N as NotificationHub
    participant S as Settler
    B->>H: bid(A7, bob, 150)
    H->>A: lock, place(bob, 150, now)
    A->>A: check window, seller, minNext
    A-->>H: accepted, previous leader ann
    H->>H: unlock
    H->>N: publish outbid(ann, A7, 150)
    H-->>B: accepted
    S->>H: closeDue() at end time
    H->>A: lock, settle(now)
    A->>A: high 150 >= reserve 120
    A-->>H: sold to bob at 150
    H->>N: publish won(bob), sold(seller)

Design patterns used and why

Concurrency and edge cases

Down the stack

Layer 1The contract

bid returns one of accepted, too low, not open, closed or own listing, and only "accepted" changes state. An accepted bid is at least the start price (first bid) or at least the previous high plus the increment, and it was stamped strictly before endAt. cancel succeeds only for the seller, before any bid and before the end. closeDue assigns each ended auction exactly one terminal outcome, and a second call returns nothing for auctions already settled. Notifications are at-least-once and may lag; the auction state is the source of truth.

Layer 2The mechanism

In memory, a hash map from auction id to auction, and one mutex per auction rather than one for the whole house, so a rush on one listing does not slow the others. Settlement keeps a min-heap keyed by (endAt, creation sequence); closeDue pops while the top is due and skips anything already cancelled. In a database, the auction row carries high_amount, leader_id and a version, and a bid is a conditional update: UPDATE auction SET high_amount = $1, leader_id = $2, version = version + 1 WHERE id = $3 AND version = $4 AND end_at > now(). Zero rows updated means someone else got there first or the auction ended; reload and re-evaluate. The bid itself is inserted into a bid table in the same transaction, so the log and the high bid never disagree. Settlement uses SELECT ... WHERE end_at <= now() AND outcome IS NULL FOR UPDATE SKIP LOCKED, so several settler workers can share the queue without two of them settling the same auction.

Layer 3The machine

An uncontended mutex costs tens of nanoseconds. Contended, a waiting goroutine or thread sleeps on a futex and wakes in a few microseconds. A hot auction might see 50 bids per second in its last minute, which is one bid every 20 ms; the critical section (a few comparisons and an append) takes under a microsecond, so even the hottest listing keeps the lock idle more than 99.99% of the time. The database path is dominated by the commit: a write-ahead log flush is roughly 0.1 to 1 ms on NVMe. The clock matters more than the CPU: wall clocks drift and NTP can step them, so the deadline comparison should use one authoritative clock per auction (the owning server or the database's now()), not each app server's.

Why it's built this way

  1. Money and reputation ride on "who won", so the winner must be a function of one ordered log.
  2. Ordering needs a single serialisation point per auction, so every rule runs under that auction's lock or row version.
  3. Auctions are independent, so the lock is per auction and the system scales by count of auctions, not by speed of one.
  4. Deadlines are judged by a clock, and background jobs are late, so open or closed is derived from the clock while only human or settler decisions are stored.
  5. Notification channels are slow and flaky, so they sit behind an observer outside the lock and may retry without affecting bids.

Extensibility

Leadership lens

Check yourself

  1. Why derive "open" from the clock instead of storing it and flipping it with a scheduled job?

    Show answer

    A scheduled job runs late, so a stored flag is wrong for a while after every boundary. Deriving it from startAt and endAt makes the answer correct at any instant, and only real decisions (cancel, settle) need stored state.

  2. Two bidders send 150 at the same millisecond when the high bid is 140 and the increment is 10. What happens and why?

    Show answer

    Both bids are serialised by the auction's lock. The first is accepted and raises the minimum next bid to 160, so the second is rejected as too low.

  3. The settler crashes after recording "sold" but before sending the winner's email. What happens on restart?

    Show answer

    The outcome is stored, so settlement skips the auction and never picks a second winner. The email must come from a durable outbox written in the same transaction as the outcome, which a relay retries.

  4. Why should Seller and Bidder not be subclasses of User?

    Show answer

    The same person plays both roles on different auctions, and a role is a relationship to one auction. Subclasses would force one role per object or awkward multiple inheritance.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →