Skip to content

Design a Library System

The library is the catalogue plus transactions archetype, and it turns on a single modelling decision that most candidates get wrong in the first thirty seconds: “Dune” is not a thing you can borrow. Three copies of Dune are.

Get that split right and the rest of the design falls out. Get it wrong and every follow-up — availability, holds, overdue notices, damaged stock — needs a workaround.

When it is not this. If every instance is interchangeable and you only need a count, you have resource allocation — a car park does not care which bay you took, only that one is free. If the interesting part is modes rather than records, it is a state machine.

This is the whole design, so it is worth being explicit about why.

A catalogue of two titles — Dune with 3 copies, Neuromancer with 1 — gives 2 items and 4 copies. Every interesting question is about copies:

QuestionAbout
“Do you have Dune?”the Item — it is in the catalogue
“Can I borrow Dune today?”the Copies — is any of the three on the shelf?
“Who has it?”a Loan — which links one copy to one member
“Copy 2 is water-damaged”one Copy — the other two are fine

With two of three Dune copies on loan, availability is 1 of 3. A design that stores available: bool on the title cannot express that, and a design that stores only a count cannot express “copy 2 is damaged”.

Verified on per-copy state — three Dune copies with states shelf, on loan, damaged:

ModelReports available
A bare count of 3 copies3 — wrong
Per-copy state1 — correct

That is the argument for Copy being a real object rather than an integer.

diagram Library: the catalogue on the left, the transactions on the right mermaid

Note Loan points at a Copy, never at an Item. That single arrow is the design.

library.py
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
 
 
class CopyState(Enum):
    SHELF = "shelf"
    ON_LOAN = "on loan"
    DAMAGED = "damaged"
    LOST = "lost"
 
 
@dataclass(frozen=True)
class Item:
    """A catalogue entry. You cannot borrow this."""
 
    isbn: str
    title: str
 
 
@dataclass
class Copy:
    """A physical thing. This is what a Loan points at."""
 
    copy_id: str
    item: Item
    state: CopyState = CopyState.SHELF
 
    def is_available(self) -> bool:
        return self.state is CopyState.SHELF
 
 
class LoanPolicy(ABC):
    """The extension point: different rules per member tier or item type."""
 
    @abstractmethod
    def loan_days(self) -> int: ...
 
    @abstractmethod
    def fine(self, due_day: int, returned_day: int) -> float: ...
 
 
class StandardPolicy(LoanPolicy):
    def __init__(self, days=14, per_day=0.25, cap=10.0):
        self.days, self.per_day, self.cap = days, per_day, cap
 
    def loan_days(self) -> int:
        return self.days
 
    def fine(self, due_day: int, returned_day: int) -> float:
        overdue = max(0, returned_day - due_day)
        return round(min(overdue * self.per_day, self.cap), 2)
 
 
@dataclass
class Loan:
    copy: Copy
    member_id: str
    out_day: int
    due_day: int
    returned_day: int | None = None
 
 
class Library:
    def __init__(self, policy: LoanPolicy):
        self.policy = policy
        self.copies: dict[str, list[Copy]] = {}          # isbn -> its copies
        self.loans: dict[str, Loan] = {}                 # copy_id -> open loan
        self.holds: dict[str, deque[str]] = {}           # isbn -> member queue
 
    def add_copy(self, copy: Copy) -> None:
        self.copies.setdefault(copy.item.isbn, []).append(copy)
 
    def available(self, isbn: str) -> list[Copy]:
        return [c for c in self.copies.get(isbn, []) if c.is_available()]
 
    def checkout(self, member_id: str, isbn: str, day: int) -> Loan | None:
        free = self.available(isbn)
        if not free:
            return None                                  # expected outcome, not an error
        copy = free[0]
        copy.state = CopyState.ON_LOAN
        loan = Loan(copy, member_id, day, day + self.policy.loan_days())
        self.loans[copy.copy_id] = loan
        return loan
 
    def return_copy(self, copy_id: str, day: int) -> float:
        loan = self.loans.pop(copy_id)
        loan.returned_day = day
        loan.copy.state = CopyState.SHELF
        return self.policy.fine(loan.due_day, day)
 
    def place_hold(self, member_id: str, isbn: str) -> int:
        q = self.holds.setdefault(isbn, deque())
        q.append(member_id)
        return len(q)                                    # the member's position
 
 
