0

Design a library management system

by · 1 day ago· ⑂ 0 remixes

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 a CopyStatus (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 a HoldStatus (WAITING, READY, FULFILLED, CANCELLED, EXPIRED).
  • FinePolicy (ABC) — computes the fine for a returned loan; DailyCappedFine is 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

  1. Under the lock, look up the member and copy. Reject if len(member.loans) >= member.kind.max_loans or fines owed exceed the limit.
  2. If the copy is ON_HOLD_SHELF, only the member whose READY hold has this copy may take it; mark that hold FULFILLED and drop it from the queue. Otherwise the copy must be AVAILABLE.
  3. Set the copy ON_LOAN, create a Loan with due = today + loan_days, and record it on the member and in active.

Return (barcode) — Library → Loan, FinePolicy, Hold

  1. Pop the loan from active; reject if the copy wasn't on loan. Remove it from the member; add FinePolicy.fine_for(loan, today) to member.fines_owed.
  2. Route the copy: if the book's queue has a WAITING hold, that hold becomes READY, gets this copy and a pickup deadline, the copy goes ON_HOLD_SHELF, and listeners notify the member. Otherwise the copy is AVAILABLE.

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 — Library is 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 — FinePolicy makes the fine rule swappable (per day with a cap, flat fee, waived for staff) without touching checkout or return.
  • Observer — listeners decouple "hold is ready" from how it's delivered (email, SMS, app push).
  • State (explicit enums with guarded transitions) — CopyStatus and HoldStatus with 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 it CANCELLED and removes it from the queue; if it was READY, its copy goes through _route like an expiry.
  • Renewals: renew(barcode, today) extends loan.due if the book's queue has no WAITING holds and the loan hasn't hit a max_renewals count on MemberType.
  • Lost or damaged copies: report_lost(barcode) closes the loan, sets the copy LOST, and charges a replacement fee through a second FinePolicy method. A lost copy is never routed to holds.
  • Branches and scale: add branch_id to BookCopy and a pickup branch to Hold; _route prefers local holds or creates a transfer. Persist as tables for books, copies, loans, holds and fines; checkout does SELECT ... FOR UPDATE on the copy row plus a count of the member's active loans in one transaction. Search moves to a full-text index; expire_holds becomes a scheduled job. Fines become Fine rows (loan, amount, paid) instead of one counter, giving an audit trail.

Interviewer expectations

Mid-level (L4)

  • Separates Book from BookCopy and models Loan as 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.

Sign in to comment, vote or remix.