Code an auction with bids and winners
Open in the interactive app: practice, quiz, mock interview →In one minute
- An auction is a small state machine with a deadline: scheduled, open, ended, then exactly one of sold, unsold or cancelled. Most bugs come from code that reads the state from two places (a stored flag and the clock) and gets two different answers.
- A bid is a request, not a fact. The auction object decides whether it is accepted, and every rule (open window, own listing, minimum increment) runs inside one critical section on that one auction.
- Settlement is a separate, idempotent step driven by the clock. "The end time passed" and "the winner was recorded" are different moments, and the model should name the gap between them.
- People who need to know about changes (outbid bidders, the seller, the winner) subscribe to events. Notification delivery stays outside the lock, so a slow email gateway cannot stall bidding.
- The problem shape is contention plus a deadline: many writers race on one row until a time boundary, then a single writer finalises. The same shape appears in exam submission portals, sprint planning poker and limit-order expiry.
Requirements
Functional:
- A seller creates a listing with a start price, a hidden reserve price, a minimum bid increment, a start time and an end time.
- Bidders place bids while the auction is open. The first bid must be at least the start price; each later bid must beat the current high bid by at least the increment. Sellers cannot bid on their own listings.
- A seller may cancel a listing until the first bid arrives. After that, the auction runs to its end.
- When the end time passes, the system settles the auction: sold to the high bidder if the reserve is met, otherwise unsold.
- The previous leader is told when they are outbid; the seller and winner are told the result.
Non-functional:
- Exactly one winner per auction, and the winning amount is one that was accepted while the auction was open.
- Deterministic and auditable: given the bid log and the clock readings, anyone can recompute the outcome.
- Bidding stays responsive on a hot listing in its last minute.
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
- AuctionHouse is the façade. It creates listings, routes bids and cancellations to the right auction, runs settlement and owns the notification hub. It is the only object callers touch.
- Auction holds the listing terms and the live bidding state: seller, start price, reserve, increment, start and end times, the high bid, the leader, the bid count and a terminal outcome. It is the unit of contention and the place where every bidding rule lives.
- Bid is an immutable value: auction id, bidder, amount, accepted time and a sequence number.
- Outcome is the terminal state: sold (winner, price), unsold, or cancelled. Once set it never changes.
- User appears only as an id. The same person sells one item and bids on another, so a role is a relationship to an auction, not a
SellerorBidderclass. - Clock supplies the time. NotificationHub is the subject that observers (email, push, in-app inbox) subscribe to.
- IncrementPolicy answers "what is the minimum next bid?". A fixed increment is the simplest policy; real sites use a table that grows with the price.
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 ..|> IncrementPolicyKey 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
- State, kept light. The lifecycle has six states but only two pieces of behaviour change with state: whether a bid or a cancel is allowed. An enum for the stored terminal outcome plus a derived status function is enough. Full State-pattern classes (
OpenAuction,ScheduledAuction) earn their place only if you add Dutch or sealed-bid formats whose bidding rules differ per phase. - Derived versus stored state. Scheduled, open and ended are not stored. They are computed from
nowagainststartAtandendAt. Only transitions that a human or the settler makes (cancelled, sold, unsold) are stored. This removes the classic bug where a cron job is late flippingstatus = OPENand a bidder is refused at 10:00:03 on an auction that should have opened at 10:00:00. - Observer for outbid, won and sold events. The auction emits events; the hub fans them out to channels.
- Strategy for the increment policy and, later, the auction format. eBay-style tiered increments ("5 on items under 100, 10 under 500") are a table lookup behind
minNext(high). - Façade: callers see
AuctionHouseonly, so swapping the in-memory map for a database repository does not touch the API. - Command for the bid log. Each accepted bid is an immutable record appended in sequence; replaying the log through
placereproduces the auction.
Concurrency and edge cases
- Two bids at the same amount. Ann and Bob both bid 150 when the high is 140 with increment 10. Whichever acquires the auction's lock first leads; the second now needs 160 and gets "too low". There is no tie-break rule to write because the lock creates a total order. Without the lock, both could read 140, both pass the check, and both write "leader = me".
- Bid at the deadline. Decide whether the end time is inclusive. The practice code treats
now >= endAtas closed, so a bid stamped exactly at the end is rejected. Stamp the time inside the lock, from the server clock, never from the client. - Late settler. The settler may run seconds after
endAt. In that window the auction's status is "ended": bids are refused because the clock says so, even though no outcome is stored yet. Settlement is idempotent: it only touches auctions without an outcome, so running it twice produces no second winner. - Cancel racing a bid. The seller presses cancel as the first bid arrives. Both operations take the same lock; whichever wins decides. If the bid wins, cancel returns "has bids". If cancel wins, the bid returns "closed".
- Reserve not met. The high bid is 90 and the reserve is 120. The auction is unsold, the seller is told, and the high bidder learns the reserve was not met. Never leak the reserve amount.
- Self-outbidding. A leader may raise their own bid; it sends no outbid notice.
- Money. Integer minor units (cents). An increment of 0.10 in floating point eventually produces a bid of 150.00000000000003.
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
- Money and reputation ride on "who won", so the winner must be a function of one ordered log.
- Ordering needs a single serialisation point per auction, so every rule runs under that auction's lock or row version.
- Auctions are independent, so the lock is per auction and the system scales by count of auctions, not by speed of one.
- 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.
- Notification channels are slow and flaky, so they sit behind an observer outside the lock and may retry without affecting bids.
Extensibility
- Proxy bidding: the auction stores each bidder's maximum and computes the visible price. The
placemethod changes; the façade, settlement and notifications do not. The sdi lesson walks through the arithmetic. - Anti-sniping (soft close): a bid within the last few minutes extends
endAt. Because status is derived fromendAt, extension is one assignment. The settler's heap now holds stale entries; on pop, re-check the auction's currentendAtand push it back if it moved. - Sealed-bid or Dutch formats: introduce an
AuctionFormatstrategy withplaceandsettle. A Vickrey (second-price sealed) auction stores bids privately and settles at the second-highest amount.
Leadership lens
- Build first: the single-auction core with the lock, the derived status and an idempotent settle, plus a test that fires two equal bids and asserts exactly one wins. Notifications can be wrong for a day; a second winner cannot be undone without a refund and an apology.
- Ask in review: what clock decides the deadline; what happens if the settler is down for ten minutes; can the bid log alone reproduce the winner; where does the reserve price appear in logs.
- Increment tables, cancellation windows and soft close change often; keep them as data or strategies so a policy change is a config review, not a release.
- Operationally, the support team's question is always "why did I lose". Give them a read-only view of the bid log with timestamps and rejected bids, so engineers are not pulled into every dispute.
Check yourself
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
startAtandendAtmakes the answer correct at any instant, and only real decisions (cancel, settle) need stored state.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.
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.
Why should
SellerandBiddernot be subclasses ofUser?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
- Low Level Design Question: Design an Online Auction System (Mayur Vaid): an object-oriented walkthrough that applies Observer and Strategy to the auction model.
- Design Online Auction System in 20 Minutes (Elegant Engineer): a compact interview-style answer, useful to compare with the class-level view here.
- Design Online Auction, System Design Mock (James Peralta): requirements and entities for the same problem from the distributed-systems side.
Free resources
- English auction (Wikipedia): the ascending open-bid format this design implements, with its variants.
- Vickrey auction (Wikipedia): the sealed second-price format, a good test of whether your format strategy is general enough.
- Auction sniping (Wikipedia): why last-second bids happen and how soft close counters them.
- Observer pattern (Refactoring.Guru): the pattern behind outbid and settlement notifications.
- Explicit locking (PostgreSQL docs): row locks and
SKIP LOCKED, the database form of per-auction serialisation and parallel settlement. - Event Sourcing (Martin Fowler): rebuilding state from an ordered log, the idea behind a replayable bid history.