Skip to main content

Online Auction

An auction is a broadcast problem wearing an e-commerce costume. The moment someone outbids the current leader, every other bidder watching that item needs to find out - and the Auction class shouldn't need to know who's watching or how many of them there are.

Requirements

Functional

  • An Auction is created for an Item with a starting price and a closing time.
  • A Bidder places a Bid; it's only accepted if it's higher than the current highest bid and the auction hasn't closed.
  • Every bidder who has previously bid on the item gets notified when a new highest bid comes in, without polling.
  • When the closing time passes, the auction closes and the highest bidder wins; no further bids are accepted.

Non-functional

  • Auction must not hold references to specific bidder types or notification channels (email, push, in-app) - adding a new notification method should not change Auction.
  • Rejecting a low bid or a bid on a closed auction should be immediate and never mutate auction state.

Design

Auction is the Subject in an Observer relationship: every Bidder who places a bid becomes a subscriber, and Auction.placeBid calls notifyOutbid on all of them the instant a new high bid lands - it has no idea whether a Bidder is a person, a bot, or a proxy bidding service, only that it implements AuctionObserver.

AliceBobAuctionplaceBid($100)1placeBid($120)2notifyOutbid($120)3placeBid($130)4notifyOutbid($130)5
  1. 1Alice's bid becomes the new highest; she is added as an observer.
  2. 2Bob outbids Alice; he too is registered as an observer of this auction.
  3. 3The auction never calls Alice's email provider directly - it just calls the observer interface.
  4. 4Alice reclaims the lead after being notified.
  5. 5Bob is notified in turn - the notification list grows with every new bidder, unknown to Auction itself.

Closing the auction is a one-way transition - Auction.close() sets a flag that placeBid checks first, so a bid racing the closing time either lands cleanly before close or is rejected cleanly after it, never half-applied.

Class diagram

«interface»AuctionObserver+ notifyOutbid(newHighBid: Bid)Auction- item: Item- highestBid: Bid- observers: List<AuctionObserver>- closesAt: datetime- closed: bool+ placeBid(bidder, amount): bool+ close()Item- id: string- title: string- startingPrice: doubleBid- bidder: Bidder- amount: double- placedAt: datetimeBidder- id: string- name: string+ notifyOutbid(newHighBid: Bid)
implementsuses
Auction is the Subject; every Bidder that has bid is an AuctionObserver notified on each new high bid.

Code

import java.time.LocalDateTime;
import java.util.*;
 
class Item {
final String id;
final String title;
final double startingPrice;
 
Item(String id, String title, double startingPrice) {
this.id = id;
this.title = title;
this.startingPrice = startingPrice;
}
}
 
class Bid {
final Bidder bidder;
final double amount;
final LocalDateTime placedAt;
 
Bid(Bidder bidder, double amount) {
this.bidder = bidder;
this.amount = amount;
this.placedAt = LocalDateTime.now();
}
}
 
interface AuctionObserver {
void notifyOutbid(Bid newHighBid);
}
 
class Bidder implements AuctionObserver {
final String id;
final String name;
 
Bidder(String id, String name) {
this.id = id;
this.name = name;
}
 
public void notifyOutbid(Bid newHighBid) {
System.out.printf("%s: you were outbid, new high is $%.2f by %s%n",
name, newHighBid.amount, newHighBid.bidder.name);
}
}
 
class Auction {
final Item item;
private final LocalDateTime closesAt;
private Bid highestBid;
private boolean closed = false;
private final Set<AuctionObserver> observers = new LinkedHashSet<>();
 
Auction(Item item, LocalDateTime closesAt) {
this.item = item;
this.closesAt = closesAt;
}
 
boolean placeBid(Bidder bidder, double amount) {
if (closed) return false;
if (highestBid != null && amount <= highestBid.amount) return false;
 
Bid previousHigh = highestBid;
highestBid = new Bid(bidder, amount);
observers.add(bidder);
 
for (AuctionObserver observer : observers) {
if (observer != bidder) observer.notifyOutbid(highestBid);
}
return true;
}
 
void close() {
closed = true;
}
 
Optional<Bid> winningBid() {
return Optional.ofNullable(closed ? highestBid : null);
}
}

Design decisions

  • Notification is Observer, not Auction calling a NotificationService directly. A direct call would force Auction to know about email templates, push tokens, or whatever channel bidders prefer. Observer flips that - Auction only knows it has a list of things that want to hear about outbids, and each Bidder decides for itself what "getting notified" means.
  • A Bidder subscribes by bidding, not through a separate watch() call. Coupling subscription to the act of bidding matches how real auctions work - you don't get outbid alerts for an item you've never bid on - and it means there's no separate watch-list state that could drift out of sync with who's actually bidding.
  • isClosed is checked at the top of placeBid, before the price comparison. Checking price first and closed-status second would let a technically-valid high bid slip through a few milliseconds after closing time under the wrong ordering; closed-status first means a closed auction rejects every bid uniformly, no matter how good it is.
  • What's missing for a real system: notifications here fire synchronously inside placeBid, which means a slow observer (a flaky push provider) blocks the bid itself - a production system would queue notifications and let placeBid return immediately, and closing on a timer needs a scheduler to call close() rather than relying on every caller to check the clock themselves.

Common follow-ups

  • What happens if Bidder.notifyOutbid throws (a flaky push provider, a dropped connection)? As written, one throwing observer would propagate the exception straight out of placeBid and abort the loop before the rest of the observers hear about the new high bid - and the bid itself has already been accepted by that point, so the state is now inconsistent with who got notified. Auction.placeBid needs to wrap each notifyOutbid call individually (the same fan-out failure isolation pub-sub-system.mdx uses for Topic.publish) so one bad observer can't silently swallow notifications to everyone bidding after them.
  • Two bidders submit what would be winning bids within milliseconds of each other - what stops both from becoming the highest bid? placeBid's read-compare-write on highestBid is not atomic against a concurrent caller, so both bids could read the same prior high before either writes - the classic race also called out in movie-booking.mdx's seat locking. The fix is the same shape: the compare-and-set on highestBid needs to happen as one atomic step, not a check followed by a separate assignment.
  • How would you support a "buy it now" price that ends the auction immediately? Add a buyItNowPrice to Item and check it inside placeBid right after the existing price-comparison check - if the bid meets or exceeds it, call close() before returning true rather than leaving the auction open for further bids. No change needed to the Observer wiring; notifyOutbid already fires for whatever the new highest bid is.
  • How would you support proxy bidding (a bidder sets a max, the system auto-bids up to it as needed)? Give Bidder an optional maxAutoBid and, inside placeBid, after accepting a new human bid, check whether any previously-outbid Bidder's maxAutoBid would still beat it - if so, immediately place that bidder's next increment as a new bid before returning. The Observer relationship doesn't change; the auto-bid is just another call to the same placeBid method, triggered internally instead of by a person.

Check yourself

Question 1 of 3

Why is notification handled through the Observer pattern rather than `Auction` calling a `NotificationService` directly?