Question: Design a library management system
Companies: Amazon
Difficulty: Hard
Problem statement
Model books, copies, members, loans, holds and fines for a library, with search and limits on loans per member.
Requirements
- The catalog holds books (title, authors, ISBN, subject). Each book has one or more physical copies, each with its own barcode.
- Members check out copies and return them. A loan has a due date set by the member's type (for example, student 14 days / 3 loans, standard 21 days / 5 loans).
- Limit on loans: a member can't hold more active loans than their type allows. A member who owes more than a set amount in fines can't borrow. A late return creates a fine per day late, capped per loan; members can pay fines.
- Holds: when no copy is on the shelf, a member can place a hold on the book (not a specific copy). Holds are served first come, first served. A returned copy goes to the hold shelf for the next member in line, who has N days to pick it up before the hold expires and the copy moves on.
- Search the catalog by title, author or subject keywords. Notify a member when their hold is ready.
- Out of scope: payments processing, multiple branches, e-books, acquisitions, staff roles and auth, persistence (in-memory model; the design maps onto tables).
Core objects
-
Book— catalog entry for a title (ISBN, title, authors, subject). Immutable. -
BookCopy— one physical item with a barcode and aCopyStatus(AVAILABLE,ON_LOAN,ON_HOLD_SHELF,LOST). -
MemberType— loan policy per membership tier: max loans, loan length. -
Member— a borrower: active loans and fines owed. -
Loan— one checkout of one copy by one member: start, due, returned dates. -
Hold— a member's place in line for a book, with aHoldStatus(WAITING,READY,FULFILLED,CANCELLED,EXPIRED). -
FinePolicy(ABC) — computes the fine for a returned loan;DailyCappedFineis the default. -
Catalog— owns books and copies; keeps an inverted keyword index for search. -
Library— facade for checkout, return, holds, fines; enforces the rules and holds the lock.
Class design
{"type":"excalidraw","elements":[
{"type":"rectangle","id":"catalog","x":0,"y":0,"width":260,"height":95,"label":{"text":"Catalog\nadd_copy(book, barcode)\nsearch(query)"}},
{"type":"rectangle","id":"library","x":420,"y":0,"width":220,"height":140,"label":{"text":"Library\ncheckout()\nreturn_copy()\nplace_hold()\nexpire_holds()"}},
{"type":"rectangle","id":"fine","x":800,"y":0,"width":260,"height":95,"label":{"text":"FinePolicy (abstract)\nfine_for(loan, returned)"}},
{"type":"rectangle","id":"daily","x":1220,"y":10,"width":220,"height":70,"label":{"text":"DailyCappedFine"}},
{"type":"rectangle","id":"hold","x":420,"y":290,"width":220,"height":70,"label":{"text":"Hold\nstatus, pickup_by"}},
{"type":"rectangle","id":"book","x":0,"y":540,"width":220,"height":70,"label":{"text":"Book\nisbn, title"}},
{"type":"rectangle","id":"copy","x":420,"y":540,"width":220,"height":70,"label":{"text":"BookCopy\nbarcode, status"}},
{"type":"rectangle","id":"loan","x":820,"y":540,"width":180,"height":70,"label":{"text":"Loan"}},
{"type":"rectangle","id":"member","x":1180,"y":540,"width":180,"height":70,"label":{"text":"Member"}},
{"type":"rectangle","id":"mtype","x":1180,"y":800,"width":180,"height":70,"label":{"text":"MemberType"}},
{"type":"arrow","id":"library-catalog","x":420,"y":64,"width":-160,"height":-9,"start":{"id":"library"},"end":{"id":"catalog"},"label":{"text":"uses"}},
{"type":"arrow","id":"library-fine","x":640,"y":47,"width":160,"height":0,"start":{"id":"library"},"end":{"id":"fine"},"label":{"text":"uses"}},
{"type":"arrow","id":"daily-fine","x":1220,"y":45,"width":-160,"height":0,"start":{"id":"daily"},"end":{"id":"fine"},"label":{"text":"extends"}},
{"type":"arrow","id":"library-hold","x":530,"y":140,"width":1,"height":150,"start":{"id":"library"},"end":{"id":"hold"},"label":{"text":"has"}},
{"type":"arrow","id":"hold-copy","x":530,"y":360,"width":1,"height":180,"start":{"id":"hold"},"end":{"id":"copy"},"label":{"text":"assigned copy"}},
{"type":"arrow","id":"hold-book","x":471,"y":360,"width":-302,"height":180,"start":{"id":"hold"},"end":{"id":"book"}},
{"type":"arrow","id":"hold-member","x":634,"y":360,"width":546,"height":185,"start":{"id":"hold"},"end":{"id":"member"}},
{"type":"arrow","id":"book-copy","x":220,"y":575,"width":200,"height":0,"start":{"id":"book"},"end":{"id":"copy"},"label":{"text":"1 to many"}},
{"type":"arrow","id":"loan-copy","x":820,"y":575,"width":-180,"height":0,"start":{"id":"loan"},"end":{"id":"copy"}},
{"type":"arrow","id":"member-loan","x":1180,"y":575,"width":-180,"height":0,"start":{"id":"member"},"end":{"id":"loan"},"label":{"text":"loans"}},
{"type":"arrow","id":"member-mtype","x":1270,"y":610,"width":1,"height":190,"start":{"id":"member"},"end":{"id":"mtype"},"label":{"text":"max_loans,\nloan_days"}}
]}
Two key choices: a hold targets a Book, so any copy can satisfy it; and loans live on the member (for the limit check) and in an active map keyed by barcode (for returns).
Key flows
Checkout (member, barcode) — Library → Member, BookCopy, Hold, Loan
- Under the lock, look up the member and copy. Reject if
len(member.loans) >= member.kind.max_loansor fines owed exceed the limit. - If the copy is
ON_HOLD_SHELF, only the member whoseREADYhold has this copy may take it; mark that holdFULFILLEDand drop it from the queue. Otherwise the copy must beAVAILABLE. - Set the copy
ON_LOAN, create aLoanwithdue = today + loan_days, and record it on the member and inactive.
Return (barcode) — Library → Loan, FinePolicy, Hold
- Pop the loan from
active; reject if the copy wasn't on loan. Remove it from the member; addFinePolicy.fine_for(loan, today)tomember.fines_owed. - Route the copy: if the book's queue has a
WAITINGhold, that hold becomesREADY, gets this copy and a pickup deadline, the copy goesON_HOLD_SHELF, and listeners notify the member. Otherwise the copy isAVAILABLE.
Place hold (member, isbn) — rejected if a copy is on the shelf (just borrow it) or the member already has a hold on this book. Otherwise append a WAITING hold to the book's FIFO queue.
Expire holds (daily job) — every READY hold past pickup_by becomes EXPIRED, leaves the queue, and its copy is routed to the next waiting hold (step 2 of Return).
{"type":"excalidraw","elements":[
{"type":"ellipse","id":"start","x":0,"y":35,"width":110,"height":50,"label":{"text":"start"}},
{"type":"rectangle","id":"avail","x":160,"y":0,"width":260,"height":120,"label":{"text":"AVAILABLE"}},
{"type":"rectangle","id":"loan","x":820,"y":0,"width":340,"height":120,"label":{"text":"ON_LOAN"}},
{"type":"rectangle","id":"shelf","x":820,"y":400,"width":340,"height":120,"label":{"text":"ON_HOLD_SHELF"}},
{"type":"rectangle","id":"lost","x":1440,"y":25,"width":180,"height":70,"label":{"text":"LOST"}},
{"type":"arrow","id":"init","x":110,"y":60,"width":50,"height":0,"start":{"id":"start"},"end":{"id":"avail"}},
{"type":"arrow","id":"checkout","x":420,"y":30,"width":400,"height":0,"start":{"id":"avail"},"end":{"id":"loan"},"label":{"text":"checkout"}},
{"type":"arrow","id":"return-free","x":820,"y":90,"width":-400,"height":0,"start":{"id":"loan"},"end":{"id":"avail"},"label":{"text":"return, no waiting hold"}},
{"type":"arrow","id":"return-held","x":870,"y":120,"width":1,"height":280,"start":{"id":"loan"},"end":{"id":"shelf"},"label":{"text":"return, hold waiting"}},
{"type":"arrow","id":"pickup","x":1110,"y":400,"width":1,"height":-280,"start":{"id":"shelf"},"end":{"id":"loan"},"label":{"text":"holder checks out"}},
{"type":"arrow","id":"next-hold","x":900,"y":520,"width":180,"height":0,"points":[[0,0],[0,80],[180,80],[180,0]],"start":{"id":"shelf"},"end":{"id":"shelf"},"label":{"text":"pickup expired, next hold"}},
{"type":"arrow","id":"expired","x":820,"y":460,"width":-530,"height":-340,"start":{"id":"shelf"},"end":{"id":"avail"},"label":{"text":"pickup expired, queue empty"}},
{"type":"arrow","id":"report-lost","x":1160,"y":60,"width":280,"height":0,"start":{"id":"loan"},"end":{"id":"lost"},"label":{"text":"reported lost"}}
]}
Holds move WAITING → READY (copy assigned) → FULFILLED (checked out) or → EXPIRED (pickup missed; copy routed onward).
Code
import threading
from abc import ABC, abstractmethod
from collections import defaultdict, deque
from dataclasses import dataclass, field
from datetime import date, timedelta
from enum import Enum
from itertools import count
from typing import Callable, Optional
class CopyStatus(Enum):
AVAILABLE = "available"; ON_LOAN = "on_loan"; ON_HOLD_SHELF = "on_hold_shelf"; LOST = "lost"
class HoldStatus(Enum):
WAITING = "waiting"; READY = "ready"; FULFILLED = "fulfilled"; CANCELLED = "cancelled"; EXPIRED = "expired"
class LibraryError(Exception): ...
@dataclass(frozen=True)
class Book:
isbn: str; title: str; authors: tuple[str, ...]; subject: str
@dataclass
class BookCopy:
barcode: str; book: Book; status: CopyStatus = CopyStatus.AVAILABLE
@dataclass(frozen=True)
class MemberType:
name: str; max_loans: int; loan_days: int
@dataclass
class Member:
member_id: str; name: str; kind: MemberType
loans: dict[str, "Loan"] = field(default_factory=dict) # barcode -> active loan
fines_owed: int = 0 # cents
@dataclass
class Loan:
loan_id: int; copy: BookCopy; member: Member; start: date; due: date; returned: Optional[date] = None
@dataclass
class Hold:
hold_id: int; book: Book; member: Member; status: HoldStatus = HoldStatus.WAITING
copy: Optional[BookCopy] = None; pickup_by: Optional[date] = None
class FinePolicy(ABC):
@abstractmethod
def fine_for(self, loan: Loan, returned: date) -> int: ...
@dataclass
class DailyCappedFine(FinePolicy):
per_day: int = 25; cap: int = 1000 # cents
def fine_for(self, loan: Loan, returned: date) -> int:
return min(max((returned - loan.due).days, 0) * self.per_day, self.cap)
class Catalog:
def __init__(self) -> None:
self.books: dict[str, Book] = {}
self.copies: dict[str, BookCopy] = {}
self.by_isbn: dict[str, list[BookCopy]] = defaultdict(list)
self.index: dict[str, set[str]] = defaultdict(set) # token -> isbns
def add_copy(self, book: Book, barcode: str) -> BookCopy:
if barcode in self.copies: raise LibraryError("duplicate barcode")
self.books.setdefault(book.isbn, book)
copy = self.copies[barcode] = BookCopy(barcode, book)
self.by_isbn[book.isbn].append(copy)
for text in (book.title, book.subject, *book.authors):
for tok in text.lower().split(): self.index[tok].add(book.isbn)
return copy
def search(self, query: str) -> list[Book]:
toks = query.lower().split() # AND across keywords
if not toks: return []
hits = set.intersection(*(self.index.get(t, set()) for t in toks))
return sorted((self.books[i] for i in hits), key=lambda b: b.title)
class Library:
MAX_FINES, PICKUP_DAYS = 1000, 3
def __init__(self, catalog: Catalog, fines: FinePolicy) -> None:
self.catalog, self.fines = catalog, fines
self.members: dict[str, Member] = {}
self.holds: dict[str, deque[Hold]] = defaultdict(deque) # isbn -> FIFO queue
self.active: dict[str, Loan] = {} # barcode -> loan
self.listeners: list[Callable[[Member, str], None]] = []
self._ids, self._lock = count(1), threading.RLock()
def checkout(self, member_id: str, barcode: str, today: date) -> Loan:
with self._lock:
m, copy = self.members[member_id], self.catalog.copies[barcode]
if len(m.loans) >= m.kind.max_loans: raise LibraryError("loan limit reached")
if m.fines_owed > self.MAX_FINES: raise LibraryError("unpaid fines over limit")
if copy.status == CopyStatus.ON_HOLD_SHELF:
hold = next((h for h in self.holds[copy.book.isbn] if h.copy is copy), None)
if hold is None or hold.member is not m: raise LibraryError("reserved for another member")
hold.status = HoldStatus.FULFILLED
self.holds[copy.book.isbn].remove(hold)
elif copy.status != CopyStatus.AVAILABLE: raise LibraryError(f"copy is {copy.status.value}")
copy.status = CopyStatus.ON_LOAN
loan = Loan(next(self._ids), copy, m, today, today + timedelta(days=m.kind.loan_days))
m.loans[barcode] = self.active[barcode] = loan
return loan
def return_copy(self, barcode: str, today: date) -> int:
with self._lock:
loan = self.active.pop(barcode, None)
if loan is None: raise LibraryError("copy is not on loan")
loan.returned = today
del loan.member.loans[barcode]
fine = self.fines.fine_for(loan, today)
loan.member.fines_owed += fine
self._route(loan.copy, today)
return fine
def place_hold(self, member_id: str, isbn: str) -> Hold:
with self._lock:
m, queue = self.members[member_id], self.holds[isbn]
if any(c.status == CopyStatus.AVAILABLE for c in self.catalog.by_isbn[isbn]):
raise LibraryError("a copy is on the shelf; check it out")
if any(h.member is m for h in queue): raise LibraryError("hold already placed")
hold = Hold(next(self._ids), self.catalog.books[isbn], m)
queue.append(hold)
return hold
def expire_holds(self, today: date) -> None:
with self._lock:
for queue in self.holds.values():
for h in [h for h in queue if h.status == HoldStatus.READY and h.pickup_by < today]:
h.status = HoldStatus.EXPIRED
queue.remove(h)
self._route(h.copy, today)
def pay_fine(self, member_id: str, amount: int) -> None:
with self._lock:
m = self.members[member_id]
m.fines_owed = max(m.fines_owed - amount, 0)
def _route(self, copy: BookCopy, today: date) -> None:
for h in self.holds[copy.book.isbn]:
if h.status == HoldStatus.WAITING:
h.status, h.copy = HoldStatus.READY, copy
h.pickup_by = today + timedelta(days=self.PICKUP_DAYS)
copy.status = CopyStatus.ON_HOLD_SHELF
for notify in self.listeners: notify(h.member, f"'{copy.book.title}' is ready")
return
copy.status = CopyStatus.AVAILABLE
lib = Library(Catalog(), DailyCappedFine())
book = Book("9780134685991", "Effective Java", ("Joshua Bloch",), "programming")
lib.catalog.add_copy(book, "C1")
std = MemberType("standard", max_loans=5, loan_days=21)
lib.members.update(ann=Member("ann", "Ann", std), bob=Member("bob", "Bob", std))
lib.listeners.append(lambda m, msg: print(f"to {m.name}: {msg}"))
d0 = date(2026, 1, 1)
lib.checkout("ann", "C1", d0)
lib.place_hold("bob", book.isbn)
print(lib.return_copy("C1", d0 + timedelta(days=25))) # 4 days late -> 100; Bob notified
lib.checkout("bob", "C1", d0 + timedelta(days=26)) # only Bob may take it
print([b.title for b in lib.catalog.search("bloch java")])
Design patterns
-
Facade —
Libraryis the single entry point for the circulation desk; callers never flip copy or hold states by hand, so the invariants (copy status matches loans and holds) live in one place. -
Strategy —
FinePolicymakes the fine rule swappable (per day with a cap, flat fee, waived for staff) without touching checkout or return. -
Observer —
listenersdecouple "hold is ready" from how it's delivered (email, SMS, app push). -
State (explicit enums with guarded transitions) —
CopyStatusandHoldStatuswith checks in each operation. Full State classes would be overkill for four states; the transition table above is the spec.
Extensions
-
Cancelling a hold:
cancel_hold(hold_id)marks itCANCELLEDand removes it from the queue; if it wasREADY, its copy goes through_routelike an expiry. -
Renewals:
renew(barcode, today)extendsloan.dueif the book's queue has noWAITINGholds and the loan hasn't hit amax_renewalscount onMemberType. -
Lost or damaged copies:
report_lost(barcode)closes the loan, sets the copyLOST, and charges a replacement fee through a secondFinePolicymethod. A lost copy is never routed to holds. -
Branches and scale: add
branch_idtoBookCopyand a pickup branch toHold;_routeprefers local holds or creates a transfer. Persist as tables for books, copies, loans, holds and fines; checkout doesSELECT ... FOR UPDATEon the copy row plus a count of the member's active loans in one transaction. Search moves to a full-text index;expire_holdsbecomes a scheduled job. Fines becomeFinerows (loan, amount, paid) instead of one counter, giving an audit trail.
Interviewer expectations
Mid-level (L4)
- Separates
BookfromBookCopyand modelsLoanas its own object with a due date. - Enforces the loan limit, computes a late fine on return, and writes working checkout and return code with a status enum on the copy.
Senior (L5)
- Holds target the book, run FIFO, and a returned copy goes to the hold shelf with a pickup deadline and expiry.
- Fine rules behind a strategy; loan limits per member type; fines block borrowing.
- Names the race (two desks checking out the last copy) and makes checkout and return atomic; injects the date for testability.
Staff (L6+)
- Writes the copy and hold state machines down and shows every operation keeps them consistent, including expiry of a READY hold.
- Maps the model onto a transactional schema: row locks or a conditional update on copy status, an index on active loans per member, a fine ledger for audit.
- Discusses fairness and policy tradeoffs: hold queue vs. branch locality, fine caps, what happens to holds when copies are added or lost.