lib = Library(StandardPolicy())
dune = Item("978-0441013593", "Dune")
for i in (1, 2, 3):
    lib.add_copy(Copy(f"dune-{i}", dune))
lib.copies[dune.isbn][2].state = CopyState.DAMAGED       # copy 3 is damaged
 
l1 = lib.checkout("amy", dune.isbn, day=0)
l2 = lib.checkout("bob", dune.isbn, day=0)
print([l1.copy.copy_id, l2.copy.copy_id, len(lib.available(dune.isbn))])
print([lib.checkout("cal", dune.isbn, day=0) is None,
       lib.place_hold("cal", dune.isbn)])
print(lib.return_copy("dune-1", day=20))
# expect ['dune-1', 'dune-2', 0]
# expect [True, 1]
# expect 1.5

Three boundaries worth stating:

  • Loan holds a Copy, not an Item. Everything else follows: “who has it” is answerable, and “copy 3 is damaged” does not affect copies 1 and 2.
  • checkout returns None when nothing is free, because that is an expected outcome — the member then places a hold. return_copy uses self.loans.pop(copy_id) and will raise on an unknown copy, which is correct: returning a copy that was never lent is a caller bug.
  • LoanPolicy is the seam for the follow-ups that always come: different loan lengths for DVDs, no fines for students, longer loans for staff.

Dune has three copies; copy 3 is damaged. Amy and Bob both check out on day 0:

StepCopy statesavailable()
startshelf, shelf, damaged2
amy checks outon loan, shelf, damaged1 — got dune-1
bob checks outon loan, on loan, damaged0 — got dune-2
cal checks outunchangedNone — nothing free
cal places a holdunchangedqueue position 1

Verified: ['dune-1', 'dune-2', 0] then [True, 1].

The damaged copy never enters the picture and no special case was needed — is_available() checks for SHELF, so DAMAGED and LOST are excluded by construction. Adding a WITHDRAWN state later requires no change to checkout at all. That is what the state enum buys over a boolean: a boolean would have needed available and not damaged and not lost, and the fourth state would have meant editing every such expression.

Loan period 14 days, 0.25 per day overdue, capped at 10.00:

Out dayDue dayReturnedDays overdueFine
0141400.00
0141510.25
0142061.50
0141008610.00 — capped

Verified. Two things to say out loud:

  • Returning on the due day is not overdue. max(0, returned - due) gives 0, not −1 or 1. Off-by-one here is the most likely bug in the whole design and it is invisible unless you test the boundary exactly.
  • The cap is applied to the final amount, which is what a cap should mean — unlike the parking lot’s pricing, where the cap sits before a multiplier and therefore does not cap what is billed. Same word, two different behaviours, and this page has the correct one.

A fine cap also has a business consequence worth naming: past 40 days overdue there is no marginal incentive to return the book, which is why real libraries switch to “replacement cost” rather than an ever-growing fine. That is the kind of observation that lands well.

EventQueueNote
amy places a hold[amy]position 1
bob places a hold[amy, bob]position 2
cal places a hold[amy, bob, cal]position 3
a copy is returned[bob, cal]reserved for amypopleft
bob is now position 1

Verified. FIFO, and deliberately so. Any priority scheme — shortest loan first, highest tier first — can starve someone indefinitely, and for a public service that is unacceptable regardless of what it does to average wait. This is the same argument as SSTF versus SCAN on the elevator page: starvation-freedom beats average-case optimality when a human is waiting.

The subtlety the queue introduces: a returned copy is now reserved, not available. So available() must exclude it, which means either a RESERVED copy state or a separate reservation record with an expiry. Say which you would pick — the state is simpler, the record handles “amy has 3 days to collect it” properly.

