Design Online Auction
An estate-sale auction house works off a wooden podium and a leather-bound ledger. The auctioneer reads the lot card, names the floor price, and starts taking paddles from the room. Every paddle that goes up gets a name and a number written in the ledger on the line below the last one. The price only ever goes up, never down, and when the auctioneer drops the hammer the lot is closed forever. Whoever's number sits on the last line of the ledger walks home with the item. If nobody bid past the floor price, the auctioneer marks the lot as no-sale and the item goes back in the case. That podium, that ledger, and that hammer are the entire design of an online auction. The rest of the code is making sure no paddle can sneak a bid in after the hammer falls and no number on the ledger is ever smaller than the number above it.

eBay shipped the first version of this in September 1995 as a side project Pierre Omidyar wrote over Labor Day weekend, and the very first listing was a broken laser pointer that sold for fourteen dollars. Omidyar wrote the bidding logic the same way the estate-sale auctioneers had been doing it for two hundred years — a list of bids per item, sorted by time, and a single rule that every new bid had to beat the highest one on the page. The hard part was not the rule. The hard part was the race between two bidders hitting submit at the same millisecond, and the closing logic that had to refuse a bid the instant the hammer fell even though the network might deliver that bid a half-second later. The design we are about to build treats both problems the same way the ledger does — every event passes through one function, that function checks the rules in order, and the function either writes a new line or refuses out loud.
The lot is the smallest thing the auction has. Three pieces of data describe it completely — what it is called, what the floor price is, and the running ledger of every accepted bid in time order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ItemId {
VintageWatch,
SignedGuitar,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LotState {
Open,
Closed,
}
#[derive(Debug, Clone)]
struct Bid {
bidder: &'static str,
amount: u32,
}
struct Lot {
name: &'static str,
reserve: u32,
state: LotState,
history: Vec<Bid>,
}
enum Event {
Bid {
item: ItemId,
bidder: &'static str,
amount: u32,
},
Close {
item: ItemId,
},
}A Lot holds a name, a reserve price (the floor the first bid has to clear), a LotState that says whether the hammer has fallen, and a Vec<Bid> that is the ledger itself. The reason the history is a Vec and not just the current highest bid is that the audit trail matters as much as the winner. A real auction house has to be able to answer "who bid what and in what order" months later, for fraud disputes, for tax records, for refunds when a winner backs out and the lot rolls to the second-highest bidder. Throwing away losing bids would save a few bytes per lot and make the rest of the design impossible. The ItemId enum is the paddle number the outside world uses to point at a lot — the auctioneer says "lot three," not "the watch," because two lots could share a name and a number cannot.
The event type is the second knob. A user can do exactly two things to an auction — place a bid, or close a lot. Every other action in a real system is either built from these two or is administrative noise that does not change the bid log. Modeling it as an enum forces the dispatch code to handle every case at compile time. The day someone adds a Cancel variant, the compiler points at every match in the codebase that has not been updated.
The House owns the lots. Two hardcoded lots live here because the lesson is about the bidding logic, not the catalog — swap the two struct fields for a HashMap<ItemId, Lot> in a real system and nothing about the rules changes.
struct House {
watch: Lot,
guitar: Lot,
}
impl House {
fn new() -> Self {
Self {
watch: Lot {
name: "Vintage Watch",
reserve: 100,
state: LotState::Open,
history: Vec::new(),
},
guitar: Lot {
name: "Signed Guitar",
reserve: 500,
state: LotState::Open,
history: Vec::new(),
},
}
}
fn lot_mut(&mut self, id: ItemId) -> &mut Lot {
match id {
ItemId::VintageWatch => &mut self.watch,
ItemId::SignedGuitar => &mut self.guitar,
}
}
}
impl Lot {
fn highest(&self) -> Option<&Bid> {
self.history.last()
}
fn floor(&self) -> u32 {
match self.highest() {
Some(b) => b.amount,
None => self.reserve,
}
}
fn place_bid(&mut self, bidder: &'static str, amount: u32) -> Result<String, String> {
if self.state == LotState::Closed {
return Err(format!("{} is closed", self.name));
}
let floor = self.floor();
let needed = if self.history.is_empty() {
floor
} else {
floor + 1
};
if amount < needed {
return Err(format!(
"{bidder} bid ${amount} on {}: must be at least ${needed}",
self.name
));
}
self.history.push(Bid { bidder, amount });
Ok(format!(
"{bidder} bid ${amount} on {} (accepted)",
self.name
))
}
fn close(&mut self) -> String {
self.state = LotState::Closed;
match self.highest() {
Some(b) if b.amount >= self.reserve => {
format!("{} sold to {} for ${}", self.name, b.bidder, b.amount)
}
_ => format!("{} unsold (no bid cleared reserve ${})", self.name, self.reserve),
}
}
}The interesting method is place_bid. It runs three checks in order before it touches the ledger. The first check is the hammer — a closed lot rejects every bid, no exceptions. The second check is the floor — for the first bid the bid has to meet the reserve, and for every later bid it has to beat the current highest by at least one dollar. The floor helper returns the reserve when the history is empty and the last bid's amount otherwise, and place_bid then asks "is this bid at least one above the floor (or equal to the reserve if no one has bid)?" Reject loudly if not. The reason the rule is "strictly greater than the previous bid" rather than "greater than or equal" is the same reason the auctioneer at a real podium says "going twice" — two bidders cannot share the top of the ledger, because then a tiebreaker has to come from somewhere and that somewhere is always a bug.
The close method is the hammer. It flips the state to Closed and reads the last line of the ledger. If the last bid cleared the reserve, the lot is sold to that bidder for that amount. If the ledger is empty or every bid sat below the reserve, the lot prints as unsold. The reserve check at close time is technically redundant given that place_bid already enforces it on the first bid, but the redundancy is the point — the close logic does not trust that place_bid was the only thing that ever wrote to the ledger, because in a year when someone adds a "admin override" function, the close check is the last line of defense.

The driver loop is the auctioneer reading events off a card. It does not invent any rules of its own — it hands each event to the house and prints what came back, along with the full ledger of both lots after every step. That print-everything-after-every-step pattern is what makes the trace readable later. A trace that only prints the event is hard to debug. A trace that prints the event and the resulting state is its own audit log.
fn render_lot(lot: &Lot) {
let state = match lot.state {
LotState::Open => "open",
LotState::Closed => "closed",
};
println!(
" {} [{state}] reserve=${} bids={}",
lot.name,
lot.reserve,
lot.history.len()
);
for (i, b) in lot.history.iter().enumerate() {
println!(" {}. {} ${}", i + 1, b.bidder, b.amount);
}
}
fn apply(house: &mut House, event: &Event) {
let outcome = match event {
Event::Bid { item, bidder, amount } => {
let lot = house.lot_mut(*item);
match lot.place_bid(bidder, *amount) {
Ok(msg) => format!("ok: {msg}"),
Err(msg) => format!("err: {msg}"),
}
}
Event::Close { item } => {
let lot = house.lot_mut(*item);
format!("close: {}", lot.close())
}
};
println!("> {outcome}");
render_lot(&house.watch);
render_lot(&house.guitar);
println!();
}The scripted sequence is the smallest set of events that hits every interesting branch. Ada opens the watch at $120, well over the $100 reserve. Bess tries $115 — under the floor by six dollars, rejected. Bess comes back at $150 and wins the top line. Cleo bids $300 on the signed guitar, which has a $500 reserve, and the first-bid rule rejects her. The auctioneer closes the watch and the hammer falls — Bess wins it at $150. Dax shows up late with $200 on the now-closed watch and the closed-lot check refuses him. Finally the guitar closes with an empty ledger and is marked unsold.
fn main() {
let events = [
Event::Bid { item: ItemId::VintageWatch, bidder: "Ada", amount: 120 },
Event::Bid { item: ItemId::VintageWatch, bidder: "Bess", amount: 115 },
Event::Bid { item: ItemId::VintageWatch, bidder: "Bess", amount: 150 },
Event::Bid { item: ItemId::SignedGuitar, bidder: "Cleo", amount: 300 },
Event::Close { item: ItemId::VintageWatch },
Event::Bid { item: ItemId::VintageWatch, bidder: "Dax", amount: 200 },
Event::Close { item: ItemId::SignedGuitar },
];
let mut house = House::new();
println!("--- auction trace ---");
println!();
for e in &events {
apply(&mut house, e);
}
}--- auction trace ---
> ok: Ada bid $120 on Vintage Watch (accepted)
Vintage Watch [open] reserve=$100 bids=1
1. Ada $120
Signed Guitar [open] reserve=$500 bids=0
> err: Bess bid $115 on Vintage Watch: must be at least $121
Vintage Watch [open] reserve=$100 bids=1
1. Ada $120
Signed Guitar [open] reserve=$500 bids=0
> ok: Bess bid $150 on Vintage Watch (accepted)
Vintage Watch [open] reserve=$100 bids=2
1. Ada $120
2. Bess $150
Signed Guitar [open] reserve=$500 bids=0
> err: Cleo bid $300 on Signed Guitar: must be at least $500
Vintage Watch [open] reserve=$100 bids=2
1. Ada $120
2. Bess $150
Signed Guitar [open] reserve=$500 bids=0
> close: Vintage Watch sold to Bess for $150
Vintage Watch [closed] reserve=$100 bids=2
1. Ada $120
2. Bess $150
Signed Guitar [open] reserve=$500 bids=0
> err: Vintage Watch is closed
Vintage Watch [closed] reserve=$100 bids=2
1. Ada $120
2. Bess $150
Signed Guitar [open] reserve=$500 bids=0
> close: Signed Guitar unsold (no bid cleared reserve $500)
Vintage Watch [closed] reserve=$100 bids=2
1. Ada $120
2. Bess $150
Signed Guitar [closed] reserve=$500 bids=0Read down the trace and the rules play out one line at a time. Line one is Ada's accepted bid and the watch ledger grows from zero entries to one. Line two is the must be at least $121 rejection — the floor moved from $100 to $121 the instant Ada's $120 landed, because the rule is "beat the highest by one." Line three is Bess clearing $150 and the ledger now reads Ada $120, Bess $150 in time order. Line four is Cleo's rejection on the guitar with the reserve-cleared error message that names the actual reserve, which is the kind of detail a customer support ticket lives or dies on. Line five is the close on the watch and the line that follows shows the lot state has flipped from open to closed while the ledger is preserved exactly as it was — closing is not a delete. Line six is Dax bidding $200 on a closed lot and getting the Vintage Watch is closed message before the floor check ever runs, because the closed-lot check is first in the chain. Line seven is the guitar closing with bids=0 in the ledger and the no-sale message naming the reserve that no one met.
A question worth answering from the trace. Why did the system tell Bess her minimum was $121 rather than $120? Because of the strict-greater rule. If two bidders could share the top of the ledger at the same amount, the system would have to pick a winner by some tiebreaker — first to arrive, alphabetical, coin flip — and every one of those tiebreakers is a fight waiting to happen. Forcing each bid to be strictly larger than the last one removes the tie before it exists. The same reason the floor on the guitar was $500 flat for Cleo's first bid and not $501 — the reserve is the floor the first bid has to meet, not beat, because the seller named that exact price as acceptable.
The thing this design cannot do is close itself. The hammer falls because the driver calls Close — not because a clock ran out. A real online auction closes at a specific timestamp whether or not any user does anything, and the next bottleneck is delivering that timer event in a way that does not let a late-network bid slip in after the deadline but before the close fires.