Design a library management system
Members, copies, loans, holds and fines.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Relationships and multiplicity — Association, aggregation, composition, and the numbers on the lines.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Domain events — Recording what happened as objects, so other parts can react later.
- Single responsibility — One reason to change per class.
- Reading and drawing class diagrams — The notation interviewers expect, and what to leave out.
01The design, in the six-step framework
1. Requirements and use cases
The library is the oldest OOD exercise there is, which means the interviewer has heard the generic answer many times. The points that still separate candidates: separating a title from its physical copies, making the loan a state machine, and raising events instead of hard-wiring fines and notifications into the checkout code.
- A librarian adds a title (ISBN, authors, subject) and adds copies of it, each with a barcode.
- A member borrows an available copy for a loan period and returns it; late returns accrue a fine.
- A member places a hold on a title with no available copy and is notified when one is returned.
- A member cannot borrow beyond a limit of simultaneous loans or with unpaid fines over a threshold.
- Anyone searches the catalogue by title, author or subject.
Out of scope: payments, inter-library loans, reservations of study rooms. Constraints: the loan rules (period, fine per day, limits) change per member type, so they are policy objects; a copy has exactly one state at a time; returning a copy must atomically pick the next hold.
2. Core entities and relationships
Book: the title record in the catalogue (ISBN, title, authors, subject).BookCopy: one physical item with a barcode and aCopyStatus.Member: id, theLoanPolicyof their member type, active loans and outstanding fines.Loan: copy, member, due date, returned date; the fine is computed on return.holds: a queue of waiting member ids per title, kept by the library.Catalogue: indexes books by title, author and subject.Library: the facade that borrows, returns, holds, and publishesDomainEvents.
A book composes its copies (destroy the title, the copies go) while a loan only aggregates a copy, which survives the loan. The policy is aggregated: one LoanPolicy object is shared by every member of a type.
3. Interfaces and key methods
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional, Protocol
class CopyStatus(Enum):
AVAILABLE = auto()
ON_LOAN = auto()
ON_HOLD_SHELF = auto() # returned, waiting for the member who held it
LOST = auto()
@dataclass(frozen=True)
class LoanPolicy:
period_days: int
max_loans: int
fine_per_day: int
fine_block: int # cannot borrow when fines_due >= this
@dataclass
class Loan:
copy: "BookCopy"
member_id: str
due: int # day number
returned: Optional[int] = None
def fine(self, policy: LoanPolicy, today: int) -> int:
"""Days late times the policy rate; zero if on time."""
class EventListener(Protocol):
def on_event(self, event: "DomainEvent") -> None: ...
class Library:
def borrow(self, member_id: str, barcode: str, today: int) -> Loan:
"""Enforces copy availability, loan limit and fine block; copy -> ON_LOAN."""
def return_copy(self, barcode: str, today: int) -> int:
"""Closes the loan, adds the fine to the member, offers the copy to the next hold; returns the fine."""
def place_hold(self, member_id: str, isbn: str) -> None:
"""FIFO per title; ignored if the member already holds it; refused while a copy is available."""
Invariants: a copy is ON_LOAN if and only if exactly one open Loan references it; a member's open loans never exceed policy.max_loans; a hold queue per title contains each member at most once; the fine for a loan is computed once, at return, from the policy of the borrowing member.
4. Design patterns and the choices behind them
Book versus BookCopy
The force: searches and holds are about titles; borrowing and fines are about a specific item. One class for both forces either a copy count that drifts or search results that list the same title five times. Two classes with a composition between them is the first thing an experienced reviewer looks for, and it is what makes "which copy is overdue" answerable. Drawing it is a class diagram habit worth having: the 1..* on the edge is the whole point.
Domain events for fines and notifications
The force: a return must update the loan, charge a fine, notify the next hold, and maybe log an audit record; tomorrow it also updates a popularity score. Publishing a DomainEvent (CopyReturned, HoldReady) and letting listeners react is the domain event idea; the return method stays small and each reaction is its own class, which keeps single responsibility intact. The alternative, calling the notifier and the fine ledger directly from return_copy, is the design that grows a 200-line method.
Copy and loan states, member types as policy
CopyStatus is a small state machine: available, on loan, on the hold shelf, lost; borrow and return_copy are the only transitions. Student, staff and guest differ only in numbers, so they are instances of one LoanPolicy value, not subclasses; the use-case walk finds no behaviour that differs, only data, and multiplicity (one policy to many members) says it should be shared.
5. Core flow and code
borrowloads the member and the copy, checks the copy isAVAILABLE(or on the hold shelf for this member), the loan count and the fine block.- It creates a
Loandue inpolicy.period_days, appends it to the member and flips the copy toON_LOAN. return_copyfinds the open loan, stamps the return day, computes the fine and adds it to the member.- If a hold exists for the title, the copy goes to
ON_HOLD_SHELFand aHoldReadyevent names the member; otherwise it becomesAVAILABLE. - Listeners (notifier, audit log) react to the events.
import threading
from collections import defaultdict, deque
from dataclasses import dataclass, field
from typing import Optional
@dataclass(frozen=True)
class Book:
isbn: str
title: str
authors: tuple
subject: str
@dataclass
class BookCopy:
barcode: str
isbn: str
status: CopyStatus = CopyStatus.AVAILABLE
reserved_for: Optional[str] = None # member id while ON_HOLD_SHELF
@dataclass
class Member:
id: str
policy: LoanPolicy
loans: list = field(default_factory=list)
fines_due: int = 0
def open_loans(self) -> list:
return [l for l in self.loans if l.returned is None]
@dataclass(frozen=True)
class DomainEvent:
kind: str # "CopyReturned", "HoldReady", "FineCharged"
member_id: str
isbn: str
amount: int = 0
def _fine(self: Loan, policy: LoanPolicy, today: int) -> int:
return max(0, today - self.due) * policy.fine_per_day
Loan.fine = _fine
class Catalogue:
def __init__(self):
self.books: dict = {}
self.copies: dict = {}
self.by_author: dict = defaultdict(set)
self.by_subject: dict = defaultdict(set)
def add_book(self, book: Book) -> None:
self.books[book.isbn] = book
for a in book.authors:
self.by_author[a.lower()].add(book.isbn)
self.by_subject[book.subject.lower()].add(book.isbn)
def add_copy(self, isbn: str, barcode: str) -> BookCopy:
copy = BookCopy(barcode, isbn)
self.copies[barcode] = copy
return copy
def available_copy(self, isbn: str) -> Optional[BookCopy]:
return next((c for c in self.copies.values() if c.isbn == isbn and c.status is CopyStatus.AVAILABLE), None)
def search(self, field_name: str, text: str) -> list:
text = text.lower()
if field_name == "title":
return [b for b in self.books.values() if text in b.title.lower()]
index = self.by_author if field_name == "author" else self.by_subject
return [self.books[i] for i in index.get(text, ())]
class Library:
def __init__(self):
self.catalogue = Catalogue()
self.members: dict = {}
self.loans: dict = {} # barcode -> open Loan
self.holds: dict = defaultdict(deque) # isbn -> member ids
self.listeners: list = []
self._lock = threading.RLock() # a copy changes state, and a hold is taken, one desk at a time
def _publish(self, event: DomainEvent) -> None:
for l in self.listeners:
l.on_event(event)
def borrow(self, member_id: str, barcode: str, today: int) -> Loan:
member, copy = self.members[member_id], self.catalogue.copies[barcode]
with self._lock:
if copy.status is CopyStatus.ON_HOLD_SHELF and copy.reserved_for != member_id:
raise ValueError("copy is reserved for another member")
if copy.status not in (CopyStatus.AVAILABLE, CopyStatus.ON_HOLD_SHELF):
raise ValueError(f"copy is {copy.status.name}")
if len(member.open_loans()) >= member.policy.max_loans:
raise ValueError("loan limit reached")
if member.fines_due >= member.policy.fine_block:
raise ValueError("outstanding fines block borrowing")
loan = Loan(copy, member_id, today + member.policy.period_days)
member.loans.append(loan)
self.loans[barcode] = loan
copy.status, copy.reserved_for = CopyStatus.ON_LOAN, None
return loan
def return_copy(self, barcode: str, today: int) -> int:
with self._lock:
loan = self.loans.pop(barcode)
member = self.members[loan.member_id]
loan.returned = today
fine = loan.fine(member.policy, today)
if fine:
member.fines_due += fine
self._publish(DomainEvent("FineCharged", member.id, loan.copy.isbn, fine))
copy = loan.copy
queue = self.holds[copy.isbn]
if queue:
copy.status, copy.reserved_for = CopyStatus.ON_HOLD_SHELF, queue.popleft()
self._publish(DomainEvent("HoldReady", copy.reserved_for, copy.isbn))
else:
copy.status = CopyStatus.AVAILABLE
self._publish(DomainEvent("CopyReturned", member.id, copy.isbn))
return fine
def place_hold(self, member_id: str, isbn: str) -> None:
with self._lock:
if self.catalogue.available_copy(isbn) is not None:
raise ValueError("a copy is available to borrow") # nothing would ever wake this hold
if member_id not in self.holds[isbn]:
self.holds[isbn].append(member_id)
def search(self, field_name: str, text: str) -> list:
return self.catalogue.search(field_name, text)
def pay_fine(self, member_id: str, amount: int) -> None:
m = self.members[member_id]
with self._lock:
m.fines_due = max(0, m.fines_due - amount)
The hold shelf state is the detail that makes holds real: a returned copy that someone is waiting for is neither available nor on loan, and the reserved_for field ties the copy to exactly one member until they collect it or the hold expires.
6. Extensions, concurrency and what interviewers probe
- "Holds expire after three days on the shelf." A
HoldReadyevent carries a deadline; a daily job (or a lazy check inborrow) moves the copy to the next member in the queue, the same expiry technique as elsewhere. - "Renewals." A method that extends
dueif no hold exists on the title; one check, one assignment. - "Different rules for staff." Another
LoanPolicyvalue; no subclass. - Concurrency: two desks returning and borrowing the same barcode must serialise on the copy, and two returns of one title must not both take the same hold; the code takes one lock per
Library, which is acceptable at branch scale, and a lock per copy is the step if asked to scale. - Test first: a return with a hold goes to the shelf and only the holder can borrow it; the fine is days late times the member's rate; the loan limit counts only open loans.
- Common mistake: one
Bookclass with anis_availableflag, and fines charged insideborrowinstead of at return.
02Where this question was reported
- Educative · Original Grokking OOD case study
- Educative · Grokking LLD chapter 9
- DesignGurus · DesignGurus case study (Everyday Objects)
- LLD Mastery · Easy; loan lifecycle, Observer, search strategies (Amazon/Adobe/Microsoft, not Google-tagged)
- LLD Canvas · Separate Book (catalog) from BookItem (physical copy)
- Codemia · Codemia Medium
- GitHub · awesome-low-level-design, Medium
- workat.tech · workat.tech machine coding (SDE II)
- systemdesign.academy · systemdesign.academy solved
- Educative · Educative LLD-for-SD-roles: roles, books, transactions; Factory
- GeeksforGeeks · GFG worked example for the 6-step method (Book, User, Library)
- raw.githubusercontent.com · 'Design a logging library with levels and sinks' (oop)