OperationCostNote
available(isbn)O(c)O(c) in copies of that titlec is small — a library has 1–20 copies of a title
checkoutO(c)O(c)dominated by available
return_copyO(1)O(1)dict pop by copy_id
place_holdO(1)O(1)deque append
Serve the next holdO(1)O(1)popleft
“Who has copy X?”O(1)O(1)the loans dict is keyed by copy_id
“What does member M have out?”O(L)O(L) over all open loansneeds a second index if asked often
“What is overdue today?”O(L)O(L) scanor a heap keyed on due_day
Search the catalogue by titleO(n)O(n)a real system uses an inverted index — that is a different problem

Two things worth being precise about:

  • available being O(c)O(c) is fine and worth saying so: a title has a handful of copies, not thousands. Bucketing available copies separately is possible but it duplicates state, and duplicate state is how availability and copy status drift apart.
  • “What does this member have out?” is the missing index. Keying loans by copy_id makes return O(1)O(1) and makes the member query a full scan. If that query matters — and for overdue notices it does — add a second dict member_id -> set[copy_id] and accept that two structures must be kept in step. Naming that trade rather than silently picking one is the answer.
VariantThe changeNotes
One copy per titleThe Item/Copy split collapsesThe toy version, and the trap
Multiple copiesLoan points at a CopyThe real design
Damaged / lost / withdrawnCopyState enum, not booleansNew states need no checkout change
FinesLoanPolicy.fine, cappedBoundary: returning on the due day is not overdue
No fines for studentsA LoanPolicy subclassWhy the policy is abstract
Different loan lengths per item typeloan_days() per policy, chosen by item typeDVDs 3 days, books 14
HoldsA FIFO deque per titlePlus a RESERVED state or a reservation record
Hold expiryA reservation record with a collect-by dateThe state-only version cannot express this
RenewalsExtend due_dayrefuse if a hold existsThe rule people forget
Borrowing limitsMember.max_loans, checked in checkoutNeeds the member index
Overdue noticesA heap keyed on due_day, or a daily scanO(L)O(L) scan is fine daily
Search by title/authorInverted index over the catalogueA different problem — say so
Video rental / equipment loanIdentical designSame archetype
  • Modelling only the title. Book.is_available cannot express “1 of 3 available”, and per-copy state cannot be recovered from a count. Verified: three copies with one on loan and one damaged gives 1 available, where a bare count says 3.
  • Loan pointing at an Item rather than a Copy. Then “who has it?” is unanswerable when three copies are out to three people, and a damaged copy taints the title.
  • Booleans instead of a CopyState enum. available and not damaged and not lost has to be edited everywhere the moment a fourth state appears. state is SHELF does not.
  • Off-by-one on the due date. Returning on the due day is not overdue. Test that exact boundary — max(0, returned - due), and the day-14 row of the table is the case that catches it.
  • An uncapped fine. 86 days overdue at 0.25/day is 21.50 with no cap and 10.00 with one. And past the cap there is no incentive to return the book — real libraries switch to replacement cost.
  • Capping in the wrong place. Cap the amount the member owes. (Contrast the parking lot, where the cap sits before a size multiplier and therefore fails to cap the bill — same word, different behaviour.)
  • A priority hold queue. Any priority rule can starve someone forever. FIFO for a public service, and say that starvation-freedom is the reason.
  • Forgetting that a returned copy under hold is reserved, not available. available() must exclude it, or the next walk-in borrower takes the copy the queue was waiting for.
  • Allowing renewal while a hold exists. The borrower could hold the queue off indefinitely. Renewal must consult the holds, which makes it a Library operation, not a Loan method.
  • Keying loans only by copy_id. Return becomes O(1)O(1) and “what does this member have out?” becomes a full scan. Add a member index if that query matters — and say the two structures must be kept in step.
  • Putting catalogue search in scope. Full-text search over titles and authors is an inverted index, a different problem. Say so and stay at the class level.
  • A LibraryManager doing everything. Catalogue, loans, fines, holds and notices in one class. Each of those is a noun with one responsibility.

Drill 2 — the due-date boundary and the fine cap

