Engineering Atlas › Low-Level Design

Code a library catalogue and lending system

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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

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 ..|> FinePolicy

Key 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

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

In the wild

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

  1. Popular titles have more readers than copies, so access needs a queue, and fairness means first come, first served.
  2. People do not always collect, so a held copy needs a deadline, otherwise one absent member blocks the whole queue.
  3. Deadlines expire with nobody at the desk, so expiry runs as a periodic, idempotent job that hands the copy on.
  4. Late fees are disputed and jobs fail, so fines are a pure function of recorded dates, computed once at return.
  5. Rules change by member type and budget year while copies and queues do not, so rules are policies behind interfaces.

Extensibility

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

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →