Online Auction
Auction platform with time-based bidding, automatic expiry, and winner determination. State machine controls the auction lifecycle while Observer keeps bidders notified in real time.
Key Abstractions
Facade, single entry point for creating auctions, placing bids, and closing
Core entity holding item, bids, time window, and current lifecycle state
Enum for lifecycle states: SCHEDULED, ACTIVE, CLOSED, CANCELLED
Immutable record of bidder, amount, and timestamp
What is being auctioned: name, description, starting price
Participant who can be a seller or bidder
Encapsulates per-auction-type bid rules (English, Dutch, sealed-bid)
Class Diagram
Why This Problem is Interesting
Most people jump straight to "store bids in a list, pick the highest." That gets you maybe 30% of the way there. The real challenge is managing the auction lifecycle correctly. An auction moves through distinct phases, and each phase has different rules about what operations are legal. You can't bid on something that hasn't started yet. You can't cancel something that already sold. Getting this wrong means real money changing hands incorrectly.
The second challenge is notifications. When Bob gets outbid, he needs to know immediately or he loses the item. When the auction closes, every participant wants the result. Baking notification logic into the Auction class turns it into a tangled mess that breaks every time you add a new notification channel.
Requirements
Functional
- Create auctions with an item, starting price, and time window
- Support multiple auction types: English (ascending), Dutch (descending), sealed-bid
- Place bids with validation against current highest and auction rules
- Automatically track auction lifecycle: scheduled, active, closed, cancelled
- Determine and announce the winner when an auction closes
- Notify users when they are outbid
Non-Functional
- Thread-safe bid placement for concurrent bidders
- Bid validation rules must be modifiable without touching auction core logic
- Notification mechanism must be extensible without modifying Auction class
Design Decisions
Why State pattern for auction lifecycle?
Without explicit state management, you end up with boolean flags scattered everywhere: isStarted, isClosed, isCancelled. Every method starts with a chain of if-else checks. With State (here implemented via an enum and guarded transitions), the Auction object rejects invalid operations at the boundary. Calling placeBid on a CLOSED auction throws immediately. No flag soup required.
Why Observer for outbid notifications?
Notifications are a cross-cutting concern. Today it is email. Tomorrow it is push notifications, SMS, or a WebSocket event. Observer lets you attach any number of notification channels without the Auction class knowing they exist. Adding a new channel means writing a new observer class and registering it. Zero changes to bidding logic.
Why separate bid validation from the Auction class?
Bid rules differ by auction type. English auctions require each bid to exceed the previous. Sealed-bid auctions just need the bid above the starting price. Dutch auctions have their own descending price logic. Pulling validation into a BidValidator class means you can swap or extend rules per auction type without touching the core Auction code. It also makes unit testing validation logic trivial.
Why Strategy for auction types?
English, Dutch, and sealed-bid auctions have fundamentally different bidding rules and winner determination logic. Rather than branching on auction type throughout the codebase, each type can implement its own strategy. Want to add a Vickrey auction (second-price sealed-bid)? Write a new strategy. Nothing else changes.
Interview Follow-ups
- "How would you handle reserve prices?" Add a
reservePricefield to Auction. During close, if the highest bid is below the reserve, the auction ends with no winner. The seller gets notified that the reserve was not met. - "How would you implement auto-bidding?" Add a
MaxBidAgentthat observes the auction. When the user is outbid, it automatically places a bid at the minimum increment up to their specified maximum. - "How would you handle auction sniping?" Extend the end time by a few minutes whenever a bid arrives in the final seconds. This is configurable per auction and lives in the Auction class as a policy.
- "How would you add payment integration?" Introduce a
PaymentServicethat theAuctionClosercalls after determining the winner. The winner gets charged, the seller gets credited, and both receive confirmation.
45-Minute LLD Playbook
Each phase is what the interviewer expects you to do and say. Concrete steps, not topic hints. Diagrams are what you would sketch on the board.
- 15 min
Clarify scope and lock the process
GoalPin down which auction types, what concurrency we expect, and what notification consumers we need. End by asking the interviewer how to budget the time.
Do & Say- ASK·1Open with: Are we designing one auction type or many? I will design English ascending in detail and structure for Dutch and sealed-bid as separate strategies. Reverse and Vickrey are v2.
- SAY·2Lock concurrency: Multiple bidders racing on the same auction is the core problem. I will use a per-auction lock so two concurrent place_bid calls cannot both believe they are highest. The cost is throughput per auction is bounded by lock contention, which is fine because most auctions have low QPS.
- SAY·3Lock lifecycle: Four states, SCHEDULED, ACTIVE, CLOSED, CANCELLED. Transitions are guarded. You cannot bid on SCHEDULED or CLOSED. You cannot cancel CLOSED. You cannot reactivate CLOSED.
- SAY·4Lock notification consumers: Outbid users get notified immediately. Auction winners get notified on close. Sellers get notified on close. One AuctionObserver interface, three consumers register.
- ASK·5Ask the process question: Do you want me to sketch the state diagram on the board first, or jump into code with the Auction class and let the state guards speak? Either works, just want to budget 40 minutes right.
Interviewer is grading: You park Dutch, sealed-bid, and reserve prices as v2 explicitly. You name per-auction locking before being asked. You ask the diagram-vs-code question.
- 25-10 min
Sketch the state machine and bid validation flow
GoalLock the four AuctionStatus values and the transition rules. Name where BidValidator lives. Draw the state diagram only if requested.
Do & Say- WRITE·1Write the state transitions in pseudocode: SCHEDULED -> ACTIVE (activate). SCHEDULED -> CANCELLED. ACTIVE -> CLOSED (close). ACTIVE -> CANCELLED. CLOSED -> (terminal). CANCELLED -> (terminal). Every other transition throws.
- WRITE·2Write the BidValidator contract: validate(status, type, amount, starting_price, current_highest, seller_id, bidder_id) raises on rule violations. Status ACTIVE, bidder not seller, amount positive, English needs amount > current highest, sealed-bid needs amount >= starting price. Say: Pulled out so Vickrey or reserve-price is a new method, not a place_bid edit.
- WRITE·3Write the place_bid flow: Acquire the per-auction lock. Fetch current_highest. Run BidValidator.validate. Record previous leader. Append Bid. If previous leader exists and is not the new bidder fire on_outbid on each observer. Release lock, return the Bid.
- WRITE·4Write the close flow: Acquire lock. Check status is ACTIVE or SCHEDULED. Set status to CLOSED. Find highest bid. Fire on_auction_closed on each observer. Release lock, return winner.
- SAY·5Name the Strategy split: AuctionType is the marker that routes to the right validation rule inside BidValidator. The validator branches on type once. Adding Vickrey adds one branch, but the branch is contained.
- SAY·6If a diagram was requested, draw the state machine as a four-node graph with the transitions labeled. Mark which transitions can run from outside (place_bid only valid in ACTIVE) versus internal (close, cancel). Then sketch the Auction class with its observer list on the side.
- SAY·7If no diagram, verbalize: State is the lifecycle guard, observer is the fanout, strategy is the auction-type-specific bid rule routed through the validator.
Interviewer is grading: You guard place_bid with status check at the validator entry, not deep in the method. You name BidValidator as separate from Auction so rules can change independently. You distinguish the SCHEDULED/ACTIVE/CLOSED/CANCELLED transitions cleanly.
- 325 min
Code in this sequence (bottom-up)
GoalBuild the data classes first, then the validator, then Auction with state guards, then the service facade. Talk while you type. End with a mental walk-through of one outbid and one close.
Do & Say- SAY·1Start with the enums: AuctionStatus with four values, AuctionType with three. Then User, Item, Bid as dataclasses. User has __hash__ and __eq__ on id so it works as a set element. Bid has bidder, amount, timestamp. (~2 min)
- SAY·2Say: Enums for status and type prevent the typo bug where cloesd silently fails comparisons. (~1 min)
- SAY·3Code AuctionObserver interface with on_outbid(auction_id, outbid_user, new_bid) and on_auction_closed(auction_id, winner, winning_amount). Then ConsoleNotifier that prints both. (~2 min)
- SAY·4Say: Two events, not one generic event. on_outbid carries the previous leader plus the new bid. on_auction_closed carries the winner and the amount. Strong typing beats a generic event bus here. (~1 min)
- SAY·5Code BidValidator. validate takes status, type, amount, starting_price, current_highest, seller_id, bidder_id. Raise on status != ACTIVE, on seller bidding on own auction, on amount <= 0. (~2 min)
- SAY·6For English, raise if amount <= max(current_highest, starting_price). For sealed-bid, raise if amount < starting_price. Say: All rules in one place. Adding reserve-price is one more check. Auction never has bid logic, just orchestration. (~2 min)
- SAY·7Code Auction constructor: id, item, seller, start_time, end_time, auction_type, defaults status to SCHEDULED, creates empty bids and observers lists, builds a BidValidator, holds a lock. (~2 min)
- SAY·8Properties for status and bids return defensive views. Say: Status is private with a property getter. External code cannot mutate it directly. Only activate, close, cancel methods can transition it. (~1 min)
- SAY·9Code Auction.activate: raise unless status is SCHEDULED, set to ACTIVE. Auction.cancel: raise if status is CLOSED, set to CANCELLED. Both with the lock. (~2 min)
- SAY·10Code Auction.place_bid. Acquire lock. Get highest bid via get_highest_bid (linear scan, max by amount). Call validator.validate. (~2 min)
- SAY·11Record previous_leader. Append a new Bid. If previous_leader exists and is not the new bidder, fire on_outbid on each observer. Return the Bid. (~2 min)
- SAY·12Say: Notify after the Bid is committed to the list, not before. Otherwise an observer panic mid-way leaves auction state inconsistent with what observers saw. (~1 min)
- SAY·13Code Auction.close. Acquire lock. Raise if status not ACTIVE or SCHEDULED. Set status to CLOSED. Find highest bid. Compute winner (None if no bids). Fire on_auction_closed on each observer. Return the winner. (~2 min)
- SAY·14Code AuctionService as the facade. Maps for auctions, default ConsoleNotifier. create_auction generates an id, builds an Auction, registers the default observer, stores it, returns it. activate_auction, place_bid, close_auction are thin delegators. (~2 min)
- SAY·15Walk-through part one: Create auction with starting price 500, AuctionType.ENGLISH. Activate. Bob bids 550, validator passes, no previous leader, no notification. Charlie bids 600, on_outbid fires for Bob.
- SAY·16Walk-through part two: Diana bids 750, on_outbid fires for Charlie. Bob bids 800, on_outbid fires for Diana. Close: on_auction_closed fires with Bob at 800. Try to bid on closed auction: validator raises because status is CLOSED. (~1 min)
Interviewer is grading: Code compiles as you type. You guard place_bid with the validator, not with if/else in the method body. You commit the Bid to the list before firing observers. You hold the lock around the entire place_bid critical section. You volunteer the seller-cannot-bid-on-own-auction rule.
- 45 min
Trade-offs, extensions, and wrap-up
GoalName two trade-offs you defend, volunteer one extension, close with a one-sentence summary.
Do & Say- SAY·1Trade-off one, per-auction lock versus optimistic concurrency: Per-auction lock serializes bids, simple, prevents lost-update where two bidders both think they are highest. Optimistic concurrency uses a version on the highest bid, one retries on conflict. Single-digit QPS, lock wins. High traffic, switch to version field plus CAS.
- SAY·2Trade-off two, observer over direct notification call: Calling NotificationService.send_email inside place_bid couples Auction to email. Adding push or SMS means rewriting place_bid. With Observer, Auction does not know consumers exist. Cost: observers fire synchronously inside the lock, so a slow consumer stalls the next bidder.
- SAY·3Extension to volunteer, auction sniping prevention: When a bid arrives in the last 30 seconds, extend end_time by 5 minutes. Encoded as a policy on the Auction class. Bidders cannot snipe by waiting until the last second, because the auction keeps reopening.
- WATCH·4Be ready for the auto-bid question: Add a MaxBidAgent that registers as an AuctionObserver. On on_outbid, if the auction is one of theirs, call place_bid for the current_highest plus minimum increment, capped at a max. Loops naturally through outbid notifications until one side hits their cap.
- SAY·5Close with one sentence: State for the lifecycle, BidValidator for type-specific rules, observer for outbid and close fanout. Adding Vickrey is one more validator branch. Adding push notifications is one more observer.
Interviewer is grading: You defend per-auction lock with a concrete failure mode (lost-update on concurrent place_bid). You volunteer sniping prevention or auto-bid agents unprompted.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from threading import Lock
import uuid
class AuctionStatus(Enum):
SCHEDULED = "scheduled"
ACTIVE = "active"
CLOSED = "closed"
CANCELLED = "cancelled"
class AuctionType(Enum):
ENGLISH = "english" # ascending bids
DUTCH = "dutch" # descending price
SEALED_BID = "sealed_bid" # hidden bids, highest wins
@dataclass
class User:
id: str
name: str
email: str
def __hash__(self):
return hash(self.id)
def __eq__(self, other):
return isinstance(other, User) and self.id == other.id
@dataclass
class Item:
name: str
description: str
starting_price: float
@dataclass
class Bid:
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
bidder: User = field(default=None)
amount: float = 0.0
timestamp: datetime = field(default_factory=datetime.now)
class AuctionObserver(ABC):
@abstractmethod
def on_outbid(self, auction_id: str, outbid_user: User, new_bid: Bid) -> None: ...
@abstractmethod
def on_auction_closed(self, auction_id: str, winner: User | None, winning_amount: float) -> None: ...
class ConsoleNotifier(AuctionObserver):
def on_outbid(self, auction_id: str, outbid_user: User, new_bid: Bid) -> None:
print(f" [NOTIFY] {outbid_user.name}: you've been outbid on auction {auction_id}. "
f"New highest: ${new_bid.amount:.2f} by {new_bid.bidder.name}")
def on_auction_closed(self, auction_id: str, winner: User | None, winning_amount: float) -> None:
if winner:
print(f" [NOTIFY] Auction {auction_id} closed. Winner: {winner.name} at ${winning_amount:.2f}")
else:
print(f" [NOTIFY] Auction {auction_id} closed with no bids.")
class BidValidator:
"""Separates bid validation rules from auction logic."""
def validate(self, auction_status: AuctionStatus, auction_type: AuctionType,
amount: float, starting_price: float, current_highest: float | None,
seller_id: str, bidder_id: str) -> None:
if auction_status != AuctionStatus.ACTIVE:
raise ValueError(f"Cannot bid on auction with status {auction_status.value}")
if bidder_id == seller_id:
raise ValueError("Seller cannot bid on their own auction")
if amount <= 0:
raise ValueError("Bid amount must be positive")
min_amount = current_highest if current_highest else starting_price
if auction_type == AuctionType.ENGLISH and amount <= min_amount:
raise ValueError(f"Bid must exceed current highest of ${min_amount:.2f}")
elif auction_type == AuctionType.SEALED_BID and amount < starting_price:
raise ValueError(f"Bid must be at least the starting price of ${starting_price:.2f}")
class Auction:
def __init__(self, auction_id: str, item: Item, seller: User,
start_time: datetime, end_time: datetime,
auction_type: AuctionType = AuctionType.ENGLISH):
self.id = auction_id
self.item = item
self.seller = seller
self.start_time = start_time
self.end_time = end_time
self.auction_type = auction_type
self._status = AuctionStatus.SCHEDULED
self._bids: list[Bid] = []
self._observers: list[AuctionObserver] = []
self._validator = BidValidator()
self._lock = Lock()
@property
def status(self) -> AuctionStatus:
return self._status
@property
def bids(self) -> list[Bid]:
return list(self._bids)
def add_observer(self, observer: AuctionObserver) -> None:
self._observers.append(observer)
def activate(self) -> None:
if self._status != AuctionStatus.SCHEDULED:
raise ValueError(f"Cannot activate auction in {self._status.value} state")
self._status = AuctionStatus.ACTIVE
def get_highest_bid(self) -> Bid | None:
if not self._bids:
return None
return max(self._bids, key=lambda b: b.amount)
def place_bid(self, bidder: User, amount: float) -> Bid:
with self._lock:
highest = self.get_highest_bid()
current_highest_amount = highest.amount if highest else None
self._validator.validate(
self._status, self.auction_type, amount,
self.item.starting_price, current_highest_amount,
self.seller.id, bidder.id
)
previous_leader = highest.bidder if highest else None
bid = Bid(bidder=bidder, amount=amount)
self._bids.append(bid)
if previous_leader and previous_leader != bidder:
for obs in self._observers:
obs.on_outbid(self.id, previous_leader, bid)
return bid
def close(self) -> User | None:
with self._lock:
if self._status not in (AuctionStatus.ACTIVE, AuctionStatus.SCHEDULED):
raise ValueError(f"Cannot close auction in {self._status.value} state")
self._status = AuctionStatus.CLOSED
highest = self.get_highest_bid()
winner = highest.bidder if highest else None
winning_amount = highest.amount if highest else 0.0
for obs in self._observers:
obs.on_auction_closed(self.id, winner, winning_amount)
return winner
def cancel(self) -> None:
with self._lock:
if self._status == AuctionStatus.CLOSED:
raise ValueError("Cannot cancel a closed auction")
self._status = AuctionStatus.CANCELLED
class AuctionService:
def __init__(self):
self._auctions: dict[str, Auction] = {}
self._default_observer = ConsoleNotifier()
def create_auction(self, seller: User, item: Item,
start_time: datetime, end_time: datetime,
auction_type: AuctionType = AuctionType.ENGLISH) -> Auction:
auction_id = str(uuid.uuid4())[:8]
auction = Auction(auction_id, item, seller, start_time, end_time, auction_type)
auction.add_observer(self._default_observer)
self._auctions[auction_id] = auction
return auction
def activate_auction(self, auction_id: str) -> None:
auction = self._get_auction(auction_id)
auction.activate()
def place_bid(self, auction_id: str, bidder: User, amount: float) -> Bid:
auction = self._get_auction(auction_id)
return auction.place_bid(bidder, amount)
def close_auction(self, auction_id: str) -> User | None:
auction = self._get_auction(auction_id)
return auction.close()
def get_auction(self, auction_id: str) -> Auction:
return self._get_auction(auction_id)
def _get_auction(self, auction_id: str) -> Auction:
auction = self._auctions.get(auction_id)
if not auction:
raise ValueError(f"Auction {auction_id} not found")
return auction
if __name__ == "__main__":
service = AuctionService()
seller = User("u1", "Alice", "alice@example.com")
bidder1 = User("u2", "Bob", "bob@example.com")
bidder2 = User("u3", "Charlie", "charlie@example.com")
bidder3 = User("u4", "Diana", "diana@example.com")
item = Item("Vintage Watch", "1960s Omega Seamaster in excellent condition", 500.0)
now = datetime.now()
auction = service.create_auction(
seller, item,
start_time=now,
end_time=now + timedelta(hours=2),
auction_type=AuctionType.ENGLISH
)
print(f"Created auction {auction.id} for '{item.name}' starting at ${item.starting_price:.2f}")
print(f" Status: {auction.status.value}")
service.activate_auction(auction.id)
print(f" Status after activation: {auction.status.value}")
print("\nPlacing bids:")
b1 = service.place_bid(auction.id, bidder1, 550.0)
print(f" Bob bids ${b1.amount:.2f}")
b2 = service.place_bid(auction.id, bidder2, 600.0)
print(f" Charlie bids ${b2.amount:.2f}")
b3 = service.place_bid(auction.id, bidder3, 750.0)
print(f" Diana bids ${b3.amount:.2f}")
b4 = service.place_bid(auction.id, bidder1, 800.0)
print(f" Bob bids again ${b4.amount:.2f}")
print("\nClosing auction:")
winner = service.close_auction(auction.id)
print(f" Winner: {winner.name}")
print(f" Final status: {auction.status.value}")
print("\nTrying to bid on closed auction:")
try:
service.place_bid(auction.id, bidder2, 900.0)
except ValueError as e:
print(f" Correctly rejected: {e}")Interview Grading by Level
What an interviewer at each level expects to see in your answer. Use this to calibrate, not to perform.
Junior Engineer (L3)
Builds an Auction that accepts bids and tracks a winner but state guards and notifications stay loose.
- Names State, Observer, and Strategy when asked.
- Writes an Auction class with a bids list and a place_bid method.
- Recognizes that bids need to exceed the current highest for English auctions.
- Adds a status field even if it is a string instead of an enum.
- Implements close that picks the highest bid as winner.
- Stores status as a string, hits typo bugs.
- Validates bid rules with if/else scattered inside place_bid instead of a BidValidator.
- Calls notification methods directly inside Auction instead of through an observer interface.
- Forgets to prevent the seller from bidding on their own auction.
- Skips the lock, allowing two concurrent bidders to both win.
Mid-Level Engineer (L4)
Drives the design with state, BidValidator, and observer as three named patterns. Lock around place_bid. Validator handles type-specific rules.
- Writes AuctionStatus and AuctionType as enums before any class.
- Pulls BidValidator out of Auction with a single validate method that takes status, type, and amounts.
- Implements per-auction locking around place_bid and close.
- Fires observers after the bid is committed to the list, not before.
- Walks through one outbid and one close as a self-check.
- Defends State guards by naming the specific transition matrix (SCHEDULED to ACTIVE, ACTIVE to CLOSED, etc).
- Does not volunteer sniping prevention until asked.
- Treats auto-bid as an obvious feature rather than naming it as an observer-driven extension.
- Misses the reserve-price requirement until prompted.
Senior Engineer (L5+)
Volunteers sniping prevention and auto-bid agents, frames lock-versus-optimistic as a QPS decision, and names the commit-before-notify invariant explicitly.
- Volunteers sniping prevention with end_time extension when a bid arrives in the last 30 seconds.
- Volunteers auto-bid as an observer-driven agent that listens to on_outbid and re-bids up to a cap.
- Frames per-auction lock versus optimistic concurrency around the auction QPS profile, not as a textbook recommendation.
- Names the commit-before-notify invariant: Bid goes into the list before observers fire, so a panic in an observer leaves the auction state consistent.
- Proposes reserve price as a field on Auction with the close logic returning no-winner if highest is below reserve.
- Suggests payment integration as a service the AuctionCloser calls after determining the winner.
- Closes with a one-sentence summary that names state, BidValidator, and observer with the role each plays, in under 20 seconds.
Common Mistakes
- ✗Letting anyone bid on a CLOSED or CANCELLED auction. State pattern makes invalid transitions impossible.
- ✗Not validating that a new bid exceeds the current highest. You end up with garbage data and angry sellers.
- ✗Coupling notification logic into the Auction class. When you add SMS or push, you are rewriting auction code.
- ✗Using strings for auction status instead of enums. One typo and your state transitions silently break.
Key Points
- ✓State pattern for auction lifecycle prevents bids on closed or cancelled auctions at the type level
- ✓Observer pattern notifies outbid users and announces auction results without coupling to notification delivery
- ✓Strategy pattern supports multiple auction types: English ascending, Dutch descending, sealed-bid
- ✓Bid validation is a separate concern from bid storage because business rules change independently