Section titled “Drill 2 — the due-date boundary and the fine cap”
They askWhat they’re checkingThe answer
“Model a library.”The one decision that mattersSeparate Item (catalogue entry) from Copy (physical thing), and have Loan point at a Copy. Two titles with 3 and 1 copies is 2 items and 4 copies
“Is Dune available?”Whether the split is realA question about copies, not the title. With 2 of 3 out it is “1 of 3” — a boolean on the title cannot say that
“Copy 2 is water-damaged.”Whether per-copy state existsA CopyState enum on the copy. Verified: three copies with one on loan and one damaged gives 1 available, where a bare count says 3
“Why an enum rather than booleans?”Extensibilitystate is SHELF needs no change when a fourth state appears; available and not damaged and not lost needs editing everywhere
“A member returns a book on the due date.”The off-by-oneNot overdue — max(0, returned - due) is 0. This is the likeliest bug in the design and only an exact-boundary test catches it
“Cap the fines.”Where the cap goesOn the final amount owed. 86 days at 0.25 is 21.50 uncapped and 10.00 capped — and past the cap there is no incentive to return the book, which is why real libraries switch to replacement cost
“Someone wants a book that is out.”HoldsA FIFO deque per title, returning the member’s position. And a returned copy under hold is reserved, not available — otherwise a walk-in takes it
“Should the hold queue have priority tiers?”StarvationNo for a public service: any priority rule can starve someone indefinitely. Same reasoning as SCAN over SSTF
“Can they renew?”The rule everyone forgetsOnly if no hold exists — otherwise the borrower starves the queue. Which makes renew a Library operation, not a Loan method, because a loan does not know about holds
“What does member M have out?”The missing indexO(L)O(L) scan as written, because loans is keyed by copy_id. Add member_id -> set[copy_id] if the query matters, and accept that two structures must stay in step
“What is overdue today?”A second access patternDaily O(L)O(L) scan is genuinely fine, or a heap keyed on due_day if you need it continuously
“Search by author.”ScopeAn inverted index over the catalogue — a different problem from the loan lifecycle. Say so rather than bolting a find_by_author loop onto Library
“Would this work for equipment hire?”The archetypeAlmost unchanged: items, serial-numbered units, loans with due dates, holds. Only the fine policy differs
pch.quizTag pch.quizDefaultTitle
  1. What is the central modelling decision in a library design?

    pch.quizShowAnswer

    B — Separating Item (the catalogue entry) from Copy (the physical thing), with Loan pointing at a Copy — "Dune" is not borrowable; three copies of Dune are. Every follow-up depends on this: availability is a question about copies, "who has it" needs a copy-to-member link, and "copy 2 is damaged" must not affect copies 1 and 3. Get it wrong and each of those needs a workaround.

  2. Dune has 3 copies: one on the shelf, one on loan, one damaged. What does a bare copy count report versus per-copy state?

    pch.quizShowAnswer

    B — The count reports 3; per-copy state correctly reports 1 — Verified. A count knows how many copies exist, not how many are borrowable — and it can never recover the difference. This is the concrete reason Copy is an object with a state rather than an integer on the Item.

  3. Why a CopyState enum rather than `available`, `damaged`, `lost` booleans?

    pch.quizShowAnswer

    B — `state is SHELF` needs no change when a fourth state appears, whereas `available and not damaged and not lost` must be edited everywhere — Same argument as the elevator page's state table, applied to a simpler object: a single state variable makes illegal combinations unrepresentable and makes new states cheap. Verified in the trace — the damaged copy is excluded by construction, and adding WITHDRAWN would require no change to checkout at all.

  4. A member returns a book exactly on the due day. What is the fine?

    pch.quizShowAnswer

    B — 0.00 — max(0, returned - due) is 0, and this exact boundary is the likeliest bug in the design — Verified: fine(14, 14) is 0.0. This off-by-one is invisible unless you test the boundary precisely — returning a day early and a day late both behave sensibly while the on-time case is wrong. It is the single row of the fines table most worth writing a test for.

  5. 86 days overdue at 0.25/day, capped at 10.00. What does the member owe, and what is the business consequence?

    pch.quizShowAnswer

    B — 10.00 — and past the cap there is no marginal incentive to return the book, which is why real libraries switch to replacement cost — Verified. The cap is correct as a consumer protection and creates a perverse incentive at the far end — noticing that second-order effect is the kind of observation that distinguishes a design discussion from a coding exercise. Note also that this cap is applied to the final amount, unlike the parking lot's, which sits before a multiplier and therefore does not cap the bill.

  6. Should the hold queue support priority tiers?

    pch.quizShowAnswer

    B — No for a public service: any priority rule can starve someone indefinitely, and starvation-freedom beats average wait when a human is queuing — This is the same judgement as choosing SCAN over SSTF on the elevator page. A steady stream of higher-priority requests keeps a low-priority member at the back forever, and "better average wait" is no defence for one person never being served. FIFO, and say why.

  7. A copy is returned and someone holds the title. Is that copy available?

    pch.quizShowAnswer

    B — No, it is reserved for the head of the queue; available() must exclude it or a walk-in borrower takes the copy the queue was waiting for — This is the subtlety the hold queue introduces, and it is easy to miss because both states look like "on the shelf" physically. Two implementations: a RESERVED copy state (simpler) or a reservation record with a collect-by date (handles "amy has 3 days to fetch it" properly). Say which you would pick and why.

  8. Can a member renew a loan?

    pch.quizShowAnswer

    B — Only if no hold exists, otherwise the borrower could starve the queue — which makes renew a Library operation rather than a Loan method — The rule people forget, and it is really a boundary question in disguise. A Loan knows about a copy and a member; it does not and should not know about the hold queue. So renewal cannot be `loan.renew()` — it has to be `library.renew(loan)`, which is exactly the kind of placement decision this round is testing.

  9. `loans` is keyed by copy_id. What does that cost?

    pch.quizShowAnswer

    B — Return is O(1), but "what does this member have out?" becomes an O(L) scan; add a member index if that query matters and keep both in step — One index serves one access pattern. Keying by copy_id makes the return path trivial, which is the hot operation, and makes the member query a scan — which matters for overdue notices and borrowing limits. Naming the trade and the second structure, rather than silently picking one, is the answer.

  10. The interviewer asks for search by author. What do you say?

    pch.quizShowAnswer

    B — That is an inverted index over the catalogue — a different problem from the loan lifecycle, and worth scoping out unless they want to go there — A linear loop answers the question and quietly commits you to O(n) search on a catalogue that could be millions of titles. Naming it as a separate concern — full-text indexing — shows you know where the design boundary is, and it is the same instinct as declining to shard the parking lot.

  • The whole design is Item versus Copy. A title is not borrowable; its copies are. Two titles with 3 and 1 copies is 2 items, 4 copies.
  • Loan points at a Copy, never at an Item. That single arrow makes “who has it” and “copy 3 is damaged” answerable.
  • A count cannot replace per-copy state. Three copies, one out and one damaged: per-copy state says 1 available, a count says 3.
  • CopyState enum, not booleansstate is SHELF excludes damaged and lost by construction, and a fourth state costs no edits.
  • Returning on the due day is not overdue. max(0, returned - due). The likeliest bug in the design; test that exact boundary.
  • Cap the fine on the final amount — 86 days at 0.25 is 21.50 uncapped, 10.00 capped. And past the cap the incentive to return disappears, hence replacement cost in real libraries.
  • Holds are FIFO because any priority rule can starve someone. Same argument as SCAN over SSTF.
  • A returned copy under hold is reserved, not available — or a walk-in takes it.
  • Renewal must check the holds, which makes it a Library operation rather than a Loan method.
  • loans keyed by copy_id makes return O(1)O(1) and the member query O(L)O(L). Add member_id -> set[copy_id] if you need it, and say both must stay in step.
  • checkout returns None when nothing is free (expected); return_copy raises on an unknown copy (caller bug).
  • Catalogue search is a separate problem — an inverted index, not a loop on Library.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading