Design a library management system¶
TL;DR¶
- You build circulation: search the catalog, borrow barcoded copies against a quota, return them with a fine, queue holds on a title, and block members who owe money.
- Three decisions carry the interview:
Book(catalog record) versusBookItem(barcoded copy) — you search books and borrow items; one lock per copy plus a quota claimed before the copy is touched, so neither the last copy nor the five-item limit can be over-issued; a FIFO hold queue that renewals cannot jump, promoted under the ledger lock and rolled back if the copy is gone. - Patterns that earn their place: Repository (catalog), Proxy (a caching catalog), Observer (hold notifications), State (copy, reservation), Strategy (fines), Factory (accounts), Facade (
LibraryService).
Problem statement¶
"Design a library management system. Members search a catalog by title, author, ISBN or subject, and borrow physical copies — up to five at a time, for ten days. Returns are late sometimes, and late means a fine; members who owe too much cannot borrow. If every copy of a title is out, a member can place a hold and be notified when one comes back. Show me the classes, the lifecycle of a copy, and what happens when three members try to borrow the last copy at the same moment."
Requirements¶
Functional
- A catalog of books; each book has one or more barcoded copies.
- Search by title, author, ISBN or subject, case-insensitively and on partial words.
- Members and librarians, each with an account and a borrowing limit (five and ten).
- Checkout of one or more copies at once, all-or-nothing, for a ten-day loan.
- Return with a fine for overdue days, capped, computed from an injected clock.
- Holds on a title, FIFO; the first copy returned goes on the hold shelf for the member at the head, with a three-day pickup window and a notification.
- Renewal, capped at two, and refused while somebody is waiting for the title.
- Copies can be marked lost or damaged, with a replacement fee for lost ones.
- Members with unpaid fines over a threshold are blocked until they pay.
Non-functional and constraints
- Correct under concurrency: the last copy of a popular title, and the loan quota, are the two invariants that must not break.
- Search is the hot read path and is allowed to be a little stale; availability is not.
- In-memory and single-process, behind a repository interface.
- Deterministic and testable: the clock, the ids and the fine policy are injected.
Out of scope: e-books and licence pools, inter-branch transfers, acquisitions and cataloguing workflow, recommendations.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Do members borrow a title or a copy? | A copy — barcodes are what the scanner reads. But holds queue on the title, because any copy will do. That asymmetry is the model. |
| Is the loan period per item or per member? | Per item, ten days, injected. A reference item with a two-day loan is then a field, not a branch. |
| What blocks a member? | Unpaid fines at or over 10.00. Not "any fine" — a 25-cent fine should not lock somebody out of the library. |
| Can a renewal jump the hold queue? | No. A queue that renewals can jump is not a queue; say this out loud, it is the rule interviewers probe. |
| What happens if the hold is not collected? | The pickup window closes, the reservation expires, and the copy passes to the next member in the queue rather than back to the open shelf. |
| Is search allowed to be stale? | Yes, for a few seconds — which is what justifies the caching proxy. Availability is read live, every time. |
| Do we track who damaged a copy? | The loan does, because the copy remembers its borrower until it is returned. Charging for it is a policy call to confirm with the interviewer. |
Core entities and relationships¶
- Book
1 → *BookItem.Bookis the immutable catalog record (ISBN, title, authors, subjects) and owns the search predicatematches.BookItemis a barcode on a shelf with a status, a borrower and a due date. - Author
* → *Book, modelled as a tuple on the book because nothing in circulation needs the reverse edge. - Person (abstract) with Member and Librarian; each declares its own
max_loans. AccountFactory opens the matching Account, so the desk never asks "what kind of person is this". - Account
1 → *Loan,1 → *Fine, and a set of currently borrowed barcodes — the quota counter. - Loan — barcode, dates, renewals, status.
days_overdueis derived, never stored. - Reservation — a hold on a
book_id, satisfied by whichever copy returns first. Its status machine is what makes the pickup window real. - ItemLockService — owns one lock per barcode and every
BookItemtransition in the system. - LibraryService — the Facade the circulation desk calls; SearchableCatalog with Catalog and the CachingCatalog proxy; BookRepository behind them; FinePolicy and HoldListener are the two injected seams.
Class diagram¶
The catalog side: a repository, a catalog, and a proxy that is indistinguishable from it.
classDiagram
class Author {
+id: str
+name: str
}
class Book {
+id: str
+isbn: str
+title: str
+authors: tuple
+subjects: tuple
+matches(needle) bool
}
class BookItem {
+barcode: str
+book_id: str
+status: ItemStatus
+borrower_id: str
+reserved_for: str
+due_on: date
+is_borrowable_by(account_id) bool
+lend_to(account_id, due_on)
+shelve()
+put_on_hold_shelf(account_id)
+mark(status)
}
class BookRepository {
<<interface>>
+add_book(book)
+add_item(item)
+book(book_id) Book
+item(barcode) BookItem
+items_of(book_id) List~BookItem~
}
class InMemoryBookRepository
class SearchableCatalog {
<<interface>>
+search(query) List~Book~
+book(book_id) Book
+item(barcode) BookItem
+copies(book_id) List~BookItem~
}
class Catalog {
+add_book(book)
+add_copies(book_id, barcodes) List~BookItem~
}
class CachingCatalog {
+hits: int
+misses: int
+invalidate()
}
Book "many" o-- "many" Author
Book "1" *-- "many" BookItem
BookRepository <|.. InMemoryBookRepository
SearchableCatalog <|.. Catalog
SearchableCatalog <|.. CachingCatalog
Catalog --> BookRepository
CachingCatalog --> Catalog : delegates
The circulation side: people, loans, holds, and the two locks.
classDiagram
class Person {
<<abstract>>
+id: str
+name: str
+role: Role
+max_loans: int
}
class Member
class Librarian
class AccountFactory {
+open(account_id, person) Account
}
class Account {
+id: str
+status: AccountStatus
+max_loans: int
+borrowed: set
+unpaid_total() Money
}
class Loan {
+barcode: str
+due_on: date
+renewals: int
+status: LoanStatus
+days_overdue(today) int
+renew(today, days) date
}
class Reservation {
+book_id: str
+account_id: str
+status: ReservationStatus
+barcode: str
+pickup_by: date
}
class Fine {
+amount: Money
+reason: str
+paid: bool
}
class ItemLockService {
-_locks: Dict~str,Lock~
+items_locked(barcodes)
+lend(barcodes, account_id, due_on) List~BookItem~
+take_back(barcode, next_holder, pickup_by) BookItem
+reserve_for(barcode, account_id) bool
+first_available(book_id) BookItem
}
class LibraryService {
-_ledger_lock: Lock
+checkout(account_id, barcodes) List~Loan~
+return_item(barcode) Fine
+renew(account_id, barcode) date
+place_hold(account_id, book_id) Reservation
+expire_holds() List~str~
+pay_fine(fine_id) Account
}
class FinePolicy {
<<interface>>
+fine_for(loan, today) Money
}
class PerDayFine
class TieredFine
class NoFine
class HoldListener {
<<interface>>
+on_hold_event(event, reservation, book)
}
class NotificationService {
+outbox() List~str~
}
Person <|-- Member
Person <|-- Librarian
AccountFactory ..> Account : creates
Account --> Person
Account "1" o-- "many" Loan
Account "1" o-- "many" Fine
LibraryService --> ItemLockService
LibraryService --> FinePolicy
LibraryService "1" o-- "many" Reservation
LibraryService --> "many" HoldListener : notifies
FinePolicy <|.. PerDayFine
FinePolicy <|.. TieredFine
FinePolicy <|.. NoFine
HoldListener <|.. NotificationService
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Repository | BookRepository, InMemoryBookRepository |
The catalog is the one part of this system that obviously becomes a database. Naming the boundary lets you answer "how would you persist it" with "swap this class", and it keeps the search predicate (Book.matches) in the domain where it can be tested. |
| Proxy | CachingCatalog in front of Catalog |
Search is the hottest read and the only one that tolerates staleness. The proxy implements the same SearchableCatalog protocol, so the service cannot tell them apart, and it deliberately does not cache availability — that would sell the same copy twice. |
| Observer | HoldListener, NotificationService |
A returned copy must page the member at the head of the queue. That belongs nowhere near return_item, and listeners are called outside the lock so a slow mail server cannot stall the returns desk. |
| State | ItemStatus guards on BookItem, ReservationStatus |
Five copy states and five reservation states with no per-state behaviour: guard clauses inside the lock, not a class per state. Say why — it is a judgement question. |
| Strategy | FinePolicy with three implementations |
Fines change by board decision, by branch and by amnesty week. PerDayFine(grace_days=3) and TieredFine() are configuration, not new code paths. |
| Factory Method | AccountFactory.open |
The person's class carries its own max_loans, so opening an account never branches on role. Adding a "student" tier is one subclass. |
| Facade | LibraryService |
The desk screen calls eight methods; the catalog, the copy locks, the fine policy and the notifiers stay behind them. |
What was deliberately not used: Singleton for the catalog. Every candidate reaches for it and it is the wrong tool — the catalog is constructed once in main and injected, so tests build a dozen and a second branch is a second object. Also skipped: a full State class hierarchy for BookItem. Five states, no behaviour, one owner — the guard clauses are the honest implementation.
Key flows¶
Borrow, return, notify. Note where the quota is claimed: before any copy is touched.
sequenceDiagram
autonumber
actor M as Member
participant LS as LibraryService
participant IL as ItemLockService
participant C as Catalog
participant N as NotificationService
M->>LS: checkout(account, C-001 + C-900)
LS->>LS: ledger lock: active? not blocked? quota free?
LS->>LS: claim the quota slots for both barcodes
LS->>IL: lend(barcodes, account, due_on)
IL->>IL: lock both copies in sorted barcode order
alt every copy borrowable
IL->>C: AVAILABLE to LOANED
LS->>LS: ledger lock: create the Loans
LS-->>M: two loans, due in 10 days
else any copy gone
IL-->>LS: ItemUnavailableError
LS->>LS: release the claimed quota slots
LS-->>M: pick another copy
end
M->>LS: return_item(C-900)
LS->>LS: ledger lock: close loan, price the delay, promote the next hold
LS->>IL: take_back(C-900, next holder, pickup_by)
IL->>C: LOANED to RESERVED
LS-)N: hold_ready
N-->>M: your copy is on the hold shelf until Friday
Copy lifecycle. RESERVED is the hold shelf: the copy is back in the building but is not on sale.
stateDiagram-v2
[*] --> AVAILABLE : copy accessioned
AVAILABLE --> LOANED : checkout
AVAILABLE --> RESERVED : a waiting hold claims it
RESERVED --> LOANED : the holder collects it
RESERVED --> AVAILABLE : pickup window closed, queue empty
LOANED --> AVAILABLE : returned, nobody waiting
LOANED --> RESERVED : returned, somebody waiting
LOANED --> LOST : declared lost, fee charged
LOANED --> DAMAGED : returned unusable
DAMAGED --> AVAILABLE : repaired
LOST --> AVAILABLE : found again
Reservation lifecycle. The pickup window is what stops a hold shelf filling up with books nobody collects.
stateDiagram-v2
[*] --> WAITING : hold placed, all copies out
WAITING --> READY : a copy is put on the hold shelf
WAITING --> CANCELLED : member changes their mind
READY --> FULFILLED : member borrows the copy
READY --> CANCELLED : member changes their mind
READY --> EXPIRED : pickup window closed
FULFILLED --> [*]
CANCELLED --> [*]
EXPIRED --> [*]
Implementation¶
Start with the vocabulary. Five copy states and five reservation states, written down before any logic, is what gets the interviewer nodding early.
class ItemStatus(StrEnum):
AVAILABLE = "available"
RESERVED = "reserved" # waiting on the hold shelf for one named member
LOANED = "loaned"
LOST = "lost"
DAMAGED = "damaged"
class LoanStatus(StrEnum):
ACTIVE = "active"
RETURNED = "returned"
LOST = "lost"
class ReservationStatus(StrEnum):
WAITING = "waiting" # in the queue for the next free copy
READY = "ready" # a copy is on the hold shelf
FULFILLED = "fulfilled"
CANCELLED = "cancelled"
EXPIRED = "expired" # the pickup window closed
class AccountStatus(StrEnum):
ACTIVE = "active"
BLOCKED = "blocked" # unpaid fines over the threshold
CLOSED = "closed"
class Role(StrEnum):
MEMBER = "member"
LIBRARIAN = "librarian"
Then the split that defines the problem. Book is immutable and owns matches, so search lives in the domain; BookItem owns its transitions, which run inside the copy lock.
@dataclass(frozen=True, slots=True)
class Author:
id: str
name: str
@dataclass(frozen=True, slots=True)
class Book:
"""The catalog record. Immutable: a new edition is a new Book, not an edit."""
id: str
isbn: str
title: str
authors: tuple[Author, ...] = ()
subjects: tuple[str, ...] = ()
def matches(self, needle: str) -> bool:
"""Title, author name, ISBN or subject - one predicate, used by every search."""
needle = needle.strip().lower()
if not needle:
return False
haystack = [self.title.lower(), self.isbn.lower()]
haystack += [a.name.lower() for a in self.authors]
haystack += [s.lower() for s in self.subjects]
return any(needle in field_value for field_value in haystack)
@dataclass(slots=True)
class BookItem:
"""One barcoded copy. This is the contended row when a title has one copy left."""
barcode: str
book_id: str
status: ItemStatus = ItemStatus.AVAILABLE
borrower_id: str | None = None
reserved_for: str | None = None
due_on: date | None = None
def is_borrowable_by(self, account_id: str) -> bool:
if self.status is ItemStatus.AVAILABLE:
return True
return self.status is ItemStatus.RESERVED and self.reserved_for == account_id
def lend_to(self, account_id: str, due_on: date) -> None:
if not self.is_borrowable_by(account_id):
raise ItemUnavailableError(f"copy {self.barcode} is {self.status}")
self.status = ItemStatus.LOANED
self.borrower_id = account_id
self.reserved_for = None
self.due_on = due_on
def shelve(self) -> None:
"""Back on the open shelf: after a return with nobody waiting, or a repair."""
self.status = ItemStatus.AVAILABLE
self.borrower_id = None
self.reserved_for = None
self.due_on = None
def put_on_hold_shelf(self, account_id: str) -> None:
self.status = ItemStatus.RESERVED
self.borrower_id = None
self.reserved_for = account_id
self.due_on = None
def mark(self, status: ItemStatus) -> None:
"""LOST or DAMAGED, from any state a librarian can reach."""
if status not in (ItemStatus.LOST, ItemStatus.DAMAGED):
raise ItemStateError(f"{status} is not a librarian mark")
self.status = status
self.borrower_id = None
self.reserved_for = None
self.due_on = None
People carry their own limit, and AccountFactory reads it. Adding a student tier with three loans is one subclass and zero changes at the desk.
class Person(ABC):
"""A human the library knows about. Subclasses declare their own borrowing limit."""
def __init__(self, person_id: str, name: str, email: str) -> None:
if not name.strip():
raise ValidationError("a person needs a name")
self.id = person_id
self.name = name.strip()
self.email = email
@property
@abstractmethod
def role(self) -> Role: ...
@property
@abstractmethod
def max_loans(self) -> int: ...
class Member(Person):
role = Role.MEMBER
max_loans = 5
class Librarian(Person):
role = Role.LIBRARIAN
max_loans = 10
@dataclass(slots=True)
class Fine:
id: str
account_id: str
barcode: str
amount: Money
reason: str
assessed_on: date
paid: bool = False
@dataclass(slots=True)
class Account:
"""The borrowing side of a person: what they hold, what they owe, whether they are blocked."""
id: str
holder_id: str
role: Role
max_loans: int
status: AccountStatus = AccountStatus.ACTIVE
borrowed: set[str] = field(default_factory=set)
fines: list[Fine] = field(default_factory=list)
def unpaid_total(self) -> Money:
total = Money(0)
for fine in self.fines:
if not fine.paid:
total = total + fine.amount
return total
class AccountFactory:
"""Factory Method: the person's class decides the limit, so the desk never asks."""
@staticmethod
def open(account_id: str, person: Person) -> Account:
return Account(
id=account_id, holder_id=person.id, role=person.role, max_loans=person.max_loans
)
days_overdue is derived from the injected clock rather than stored, because an is_overdue column is wrong every night at midnight.
@dataclass(slots=True)
class Loan:
id: str
barcode: str
book_id: str
account_id: str
borrowed_on: date
due_on: date
returned_on: date | None = None
renewals: int = 0
status: LoanStatus = LoanStatus.ACTIVE
def days_overdue(self, today: date) -> int:
"""Derived, never stored: an overdue flag in a column goes stale at midnight."""
reference = self.returned_on or today
return max(0, (reference - self.due_on).days)
def renew(self, today: date, days: int = LOAN_PERIOD_DAYS) -> date:
if self.renewals >= MAX_RENEWALS:
raise RenewalBlockedError(f"loan {self.id} has used its {MAX_RENEWALS} renewals")
self.renewals += 1
self.due_on = max(self.due_on, today) + timedelta(days=days)
return self.due_on
@dataclass(slots=True)
class Reservation:
"""A hold on a *book*; whichever copy comes back first satisfies it."""
id: str
book_id: str
account_id: str
placed_at: float
status: ReservationStatus = ReservationStatus.WAITING
barcode: str | None = None
pickup_by: date | None = None
The catalog sits behind a repository, and the proxy in front of it is the same protocol — which is exactly what makes it a Proxy rather than a helper.
class BookRepository(Protocol):
"""Collection-like persistence boundary for catalog records and copies."""
def add_book(self, book: Book) -> None: ...
def add_item(self, item: BookItem) -> None: ...
def book(self, book_id: str) -> Book: ...
def item(self, barcode: str) -> BookItem: ...
def books(self) -> list[Book]: ...
def items_of(self, book_id: str) -> list[BookItem]: ...
class InMemoryBookRepository:
"""Dict-backed store. ``_lock`` guards the two dicts, not the item *status*."""
def __init__(self) -> None:
self._books: dict[str, Book] = {}
self._items: dict[str, BookItem] = {}
self._lock = threading.Lock()
def add_book(self, book: Book) -> None:
with self._lock:
self._books[book.id] = book
def add_item(self, item: BookItem) -> None:
with self._lock:
if item.book_id not in self._books:
raise NotInCatalogError(f"no catalog record {item.book_id!r}")
self._items[item.barcode] = item
def book(self, book_id: str) -> Book:
with self._lock:
try:
return self._books[book_id]
except KeyError:
raise NotInCatalogError(f"no book {book_id!r}") from None
def item(self, barcode: str) -> BookItem:
with self._lock:
try:
return self._items[barcode]
except KeyError:
raise NotInCatalogError(f"no copy with barcode {barcode!r}") from None
def books(self) -> list[Book]:
with self._lock:
return list(self._books.values())
def items_of(self, book_id: str) -> list[BookItem]:
with self._lock:
return sorted(
(i for i in self._items.values() if i.book_id == book_id), key=lambda i: i.barcode
)
class SearchableCatalog(Protocol):
"""What the service layer needs. Both the real catalog and the proxy satisfy it."""
def search(self, query: str) -> list[Book]: ...
def book(self, book_id: str) -> Book: ...
def item(self, barcode: str) -> BookItem: ...
def copies(self, book_id: str) -> list[BookItem]: ...
class Catalog:
"""Search by title, author, ISBN or subject - one predicate on ``Book.matches``."""
def __init__(self, repository: BookRepository | None = None) -> None:
self._repository = repository or InMemoryBookRepository()
def add_book(self, book: Book) -> None:
self._repository.add_book(book)
def add_copies(self, book_id: str, barcodes: list[str]) -> list[BookItem]:
items = [BookItem(barcode=b, book_id=book_id) for b in barcodes]
for item in items:
self._repository.add_item(item)
return items
def search(self, query: str) -> list[Book]:
return sorted(
(b for b in self._repository.books() if b.matches(query)), key=lambda b: b.title
)
def book(self, book_id: str) -> Book:
return self._repository.book(book_id)
def item(self, barcode: str) -> BookItem:
return self._repository.item(barcode)
def copies(self, book_id: str) -> list[BookItem]:
return self._repository.items_of(book_id)
class CachingCatalog:
"""Proxy: identical interface, memoised searches, invalidated when stock changes.
Search is the hottest read path in an OPAC and the only one whose answer changes
rarely. Availability deliberately is *not* cached - it changes every checkout.
"""
def __init__(self, inner: Catalog) -> None:
self._inner = inner
self._cache: dict[str, list[Book]] = {}
self._lock = threading.Lock()
self.hits = 0
self.misses = 0
def add_book(self, book: Book) -> None:
self._inner.add_book(book)
self.invalidate()
def add_copies(self, book_id: str, barcodes: list[str]) -> list[BookItem]:
items = self._inner.add_copies(book_id, barcodes)
self.invalidate()
return items
def invalidate(self) -> None:
with self._lock:
self._cache.clear()
def search(self, query: str) -> list[Book]:
key = query.strip().lower()
with self._lock:
cached = self._cache.get(key)
if cached is not None:
self.hits += 1
return list(cached)
result = self._inner.search(query)
with self._lock:
self._cache[key] = list(result)
self.misses += 1
return result
def book(self, book_id: str) -> Book:
return self._inner.book(book_id)
def item(self, barcode: str) -> BookItem:
return self._inner.item(barcode)
def copies(self, book_id: str) -> list[BookItem]:
return self._inner.copies(book_id)
Now the contended part. Every BookItem transition in the system happens inside ItemLockService, and multi-copy operations sort the barcodes before acquiring.
class ItemLockService:
"""The scarce-inventory gatekeeper: one lock per barcoded copy, sorted acquisition.
Every transition of ``BookItem.status`` in the whole system happens in here.
"""
def __init__(self, catalog: SearchableCatalog) -> None:
self._catalog = catalog
self._locks: dict[str, threading.Lock] = {}
self._registry_lock = threading.Lock()
def _lock_for(self, barcode: str) -> threading.Lock:
with self._registry_lock:
return self._locks.setdefault(barcode, threading.Lock())
@contextmanager
def items_locked(self, barcodes: Sequence[str]) -> Iterator[None]:
"""Sorted order: a member taking C-002 and C-001 always locks C-001 first."""
acquired: list[threading.Lock] = []
try:
for barcode in sorted(set(barcodes)):
lock = self._lock_for(barcode)
lock.acquire()
acquired.append(lock)
yield
finally:
for lock in reversed(acquired):
lock.release()
def lend(self, barcodes: Sequence[str], account_id: str, due_on: date) -> list[BookItem]:
"""All-or-nothing: check every copy, then lend every copy."""
with self.items_locked(barcodes):
items = [self._catalog.item(b) for b in barcodes]
blocked = sorted(i.barcode for i in items if not i.is_borrowable_by(account_id))
if blocked:
raise ItemUnavailableError(f"copies not available: {', '.join(blocked)}")
for item in items:
item.lend_to(account_id, due_on)
return items
def take_back(self, barcode: str, next_holder: str | None, pickup_by: date | None) -> BookItem:
"""LOANED to RESERVED (somebody is waiting) or to AVAILABLE (nobody is)."""
with self.items_locked([barcode]):
item = self._catalog.item(barcode)
if item.status is not ItemStatus.LOANED:
raise ItemStateError(f"copy {barcode} is {item.status}, not on loan")
if next_holder is None:
item.shelve()
else:
item.put_on_hold_shelf(next_holder)
item.due_on = pickup_by
return item
def reserve_for(self, barcode: str, account_id: str) -> bool:
"""Put a free copy on the hold shelf. False if somebody borrowed it first."""
with self.items_locked([barcode]):
item = self._catalog.item(barcode)
if item.status is not ItemStatus.AVAILABLE:
return False
item.put_on_hold_shelf(account_id)
return True
def release_hold_shelf(self, barcode: str) -> bool:
with self.items_locked([barcode]):
item = self._catalog.item(barcode)
if item.status is not ItemStatus.RESERVED:
return False
item.shelve()
return True
def mark(self, barcode: str, status: ItemStatus) -> BookItem:
with self.items_locked([barcode]):
item = self._catalog.item(barcode)
item.mark(status)
return item
def repair(self, barcode: str) -> BookItem:
with self.items_locked([barcode]):
item = self._catalog.item(barcode)
if item.status not in (ItemStatus.DAMAGED, ItemStatus.LOST):
raise ItemStateError(f"copy {barcode} is {item.status}, nothing to repair")
item.shelve()
return item
def set_due(self, barcode: str, due_on: date) -> None:
with self.items_locked([barcode]):
self._catalog.item(barcode).due_on = due_on
def first_available(self, book_id: str) -> BookItem | None:
"""Lock-free hint. Every caller re-checks under the copy lock."""
for item in self._catalog.copies(book_id):
if item.status is ItemStatus.AVAILABLE:
return item
return None
The facade. Read checkout and return_item together: the quota is claimed before the copies are touched and rolled back if they are gone, and the hold is promoted before the copy moves.
class LibraryService:
"""Facade for the circulation desk. Everything the librarian's screen calls."""
BLOCK_THRESHOLD = Money.of("10.00")
def __init__(
self,
catalog: SearchableCatalog,
items: ItemLockService,
fines: FinePolicy | None = None,
clock: Clock | None = None,
ids: IdGenerator | None = None,
) -> None:
self._catalog = catalog
self._items = items
self._fines = fines or PerDayFine()
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("LN")
self._accounts: dict[str, Account] = {}
self._loans: dict[str, Loan] = {}
self._fine_ledger: dict[str, Fine] = {}
self._reservations: dict[str, Reservation] = {}
self._queues: dict[str, list[Reservation]] = {}
self._listeners: list[HoldListener] = []
self._ledger_lock = threading.Lock()
def subscribe(self, listener: HoldListener) -> None:
self._listeners.append(listener)
def today(self) -> date:
return self._clock.now_dt().date()
def register(self, person: Person) -> Account:
account = AccountFactory.open(self._ids.next_id(), person)
with self._ledger_lock:
self._accounts[account.id] = account
return account
def search(self, query: str) -> list[Book]:
return self._catalog.search(query)
# -- circulation -------------------------------------------------------------------
def checkout(self, account_id: str, barcodes: Sequence[str]) -> list[Loan]:
"""Reserve the quota slots, take the copies, then commit the loans.
The quota is claimed under the ledger lock *before* any copy lock, so two
concurrent checkouts can never push one account past its limit.
"""
wanted = tuple(sorted(set(barcodes)))
if not wanted:
raise ItemUnavailableError("scan at least one barcode")
today = self.today()
due_on = today + timedelta(days=LOAN_PERIOD_DAYS)
with self._ledger_lock:
account = self._require_account(account_id)
self._check_borrowable(account)
# Only barcodes the card is not already holding take a quota slot, and
# only those may be released again: rolling back the whole scan would
# drop a copy the member genuinely still has out.
claimed = set(wanted) - account.borrowed
if len(account.borrowed) + len(claimed) > account.max_loans:
raise LoanLimitError(
f"account {account_id} may hold {account.max_loans} items; "
f"has {len(account.borrowed)}, asked for {len(wanted)}"
)
account.borrowed.update(claimed)
try:
self._items.lend(wanted, account_id, due_on)
except (ItemUnavailableError, NotInCatalogError):
with self._ledger_lock:
account.borrowed.difference_update(claimed)
raise
loans = []
with self._ledger_lock:
for barcode in wanted:
item = self._catalog.item(barcode)
loan = Loan(
id=self._ids.next_id(),
barcode=barcode,
book_id=item.book_id,
account_id=account_id,
borrowed_on=today,
due_on=due_on,
)
self._loans[loan.id] = loan
loans.append(loan)
self._fulfil_ready_hold(account_id, barcode)
return loans
def return_item(self, barcode: str) -> Fine | None:
"""Close the loan, price the delay, then hand the copy to the next hold."""
today = self.today()
with self._ledger_lock:
loan = self._active_loan(barcode)
loan.returned_on = today
loan.status = LoanStatus.RETURNED
account = self._accounts[loan.account_id]
account.borrowed.discard(barcode)
fine = self._assess(account, loan, today, self._fines.fine_for(loan, today), "overdue")
reservation = self._promote(loan.book_id, barcode, today)
next_holder = reservation.account_id if reservation is not None else None
pickup_by = reservation.pickup_by if reservation is not None else None
self._items.take_back(barcode, next_holder, pickup_by)
if reservation is not None:
self._emit("hold_ready", reservation)
return fine
def renew(self, account_id: str, barcode: str) -> date:
"""Refused when somebody is waiting: a queue that renewals can jump is not a queue."""
today = self.today()
with self._ledger_lock:
loan = self._active_loan(barcode)
if loan.account_id != account_id:
raise RenewalBlockedError(f"copy {barcode} is not on {account_id}'s card")
self._check_borrowable(self._require_account(account_id))
if any(r.status is ReservationStatus.WAITING for r in self._queues.get(loan.book_id, [])):
raise RenewalBlockedError(f"another member is waiting for {loan.book_id}")
due_on = loan.renew(today, LOAN_PERIOD_DAYS)
self._items.set_due(barcode, due_on)
return due_on
# -- holds -------------------------------------------------------------------------
def place_hold(self, account_id: str, book_id: str) -> Reservation:
today = self.today()
with self._ledger_lock:
self._check_borrowable(self._require_account(account_id))
queue = self._queues.setdefault(book_id, [])
if any(
r.account_id == account_id
and r.status in (ReservationStatus.WAITING, ReservationStatus.READY)
for r in queue
):
raise ItemUnavailableError(f"{account_id} already holds a place for {book_id}")
reservation = Reservation(
id=self._ids.next_id(),
book_id=book_id,
account_id=account_id,
placed_at=self._clock.now(),
)
queue.append(reservation)
self._reservations[reservation.id] = reservation
free = self._items.first_available(book_id)
if free is not None:
self._offer(book_id, free.barcode, today)
return reservation
def cancel_hold(self, reservation_id: str) -> None:
with self._ledger_lock:
reservation = self._require_reservation(reservation_id)
if reservation.status in (ReservationStatus.FULFILLED, ReservationStatus.CANCELLED):
raise ItemStateError(f"reservation {reservation_id} is {reservation.status}")
was_ready, barcode = reservation.status is ReservationStatus.READY, reservation.barcode
reservation.status = ReservationStatus.CANCELLED
if was_ready and barcode is not None:
self._recycle(reservation.book_id, barcode)
def expire_holds(self) -> list[str]:
"""Pickup window closed: shelve the copy or pass it to the next member."""
today = self.today()
lapsed: list[Reservation] = []
with self._ledger_lock:
for reservation in self._reservations.values():
if (
reservation.status is ReservationStatus.READY
and reservation.pickup_by is not None
and reservation.pickup_by < today
):
reservation.status = ReservationStatus.EXPIRED
lapsed.append(reservation)
for reservation in lapsed:
self._emit("hold_expired", reservation)
if reservation.barcode is not None:
self._recycle(reservation.book_id, reservation.barcode)
return [r.id for r in lapsed]
# -- money and condition -----------------------------------------------------------
def pay_fine(self, fine_id: str) -> Account:
with self._ledger_lock:
fine = self._fine_ledger.get(fine_id)
if fine is None:
raise NotInCatalogError(f"unknown fine {fine_id!r}")
fine.paid = True
account = self._accounts[fine.account_id]
if (
account.status is AccountStatus.BLOCKED
and account.unpaid_total() < self.BLOCK_THRESHOLD
):
account.status = AccountStatus.ACTIVE
return account
def mark_lost(self, barcode: str) -> Fine | None:
today = self.today()
with self._ledger_lock:
loan = self._active_loan(barcode)
loan.status = LoanStatus.LOST
loan.returned_on = today
account = self._accounts[loan.account_id]
account.borrowed.discard(barcode)
fine = self._assess(account, loan, today, DEFAULT_LOST_ITEM_FEE, "lost item")
self._items.mark(barcode, ItemStatus.LOST)
return fine
def mark_damaged(self, barcode: str) -> BookItem:
return self._items.mark(barcode, ItemStatus.DAMAGED)
def account(self, account_id: str) -> Account:
with self._ledger_lock:
return self._require_account(account_id)
def queue_for(self, book_id: str) -> list[Reservation]:
with self._ledger_lock:
return [r for r in self._queues.get(book_id, []) if r.status is ReservationStatus.WAITING]
# -- internals ---------------------------------------------------------------------
def _offer(self, book_id: str, barcode: str, today: date) -> Reservation | None:
"""Promote under the ledger lock, then claim the copy; demote if it has gone."""
with self._ledger_lock:
reservation = self._promote(book_id, barcode, today)
if reservation is None:
return None
if self._items.reserve_for(barcode, reservation.account_id):
self._emit("hold_ready", reservation)
return reservation
with self._ledger_lock: # somebody borrowed it first: back to the head of the queue
reservation.status = ReservationStatus.WAITING
reservation.barcode = None
reservation.pickup_by = None
return None
def _recycle(self, book_id: str, barcode: str) -> None:
if self._items.release_hold_shelf(barcode):
self._offer(book_id, barcode, self.today())
def _promote(self, book_id: str, barcode: str, today: date) -> Reservation | None:
"""Ledger lock held. FIFO: the earliest waiting hold gets the copy."""
for reservation in self._queues.get(book_id, []):
if reservation.status is ReservationStatus.WAITING:
reservation.status = ReservationStatus.READY
reservation.barcode = barcode
reservation.pickup_by = today + timedelta(days=PICKUP_WINDOW_DAYS)
return reservation
return None
def _fulfil_ready_hold(self, account_id: str, barcode: str) -> None:
for reservation in self._reservations.values():
if (
reservation.status is ReservationStatus.READY
and reservation.barcode == barcode
and reservation.account_id == account_id
):
reservation.status = ReservationStatus.FULFILLED
def _assess(
self, account: Account, loan: Loan, today: date, amount: Money, reason: str
) -> Fine | None:
if amount.is_zero():
return None
fine = Fine(
id=self._ids.next_id(),
account_id=account.id,
barcode=loan.barcode,
amount=amount,
reason=reason,
assessed_on=today,
)
account.fines.append(fine)
self._fine_ledger[fine.id] = fine
if account.unpaid_total() >= self.BLOCK_THRESHOLD:
account.status = AccountStatus.BLOCKED
return fine
def _check_borrowable(self, account: Account) -> None:
if account.status is not AccountStatus.ACTIVE:
raise AccountBlockedError(
f"account {account.id} is {account.status} (owes {account.unpaid_total()})"
)
def _active_loan(self, barcode: str) -> Loan:
for loan in self._loans.values():
if loan.barcode == barcode and loan.status is LoanStatus.ACTIVE:
return loan
raise NotInCatalogError(f"no active loan for copy {barcode!r}")
def _require_account(self, account_id: str) -> Account:
try:
return self._accounts[account_id]
except KeyError:
raise NotInCatalogError(f"unknown account {account_id!r}") from None
def _require_reservation(self, reservation_id: str) -> Reservation:
try:
return self._reservations[reservation_id]
except KeyError:
raise NotInCatalogError(f"unknown reservation {reservation_id!r}") from None
def _emit(self, event: str, reservation: Reservation) -> None:
# Outside the lock: a slow notifier must never stall the returns desk.
book = self._catalog.book(reservation.book_id)
for listener in self._listeners:
listener.on_hold_event(event, reservation, book)
Fines are the policy the board changes every year, so they are a strategy with a cap:
DEFAULT_DAILY_RATE = Money.of("0.25")
DEFAULT_CAP = Money.of("10.00")
DEFAULT_LOST_ITEM_FEE = Money.of("30.00")
TIER_ONE_RATE = Money.of("0.10")
TIER_TWO_RATE = Money.of("0.50")
class FinePolicy(Protocol):
"""Turn an overdue loan into money. Stateless, therefore thread-safe."""
def fine_for(self, loan: Loan, today: date) -> Money: ...
class PerDayFine:
"""A rate per day late, a grace period, and a cap so a lost book is not infinite."""
def __init__(
self,
daily_rate: Money = DEFAULT_DAILY_RATE,
grace_days: int = 0,
cap: Money = DEFAULT_CAP,
) -> None:
self._rate = daily_rate
self._grace = grace_days
self._cap = cap
def fine_for(self, loan: Loan, today: date) -> Money:
late = loan.days_overdue(today) - self._grace
if late <= 0:
return Money(0)
return min(self._rate * late, self._cap)
class TieredFine:
"""Cheap for the first week, painful after it - what most public libraries do."""
def __init__(
self,
first_week: Money = TIER_ONE_RATE,
after: Money = TIER_TWO_RATE,
cap: Money = DEFAULT_CAP,
) -> None:
self._first_week = first_week
self._after = after
self._cap = cap
def fine_for(self, loan: Loan, today: date) -> Money:
late = loan.days_overdue(today)
if late <= 0:
return Money(0)
cheap_days = min(late, 7)
total = self._first_week * cheap_days + self._after * max(0, late - 7)
return min(total, self._cap)
class NoFine:
"""Amnesty week, or a children's library that never fines."""
def fine_for(self, loan: Loan, today: date) -> Money:
return Money(0)
Running python -m lld.library_management.demo walks a week at the desk:
accounts: LN-1 (limit 5), LN-2 (limit 5)
search 'gaiman' -> ['American Gods']
caching proxy: 1 hit, 1 miss
LN-1 borrows ['C-001', 'C-003'], due 2026-03-21
last copy contended: copies not available: C-003
LN-2 joins the queue for Godel Escher Bach -> waiting
renewal refused: another member is waiting for b-2
returned 40 days late -> fine 10.00 USD (overdue, capped), account now blocked
notified: hold_ready: Godel Escher Bach for LN-2 (copy C-003, by 2026-05-03)
borrowing blocked: account LN-1 is blocked (owes 10.00 USD)
fine paid -> active, can borrow again
LN-2 collects the hold: fulfilled, queue for b-2 is now []
C-001 marked lost -> fee 30.00 USD, copy is lost
Concurrency and edge cases¶
Which lock protects what.
ItemLockService._locks— onethreading.Lockper barcode, created lazily under_registry_lock. It guards copy status, the borrower and the hold shelf. Three members racing forC-001serialise onC-001alone; a fourth borrowingC-002never waits. This is the in-process twin of a row lock on the copies table.LibraryService._ledger_lock— guards accounts, loans, fines, reservations and the hold queues. It is never held while a copy lock is held.
Lock ordering. A checkout of several barcodes sorts them before acquiring, so two members scanning C-002, C-001 and C-001, C-002 both take C-001 first. Acquisition is cheap — an uncontended mutex is about 17 ns, so a five-book stack costs under 100 ns, which is nothing next to the scanner.
The last copy. lend checks every copy under the locks and only then mutates any of them, so a stack is all-or-nothing. The concurrency test fires 30 members at a single copy and asserts exactly one loan, with the copy's borrower_id equal to that winner.
The quota, which is the race candidates miss. The five-item limit lives on the account, not on any copy, so per-copy locks alone would let two concurrent checkouts push a member to six. checkout therefore claims the slots under the ledger lock first, takes the copies second, and releases the slots if the copies are gone. Release exactly what you claimed, not the whole scan — a barcode the card already held never took a slot, so subtracting it would hand back quota for a book that is still out. The second concurrency test fires 12 single-book checkouts from one account and asserts that exactly five succeed.
The hold queue. Promotion happens under the ledger lock and the copy is claimed afterwards, never the other way round — that is what keeps the two lock families from nesting. If the copy vanished in between, _offer demotes the reservation back to WAITING at its original position in the list, so nobody loses their place. On a return the copy is still LOANED while the queue is popped, so the window is not a race at all.
Renewal versus reserved. renew refuses while any WAITING reservation exists on the title, and separately caps renewals at two. Both checks run under the ledger lock, which is also where the hold queue lives, so there is no window in which a hold is placed between the check and the extension.
Other edge cases handled: returning a copy twice raises rather than double-crediting; a blocked account cannot borrow or renew; expired holds pass the copy down the queue rather than back to the open shelf; cancelling a READY hold does the same; a lost copy clears the borrower and charges a replacement fee; fines are capped so a forgotten book does not accrue forever; the caching proxy is invalidated on new stock, so a newly catalogued title is findable immediately.
Common mistake
Modelling one class for a book and putting is_available on it. Then a library with four copies of a bestseller cannot represent "three out, one on the hold shelf", holds have nothing to attach to, and the barcode on the physical object has no home. Draw Book "1" *-- "many" BookItem in the first five minutes and say "you search books, you borrow items" — it is the sentence the whole design hangs on.
Extensibility and follow-ups¶
- E-books and licences. A licence pool is a copy count without barcodes: implement
SearchableCatalogover a counter, and the loan is time-boxed with automatic return.LibraryServiceneeds no change because it already talks to the protocol, not toCatalog. - Branches.
ItemLockServiceandLibraryServiceare per-branch objects; an inter-branch transfer is aBookItemchanging its owning branch whileIN_TRANSIT, which is one more state on a machine you have already drawn. Cross-branch holds become the interesting part, and that is where this turns into an HLD question. - Overdue reminders. A scheduled job that scans active loans and emits events to the existing
HoldListenerfan-out — the same seam the hold notifications use, so it is a new listener rather than new plumbing. - Recommendations. Loans are already a member-to-book event stream; a recommender consumes it offline. Keep it out of the circulation path.
- Persistence and search at scale.
BookRepositorybecomes SQL andBook.matchesbecomes a full-text index. A single primary handles 50k+ indexed reads per second, so a public library system will be limited by the search index long before the loans table. The caching proxy in front is the cheap first move: caching the top 20 % of queries typically absorbs most of the read traffic. - A different fine regime.
PerDayFine(grace_days=3),TieredFine()andNoFine()already exist; per-branch policy is a constructor argument.
Interview tip
When the interviewer says "now two people want the last copy", do not jump to the lock. Say the invariant first — "exactly one loan exists for a barcode at a time, and an account never exceeds its limit" — then show the two locks that enforce them. Naming invariants before mechanisms is the single clearest senior signal in a concurrency discussion.
Tests¶
tests/test_library_management.py has 23 cases. The hold queue is the one to walk through, because it exercises the copy lifecycle, the FIFO order and the notification in one scenario:
def test_hold_queue_is_fifo_and_a_return_puts_the_copy_on_the_hold_shelf(clock: FakeClock) -> None:
catalog, library = build(clock, copies=1)
notifier = NotificationService()
library.subscribe(notifier)
asha = library.register(Member("p-1", "Asha", "asha@example.com"))
bala = library.register(Member("p-2", "Bala", "bala@example.com"))
chitra = library.register(Member("p-3", "Chitra", "chitra@example.com"))
library.checkout(asha.id, ["C-001"])
first = library.place_hold(bala.id, "b-1")
second = library.place_hold(chitra.id, "b-1")
assert [r.id for r in library.queue_for("b-1")] == [first.id, second.id]
library.return_item("C-001")
assert first.status is ReservationStatus.READY and first.barcode == "C-001"
assert second.status is ReservationStatus.WAITING # still queued behind Bala
assert catalog.item("C-001").reserved_for == bala.id
with pytest.raises(ItemUnavailableError):
library.checkout(chitra.id, ["C-001"]) # it is on the hold shelf for Bala
library.checkout(bala.id, ["C-001"])
assert first.status is ReservationStatus.FULFILLED
assert notifier.outbox()[0].startswith("hold_ready: American Gods")
The two concurrency tests pin the two different invariants — one copy, one loan; and one account, five items — because per-copy locking alone only gets you the first.
def test_thirty_members_race_for_the_last_copy_and_exactly_one_wins(clock: FakeClock) -> None:
catalog, library = build(clock, copies=1)
accounts = [
library.register(Member(f"p-{i}", f"Member {i}", f"m{i}@example.com")) for i in range(30)
]
def borrow(i: int) -> str | None:
try:
return library.checkout(accounts[i].id, ["C-001"])[0].account_id
except ConflictError:
return None
with ThreadPoolExecutor(max_workers=12) as pool:
winners = [w for w in pool.map(borrow, range(30)) if w is not None]
assert len(winners) == 1 # one copy, one loan
item = catalog.item("C-001")
assert item.status is ItemStatus.LOANED and item.borrower_id == winners[0]
assert sum(1 for a in accounts if library.account(a.id).borrowed) == 1
def test_the_loan_limit_holds_under_concurrent_checkouts(clock: FakeClock) -> None:
_, library = build(clock, copies=12)
account = library.register(Member("p-1", "Asha", "asha@example.com"))
def borrow(i: int) -> bool:
try:
library.checkout(account.id, [f"C-{i:03d}"])
return True
except ConflictError:
return False
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(borrow, range(1, 13)))
assert sum(results) == 5 # the member limit, not eleven
assert len(library.account(account.id).borrowed) == 5
The rest cover: the checkout-to-return state walk; search across title, author, ISBN and subject via parametrize; the caching proxy hitting, missing and invalidating; the member limit, the librarian limit and the blocked-then-paid cycle; a re-scan of a copy already on the card, which must roll back only the slots this checkout claimed; expired holds passing the copy down the queue; renewal blocked by the cap and by a waiting hold; a cancelled hold releasing the copy; lost and damaged copies leaving circulation; and all three fine policies with grace periods and caps. Run them with uv run pytest code/lld/library_management -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0–5 | Clarify | Book or copy? Loan period and limit? What blocks a member? Can renewals jump the queue? Park e-books and branches. |
| 5–11 | Entities | Draw Book "1" *-- "many" BookItem first and say the sentence. Then Account, Loan, Reservation, Fine. |
| 11–18 | State machines | Copy (five states) and Reservation (five). Mark the hold shelf and the pickup window. |
| 18–24 | Class diagram | Catalog behind a repository, the facade in the middle, fine policy and listeners hanging off. Mark the two locks. |
| 24–35 | Code | BookItem.is_borrowable_by → items_locked (say "sorted") → lend (say "check all, then lend all") → checkout (say "claim the quota first") → return_item (say "promote, then move the copy"). |
| 35–42 | Concurrency | The last copy and the quota — two invariants, two locks. Describe both concurrency tests. |
| 42–45 | Extensions | Licence pools behind the same protocol, branches with an in-transit state, reminders as another listener. |
Related¶
- Repository — the catalog persistence boundary
- Proxy —
CachingCatalogand why availability is not cached - Observer — hold notifications off the returns path
- Design a hotel management system — the sibling contended-resource problem, over date ranges
- State — the copy and reservation machines
- Strategy — the fine policies