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.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from enum import Enum
4 from datetime import datetime, timedelta
5 from dataclasses import dataclass, field
6 from threading import Lock
7 import uuid
8
9
10 class AuctionStatus(Enum):
11 SCHEDULED = "scheduled"
12 ACTIVE = "active"
13 CLOSED = "closed"
14 CANCELLED = "cancelled"
15
16
17 class AuctionType(Enum):
18 ENGLISH = "english" # ascending bids
19 DUTCH = "dutch" # descending price
20 SEALED_BID = "sealed_bid" # hidden bids, highest wins
21
22
23 @dataclass
24 class User:
25 id: str
26 name: str
27 email: str
28
29 def __hash__(self):
30 return hash(self.id)
31
32 def __eq__(self, other):
33 return isinstance(other, User) and self.id == other.id
34
35
36 @dataclass
37 class Item:
38 name: str
39 description: str
40 starting_price: float
41
42
43 @dataclass
44 class Bid:
45 id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
46 bidder: User = field(default=None)
47 amount: float = 0.0
48 timestamp: datetime = field(default_factory=datetime.now)
49
50
51 class AuctionObserver(ABC):
52 @abstractmethod
53 def on_outbid(self, auction_id: str, outbid_user: User, new_bid: Bid) -> None: ...
54
55 @abstractmethod
56 def on_auction_closed(self, auction_id: str, winner: User | None, winning_amount: float) -> None: ...
57
58
59 class ConsoleNotifier(AuctionObserver):
60 def on_outbid(self, auction_id: str, outbid_user: User, new_bid: Bid) -> None:
61 print(f" [NOTIFY] {outbid_user.name}: you've been outbid on auction {auction_id}. "
62 f"New highest: ${new_bid.amount:.2f} by {new_bid.bidder.name}")
63
64 def on_auction_closed(self, auction_id: str, winner: User | None, winning_amount: float) -> None:
65 if winner:
66 print(f" [NOTIFY] Auction {auction_id} closed. Winner: {winner.name} at ${winning_amount:.2f}")
67 else:
68 print(f" [NOTIFY] Auction {auction_id} closed with no bids.")
69
70
71 class BidValidator:
72 """Separates bid validation rules from auction logic."""
73
74 def validate(self, auction_status: AuctionStatus, auction_type: AuctionType,
75 amount: float, starting_price: float, current_highest: float | None,
76 seller_id: str, bidder_id: str) -> None:
77 if auction_status != AuctionStatus.ACTIVE:
78 raise ValueError(f"Cannot bid on auction with status {auction_status.value}")
79 if bidder_id == seller_id:
80 raise ValueError("Seller cannot bid on their own auction")
81 if amount <= 0:
82 raise ValueError("Bid amount must be positive")
83
84 min_amount = current_highest if current_highest else starting_price
85 if auction_type == AuctionType.ENGLISH and amount <= min_amount:
86 raise ValueError(f"Bid must exceed current highest of ${min_amount:.2f}")
87 elif auction_type == AuctionType.SEALED_BID and amount < starting_price:
88 raise ValueError(f"Bid must be at least the starting price of ${starting_price:.2f}")
89
90
91 class Auction:
92 def __init__(self, auction_id: str, item: Item, seller: User,
93 start_time: datetime, end_time: datetime,
94 auction_type: AuctionType = AuctionType.ENGLISH):
95 self.id = auction_id
96 self.item = item
97 self.seller = seller
98 self.start_time = start_time
99 self.end_time = end_time
100 self.auction_type = auction_type
101 self._status = AuctionStatus.SCHEDULED
102 self._bids: list[Bid] = []
103 self._observers: list[AuctionObserver] = []
104 self._validator = BidValidator()
105 self._lock = Lock()
106
107 @property
108 def status(self) -> AuctionStatus:
109 return self._status
110
111 @property
112 def bids(self) -> list[Bid]:
113 return list(self._bids)
114
115 def add_observer(self, observer: AuctionObserver) -> None:
116 self._observers.append(observer)
117
118 def activate(self) -> None:
119 if self._status != AuctionStatus.SCHEDULED:
120 raise ValueError(f"Cannot activate auction in {self._status.value} state")
121 self._status = AuctionStatus.ACTIVE
122
123 def get_highest_bid(self) -> Bid | None:
124 if not self._bids:
125 return None
126 return max(self._bids, key=lambda b: b.amount)
127
128 def place_bid(self, bidder: User, amount: float) -> Bid:
129 with self._lock:
130 highest = self.get_highest_bid()
131 current_highest_amount = highest.amount if highest else None
132
133 self._validator.validate(
134 self._status, self.auction_type, amount,
135 self.item.starting_price, current_highest_amount,
136 self.seller.id, bidder.id
137 )
138
139 previous_leader = highest.bidder if highest else None
140 bid = Bid(bidder=bidder, amount=amount)
141 self._bids.append(bid)
142
143 if previous_leader and previous_leader != bidder:
144 for obs in self._observers:
145 obs.on_outbid(self.id, previous_leader, bid)
146
147 return bid
148
149 def close(self) -> User | None:
150 with self._lock:
151 if self._status not in (AuctionStatus.ACTIVE, AuctionStatus.SCHEDULED):
152 raise ValueError(f"Cannot close auction in {self._status.value} state")
153 self._status = AuctionStatus.CLOSED
154 highest = self.get_highest_bid()
155 winner = highest.bidder if highest else None
156 winning_amount = highest.amount if highest else 0.0
157
158 for obs in self._observers:
159 obs.on_auction_closed(self.id, winner, winning_amount)
160
161 return winner
162
163 def cancel(self) -> None:
164 with self._lock:
165 if self._status == AuctionStatus.CLOSED:
166 raise ValueError("Cannot cancel a closed auction")
167 self._status = AuctionStatus.CANCELLED
168
169
170 class AuctionService:
171 def __init__(self):
172 self._auctions: dict[str, Auction] = {}
173 self._default_observer = ConsoleNotifier()
174
175 def create_auction(self, seller: User, item: Item,
176 start_time: datetime, end_time: datetime,
177 auction_type: AuctionType = AuctionType.ENGLISH) -> Auction:
178 auction_id = str(uuid.uuid4())[:8]
179 auction = Auction(auction_id, item, seller, start_time, end_time, auction_type)
180 auction.add_observer(self._default_observer)
181 self._auctions[auction_id] = auction
182 return auction
183
184 def activate_auction(self, auction_id: str) -> None:
185 auction = self._get_auction(auction_id)
186 auction.activate()
187
188 def place_bid(self, auction_id: str, bidder: User, amount: float) -> Bid:
189 auction = self._get_auction(auction_id)
190 return auction.place_bid(bidder, amount)
191
192 def close_auction(self, auction_id: str) -> User | None:
193 auction = self._get_auction(auction_id)
194 return auction.close()
195
196 def get_auction(self, auction_id: str) -> Auction:
197 return self._get_auction(auction_id)
198
199 def _get_auction(self, auction_id: str) -> Auction:
200 auction = self._auctions.get(auction_id)
201 if not auction:
202 raise ValueError(f"Auction {auction_id} not found")
203 return auction
204
205
206 if __name__ == "__main__":
207 service = AuctionService()
208
209 seller = User("u1", "Alice", "alice@example.com")
210 bidder1 = User("u2", "Bob", "bob@example.com")
211 bidder2 = User("u3", "Charlie", "charlie@example.com")
212 bidder3 = User("u4", "Diana", "diana@example.com")
213
214 item = Item("Vintage Watch", "1960s Omega Seamaster in excellent condition", 500.0)
215 now = datetime.now()
216 auction = service.create_auction(
217 seller, item,
218 start_time=now,
219 end_time=now + timedelta(hours=2),
220 auction_type=AuctionType.ENGLISH
221 )
222 print(f"Created auction {auction.id} for '{item.name}' starting at ${item.starting_price:.2f}")
223 print(f" Status: {auction.status.value}")
224
225 service.activate_auction(auction.id)
226 print(f" Status after activation: {auction.status.value}")
227
228 print("\nPlacing bids:")
229 b1 = service.place_bid(auction.id, bidder1, 550.0)
230 print(f" Bob bids ${b1.amount:.2f}")
231
232 b2 = service.place_bid(auction.id, bidder2, 600.0)
233 print(f" Charlie bids ${b2.amount:.2f}")
234
235 b3 = service.place_bid(auction.id, bidder3, 750.0)
236 print(f" Diana bids ${b3.amount:.2f}")
237
238 b4 = service.place_bid(auction.id, bidder1, 800.0)
239 print(f" Bob bids again ${b4.amount:.2f}")
240
241 print("\nClosing auction:")
242 winner = service.close_auction(auction.id)
243 print(f" Winner: {winner.name}")
244 print(f" Final status: {auction.status.value}")
245
246 print("\nTrying to bid on closed auction:")
247 try:
248 service.place_bid(auction.id, bidder2, 900.0)
249 except ValueError as e:
250 print(f" Correctly rejected: {e}")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