Code a library catalogue and lending system
Open in the interactive app: practice, quiz, mock interview →In one minute
- A library is a lending business with a queue in front of every popular title. The class list is easy; the design is judged on the circulation rules: who gets the next copy, how long they have to collect it, when a loan may be renewed, and what lateness costs.
- Separate the title (an ISBN, what people ask for) from the copy (a barcode on a shelf, what moves). Loans and hold pickups attach to copies; hold queues attach to titles.
- A copy is a small state machine: on the shelf, on loan, waiting on the hold shelf for one member, and later lost or in repair. Every operation is a guarded transition, and illegal ones fail without side effects.
- Circulation rules vary by member type and change every budget year, so loan length, renewal limits and fines are policies injected into the core, together with the clock.
- The problem shapes are contention (one copy, many borrowers) and ordering (holds served first come, first served, with a time limit that cascades to the next person). The same shape appears in rental cars, shared GPUs and hospital beds.
Requirements
Functional:
- Catalogue titles and add physical copies of a title.
- Members borrow a copy of a title for a fixed loan period and return it.
- Members place a hold on a title when no copy is on the shelf. A returned copy goes to the oldest waiting hold and sits on the hold shelf for a pickup window; if not collected in time, it passes to the next member in the queue.
- Members renew a loan a limited number of times, but not when it is overdue or when someone is waiting for the title.
- Late returns accrue a daily fine, capped per loan. Members whose unpaid balance reaches a threshold cannot borrow until they pay.
- Notify members when a hold is ready or a loan is nearly due.
Non-functional:
- A copy is never lent to two members, and a held copy is never lent to anyone but its holder.
- Fines are reproducible from the loan record and the return date.
- Rules are configurable per branch and per member type without code changes in the core.
Real systems split these concerns into modules, with circulation at the centre (integrated library system; Koha's circulation manual shows the rules in practice). Out of scope: search and recommendations, acquisitions, inter-library loans, e-books with licence counts. The What is LLD lesson already builds the basic loan and hold core; this lesson goes one level deeper into the circulation policies built on top of it.
Entities and relationships
- Title: ISBN, metadata and the hold queue for that title. People wait for a title, not a specific copy, so the queue lives here.
- Copy: barcode, title reference, and a state with its data:
Shelf,OnLoan(member, due, renewals),OnHoldShelf(member, pickupBy),Lost,InRepair. It is the unit of contention. - Member: id, member type (adult, child, staff) and an outstanding fine balance. A member type selects the circulation policy.
- Loan: the record of one borrowing: copy, member, checkout day, due day, renewals used and return day. It outlives the copy's state change, because fines and history need it.
- Hold: member, title, placed-at time and status (waiting, ready, collected, expired, cancelled).
- CirculationPolicy: loan days, maximum renewals and pickup days for a member type. FinePolicy: turns days late into money. Clock supplies today. Notifier sends "ready" and "due soon" messages.
- Library: the façade that front-desk terminals and self-service kiosks call.
Relationships: a title aggregates its copies and owns its hold queue. A copy's state refers to a member by id. A loan references a copy and a member; neither owns it, and loans are kept after return. The library holds policies by reference so a branch can swap them.
The key modelling choice is putting the hold queue on the title and the pickup deadline on the copy. When a copy comes back, the title's queue decides who gets it; once assigned, the copy carries its own deadline, so expiry can be checked copy by copy without scanning queues.
Class diagram
classDiagram
class Library {
-titles: Map~Isbn, Title~
-copies: Map~CopyId, Copy~
-balances: Map~MemberId, int~
+checkout(member, isbn, today) Loan
+renew(copyId, today) Day
+returnCopy(copyId, today) Money
+placeHold(member, isbn) int
+tick(today) Expired[]
+pay(member, amount) Money
}
class Title {
+isbn: Isbn
-queue: MemberId[]
}
class Copy {
+id: CopyId
+isbn: Isbn
+state: CopyState
+member: MemberId
+due: Day
+renewals: int
+pickupBy: Day
}
class Loan {
+copyId: CopyId
+member: MemberId
+due: Day
+returned: Day
}
class CirculationPolicy {
<<interface>>
+loanDays(memberType) int
+maxRenewals(memberType) int
+pickupDays() int
}
class FinePolicy {
<<interface>>
+fine(daysLate) Money
}
class Notifier {
<<interface>>
+holdReady(member, copyId, pickupBy)
}
Library "1" *-- "many" Title
Title "1" o-- "many" Copy
Library "1" o-- "many" Loan
Loan --> Copy
Library --> CirculationPolicy
Library --> FinePolicy
Library --> Notifier
CappedDaily ..|> FinePolicyKey flows
The interesting flow is a return that lands on a busy title, followed by a hold that is never collected.
sequenceDiagram
participant K as ReturnKiosk
participant L as Library
participant F as FinePolicy
participant T as Title
participant N as Notifier
participant J as NightlyTick
K->>L: returnCopy(c1, day 20)
L->>L: lock, loan due day 14
L->>F: fine(6 days late)
F-->>L: 150 cents
L->>T: next waiting member
T-->>L: bob
L->>L: c1 OnHoldShelf(bob, pickupBy 23), unlock
L->>N: holdReady(bob, c1, 23)
L-->>K: fine 150, route to hold shelf
J->>L: tick(day 24)
L->>L: lock, c1 pickup expired
L->>T: next waiting member
T-->>L: cy
L->>L: c1 OnHoldShelf(cy, pickupBy 27), unlock
L->>N: holdReady(cy, c1, 27)Design patterns used and why
- State for the copy lifecycle (Refactoring.Guru). The set of legal operations depends on state: you can renew only
OnLoan, collect onlyOnHoldShelfand only as the holder, and nothing at all onLost. In Python or Go, a tagged state plus guarded transitions is enough; reach for a class per state (see State) when states gain real behaviour, such asInRepairtracking a bindery job. - Strategy for circulation and fine policies. A children's card may have no fines, staff may get 60-day loans, and a branch may run a fine-free amnesty month. Each is a new policy object;
checkoutandreturnCopydo not change. - Observer for notifications. The core emits "hold ready" and "due soon" events; email, SMS and the mobile app subscribe. Send after the lock is released so a slow SMS gateway cannot stall the return desk.
- Façade: front-desk terminals, self-checkout kiosks and the website all call the same
Libraryoperations, which is where every invariant is enforced. - Repository behind the façade (see Repository), so tests run in memory and production on Postgres.
What not to model: a Book, DVD, Magazine hierarchy. Media types differ in loan length and fine rates, which are policy data keyed by a media type field, not behaviour that needs dispatch.
Concurrency and edge cases
- Last copy, two desks. Two members ask for the same title at two terminals. Choosing a shelf copy and marking it on loan must be one atomic step, under a lock or as a conditional update (row locks are covered in the PostgreSQL docs;
UPDATE copy SET state = 'loan', member = $1 WHERE id = $2 AND state = 'shelf', then check the row count). - Return races with hold placement. A member places a hold just as the last loan comes back. If the return reads the queue before the hold is appended, the copy goes to the shelf and the new holder finds it there, which is fine. If the hold check sees "no copy on shelf" and the return then shelves it, the member waits for nothing. Serialize both on the title, or make
placeHoldandreturnCopytake the same per-title lock. - Renewal while someone waits. Renewal must check the queue in the same critical section, or a member can renew a copy the moment after someone places a hold, starving the queue.
- Pickup expiry cascades. An uncollected hold passes the copy to the next member with a fresh window. Run expiry as a periodic tick over copies whose deadline has passed, and treat it as idempotent: running the nightly job twice must not skip a member.
- Fines are computed, not accumulated. Do not run a job that adds 25 cents to every overdue loan each night; it double charges when it reruns and undercharges when it fails. Compute the fine once at return from
dueandreturnedwith the policy, and show a live estimate for loans still out. - Closed days. Open-source systems expose this as a calendar that the due-date and fine rules consult (Koha circulation). Not charging for closed days belongs in the fine and due-date policies, driven by a branch calendar, not in the core.
- Lost copies. A long-overdue copy becomes
Lostwith a replacement fee; if it comes back, reverse the fee and charge the capped late fine.
In the wild
- Microsoft Azure, 2012: the leap-day certificate. An agent inside each VM computed a certificate's expiry by adding one year to the current date. On 29 February that gave 29 February 2013, which does not exist, so certificate creation failed, and healthy hosts were marked faulty. The disruption lasted about 34 hours. A due date computed as "today plus N" needs the same care, and belongs in a tested policy (Azure blog).
- Cloudflare, 2017: time went backwards. At the leap second, Go's wall clock stepped back, so a duration computed as end minus start went negative and reached a function that panics on negative input. About 0.2% of DNS queries were affected at peak. A fine computed from
returned - dueshould clamp negatives and use injected, testable dates (Cloudflare).
Down the stack
Layer 1The contract
checkout(member, isbn, today) lends the copy waiting on the hold shelf for that member if there is one, otherwise the lowest-id shelf copy, and fails without changes if the member is blocked or no copy is available. renew succeeds only for an on-time loan with renewals left and an empty queue, and sets due = today + loanDays. returnCopy returns min(daysLate * rate, cap) and routes the copy to exactly one place: the oldest waiting member's hold shelf or the open shelf. tick(today) moves every hold whose pickupBy < today to the next waiting member or to the shelf, and is idempotent for a given day.
Layer 2The mechanism
In memory, each title owns a FIFO queue (a deque or slice) of member ids, and each copy stores its state and the fields that state needs. Checkout scans the title's copies, which is cheap because a title rarely has more than a few dozen. The tick walks only copies on the hold shelf; keep them in a min-heap by pickupBy if the hold shelf is large. In Postgres, hold rows carry (isbn, placed_at, id) with an index in that order, and "next waiting member" is SELECT ... WHERE isbn = $1 AND status = 'waiting' ORDER BY placed_at, id LIMIT 1 FOR UPDATE. The tie-breaker on id matters: two holds placed in the same millisecond must still have a defined order. Self-service kiosks commonly speak SIP2, a line-based protocol originally from 3M with fixed-format checkout and checkin messages, so the façade must answer within the kiosk's timeout.
Layer 3The machine
Circulation traffic is tiny by web standards: a busy city branch handles a few thousand transactions a day, peaking at a few per second when a school class visits. An uncontended mutex costs about 20 ns and a Postgres commit waits for a WAL fsync of 0.1 to 1 ms on NVMe, so a single lock per branch is never the bottleneck. The nightly tick touches a few thousand rows, well inside a second. The real cost is notifications: each SMS is a network call of hundreds of milliseconds to an external provider that can fail, which is why notifications go through a queue and never run inside the lock.
Why it's built this way
- Popular titles have more readers than copies, so access needs a queue, and fairness means first come, first served.
- People do not always collect, so a held copy needs a deadline, otherwise one absent member blocks the whole queue.
- Deadlines expire with nobody at the desk, so expiry runs as a periodic, idempotent job that hands the copy on.
- Late fees are disputed and jobs fail, so fines are a pure function of recorded dates, computed once at return.
- Rules change by member type and budget year while copies and queues do not, so rules are policies behind interfaces.
Extensibility
- Pickup at another branch: add a destination branch to the hold and an
InTransitcopy state between return and hold shelf; the pickup window starts on arrival, not on return. - E-books: a title with a licence count instead of physical copies. The queue logic is unchanged; checkout decrements a counter rather than changing a copy's state.
- Fine-free policy: many libraries have dropped overdue fines. That is a
FinePolicyreturning zero and a blocking rule based on overdue count instead of balance, with no change to the core. - Suspended holds: a member on holiday freezes their place. Give holds a
suspendedUntiland make "next waiting member" skip suspended holds without losing their position.
Variant: car rental booking
A rental counter looks like a library with a price tag, but one decision changes the model: the customer reserves a category ("compact SUV, Friday to Monday"), not a specific car. A library hold queues for a title and is served in order. A car reservation is a promise against future capacity: the system counts, for every day of the stay, whether active reservations stay within the category's usable fleet. Availability becomes a counting problem per category per day rather than a per-copy state machine, and the stay is a half-open range [start, end) so that a car returned on day 3 and a pickup on day 3 do not collide.
Two objects with two lifecycles replace the single copy state machine. A reservation (reserved, picked up, returned, cancelled, no-show) is paper capacity. A rental agreement is created at pickup and ties the reservation to one physical car, its odometer and fuel level. Paper and metal drift apart: a late return or a car in maintenance means a valid reservation meets an empty bay. So pickup checks physical status, not the calendar, and an UpgradePolicy offers the next category up or a car from another branch, keeping the booked rate.
The contention is also different. Two sites selling the last SUV for overlapping dates need the count-then-insert to be atomic: lock a capacity(category, day) row for each day of the stay, in day order to avoid deadlocks, or serialise on one row per category and branch. A plain overlap query followed by an insert races under read committed isolation. Selling slightly more than the fleet is a deliberate, per-category overbooking factor, never an accident of a race.
Pricing is richer than library fines but follows the same rule: a pure function of recorded facts. Booked days at the daily rate, a late fee for extra days, and a per-kilometre charge above an allowance, all in integer minor units, using a rate card snapshotted on the reservation so later price changes cannot alter the bill. Reject an odometer reading below the pickup reading instead of charging a negative distance. A scheduled no-show job frees capacity after a grace period, using the same injected clock. Add-ons such as child seats are inventory with their own per-day capacity, reserved with the same range-count logic. The problem shape is the same contention and ordering, now counted by time slot; hotels sell room types, and airlines sell fare buckets, the same way (PostgreSQL explicit locking).
Leadership lens
- Build first: atomic checkout and return with hold routing, then the tick. Fines can be corrected by staff; a held copy lent to the wrong person is a public complaint.
- Ask in review: where is the per-title serialization point; is the tick idempotent; are fines computed or accumulated; what happens when a kiosk times out mid-checkout and the member walks away with the book.
- Librarians, not engineers, own the rules. Give them a configuration screen with an audit log of rule changes, and version the policy on each loan so a mid-loan change does not rewrite old fines.
Check yourself
Why does the hold queue belong to the title rather than to a copy?
Show answer
Members want any copy of the title, and whichever copy comes back first should serve the oldest waiting member. A per-copy queue would make members wait behind one specific copy while others sit idle.
Why compute fines at return instead of adding a daily amount with a nightly job?
Show answer
A job that adds money is not idempotent: reruns double charge and failures undercharge. A fine computed from due and return dates is reproducible and easy to audit.
A copy waits on the hold shelf for Bob until day 23. On day 24 the tick runs twice. What must happen?
Show answer
The first run passes the copy to the next waiting member with a new deadline; the second finds no expired hold and does nothing. Expiry is a function of
pickupBy < today, not of how many times the job runs.Why may a member not renew a loan when someone is waiting for the title?
Show answer
Renewal would extend one member's access indefinitely while the queue starves. The check must happen in the same critical section as hold placement, or a race lets a renewal slip in after a hold.
Sources and further study
Primary
- Go 1.9 release notes: transparent monotonic time: the language-level fix for durations that go negative when the wall clock steps.
Systems docs
- Koha manual: Circulation: how a widely used open-source library system handles checkouts, holds, calendars and fines.
- PostgreSQL: Explicit locking: row locks for atomic checkout, hold assignment and per-day capacity rows.
- PostgreSQL: SELECT locking clause:
FOR UPDATE,NOWAITandSKIP LOCKEDfor taking the next waiting hold. - Stripe: currencies and minor units: why money is integer minor units plus a currency code, for fines and rental charges.
Books
- Design Patterns: Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson and Vlissides: chapter 5 (Behavioral, including State, Strategy and Observer).
- Head First Design Patterns, Freeman and Robson: chapter 1 (Strategy) and chapter 2 (Observer).
Other curricula
- Integrated library system (Wikipedia): the modules real library software is split into, with circulation at the centre.
- Refactoring.Guru: State: the pattern used for copy lifecycles, with examples in many languages.
Watch
- Google Interview Question Solved, Low Level Design of Library Management System, Part 1 (Soumyajit Bhattacharyay): an interview-style walkthrough of the entities and use cases.
- State Pattern, Design Patterns ep 17 (Christopher Okhravi): the pattern behind the copy lifecycle.
- Strategy Pattern, Design Patterns ep 1 (Christopher Okhravi): how loan and fine rules become swappable policies.
Free resources
- Integrated library system (Wikipedia): the modules real library software is split into, with circulation at the centre.
- Koha manual: Circulation: how a widely used open-source library system handles checkouts, holds and fines.
- State pattern (Refactoring.Guru): the pattern used for copy lifecycles, with examples in many languages.
- Explicit locking (PostgreSQL docs): row locks for atomic checkout and hold assignment.