The 100 problems, solved / Booking, allocation and simulators / 061 · Design a library management system

Design a library management system

Members, copies, loans, holds and fines.

Concepts this design leans on

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 a CopyStatus.
  • Member: id, the LoanPolicy of 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 publishes DomainEvents.
FacadeCatalogueCirculation0..*0..*1..*0..maxLibrarycataloguemembersloansholds: dict[isbn, deque]listenersborrow(member_id, barcode, today) -> Loanreturn_copy(barcode, today) -> intplace_hold(member_id, isbn)search(field, text)Cataloguebooks: dict[isbn, Book]copies: dict[barcode, BookCopy]by_authorby_subjectadd_book(book)add_copy(isbn, barcode)available_copy(isbn)BookisbntitleauthorssubjectBookCopybarcodeisbnstatus: CopyStatusMemberidpolicy: LoanPolicyloans: list[Loan]fines_due: intLoancopymember_idduereturned: int | NoneLoanPolicyperiod_daysmax_loansfine_per_dayfine_block

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

  1. borrow loads the member and the copy, checks the copy is AVAILABLE (or on the hold shelf for this member), the loan count and the fine block.
  2. It creates a Loan due in policy.period_days, appends it to the member and flips the copy to ON_LOAN.
  3. return_copy finds the open loan, stamps the return day, computes the fine and adds it to the member.
  4. If a hold exists for the title, the copy goes to ON_HOLD_SHELF and a HoldReady event names the member; otherwise it becomes AVAILABLE.
  5. 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 HoldReady event carries a deadline; a daily job (or a lazy check in borrow) moves the copy to the next member in the queue, the same expiry technique as elsewhere.
  • "Renewals." A method that extends due if no hold exists on the title; one check, one assignment.
  • "Different rules for staff." Another LoanPolicy value; 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 Book class with an is_available flag, and fines charged inside borrow instead 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)