← Back to Design & Development
Low-Level Design

Library Management System

A real lending library — Repository search, Observer reservation queue, Strategy fines, State pattern for book lifecycle, full Java code & every gap in Grokking's reference plugged.

Step 1

Clarify Requirements

Picture a college library on a Monday morning. Priya is hunting for "Introduction to Algorithms" — Cormen, of which the library has 3 copies. Two are checked out, one is on rack 4-B. She walks to the counter; the librarian scans her card, scans the book, prints a slip with a 10-day due date. Across town, Raj just returned a copy of the same book — and he gets an email because he was on the reservation waitlist. That's the system we're designing.

✅ Functional Requirements

  • Search books by title, author, subject, publication date
  • Multiple copies per book — each is a "BookItem" with its own barcode & rack
  • Members check out, return, renew, and reserve book items
  • Max 5 books per member; max 10 days lending
  • Reservation queue (FIFO) — when a book returns, head of queue is notified
  • Fines for late returns; rates differ by member type (Student / Faculty / Public)
  • Notifications (email/SMS) for: book available after reservation, due-date reminder, overdue fine
  • Librarians add/remove books, block/unblock members

🎯 Non-Functional & 🚫 Out of Scope

  • Concurrency: two members trying to check out the last copy must not both succeed
  • Search latency < 200ms for 1M books — needs an indexed Repository, not a HashMap of exact strings
  • Auditability: every checkout/return/fine event logged

🚫 Out of scope

  • Inter-library loans across branches
  • Reading-room seat booking
  • OCR / barcode hardware drivers
  • E-book DRM & download infrastructure
Interviewer trick: Reservation queueing and "what if 5 people reserved the same book" exposes whether you understand state coordination. If your reservation is a single record with no queue, you've already lost.
Step 2

Actors & Use Cases

flowchart LR M([Member]) L([Librarian]) S([System]) M --> SR[Search] M --> CO[Check-out] M --> RT[Return] M --> RV[Reserve] M --> RN[Renew] M --> PF[Pay Fine] L --> AB[Add / Remove Book] L --> AM[Block / Unblock Member] L --> SC[Scan at Counter] S --> NT[Notify reservation available] S --> NF[Notify overdue / fine] S --> AF[Accrue fine daily] style M fill:#e8743b,stroke:#e8743b,color:#fff style L fill:#4a90d9,stroke:#4a90d9,color:#fff style S fill:#38b265,stroke:#38b265,color:#fff

Member

Student / Faculty / Public reader. Searches, borrows, reserves, returns, pays fines.

Librarian

Counter staff. Adds books, processes check-outs, blocks bad-actor members, settles fines.

System

Silent actor. Runs the daily fine accrual job, fires reservation-available notifications, sends due-date reminders.

Step 3

Story — From a Card File to a Catalog

The fastest way to design a library system: start with an actual paper card file, watch it break, and let each break suggest a class.

Pass 1 — The naive design

A single class Library with a list of books and a method checkout(memberId, bookTitle). The method finds the first book matching the title and marks it taken. Done in 25 lines.

flowchart LR Member --> Library[Library monolith] Library --> Books[(Book list)] Library --> Members[(Member list)] style Library fill:#e05252,stroke:#e05252,color:#fff

💥 Multiple copies

"Cormen" has 3 copies. The naive code can't tell you which one Priya took, where it was on the shelf, or if any are still available. Books and copies are different concepts.

💥 Search by author

Member wants "anything by Sedgewick". The HashMap-by-title can't answer that. Search needs proper indexing.

💥 5 reservations

5 members reserve the same book. When it returns, who gets it? Naive code has one reservation field — newer overwrites older. Reservation is a queue, not a slot.

💥 Late return

Faculty get longer grace; students don't. Public members pay double. Fine is a strategy.

💥 Concurrent check-out

Two librarians at two counters scan the same last copy at the same instant. Both succeed. No locking.

💥 Notification spam

System tightly calls email API inside checkout method. SMTP slow → counter slow. Notification needs Observer + async.

Pass 2 — The mental split

📖 Catalog plane

What books exist, how to search them, where their copies live. Read-heavy, indexed by Repository. Source of truth = BookItem state machine.

🤝 Lending plane

Who has what right now, who's waiting, what fines are due. Write-heavy, audited, transactional. BookLending, BookReservation, Fine live here.

Pass 3 — The production shape

flowchart TB M([Member]) L([Librarian]) M --> CT[① Counter Service] L --> CT CT --> LIB[② Library Singleton] LIB --> CAT[③ Catalog
Repository] CAT --> IDX[(④ Search Indexes
title author subject)] LIB --> BI[⑤ BookItem
State machine] LIB --> RES[⑥ ReservationQueue
per-book FIFO] LIB --> LEN[⑦ BookLending] LIB --> FIN[⑧ FineStrategy] RES -.notify.-> NT[⑨ NotificationService
Observer] LEN -.audit.-> AL[⑩ AuditLog] style CT fill:#e8743b,stroke:#e8743b,color:#fff style LIB fill:#4a90d9,stroke:#4a90d9,color:#fff style CAT fill:#9b72cf,stroke:#9b72cf,color:#fff style IDX fill:#9b72cf,stroke:#9b72cf,color:#fff style BI fill:#3cbfbf,stroke:#3cbfbf,color:#fff style RES fill:#38b265,stroke:#38b265,color:#fff style LEN fill:#d4a838,stroke:#d4a838,color:#000 style FIN fill:#d4a838,stroke:#d4a838,color:#000 style NT fill:#e05252,stroke:#e05252,color:#fff style AL fill:#e05252,stroke:#e05252,color:#fff

Counter Service

The API the librarian's terminal hits. Wraps every operation in a transaction so two simultaneous check-outs of the last copy can't both succeed.

Solves: Concurrency at the boundary — without one, race conditions live everywhere downstream.

Library Singleton

Root of the world. Owns the catalog, the lending records, the reservation queues, the fine strategies.

Solves: One library = one process root. Subsystems can find each other without dependency-injection ceremony.

Catalog (Repository)

The browse / search interface. Thin facade over the search indexes; doesn't expose how data is stored.

Solves: Grokking's HashMap<title, List<Book>> only does exact match. A real Repository plugs into Lucene / Postgres FTS without changing callers.

Search Indexes

Inverted indexes per searchable field. Token-based, not exact-match.

Solves: "Anything by Sedgewick" returns in 5ms across 1M books, not after a linear scan.

BookItem (State Machine)

One physical copy. States: AVAILABLE → RESERVED → LOANED → AVAILABLE again, with a LOST detour. Transitions live as methods that throw on illegal moves.

Solves: Stops "check out a reserved book", "return a book that was never lent", "renew a lost book".

ReservationQueue

A FIFO queue per Book (not per BookItem — any copy of "Cormen" satisfies the reservation). When a copy is returned, head is dequeued and notified.

Solves: Grokking's design has a single BookReservation per book item, and overwrites on second reservation. The queue handles "5 members all want the same book" correctly.

BookLending

Records: which BookItem went to which Member, when, when it's due, when it returned. The audit substrate.

Solves: "Who has my book?" and "all books Priya borrowed last year" become single SQL queries.

FineStrategy

Interface. FlatRateFine charges ₹2/day. StudentFineStrategy waives the first 2 days. FacultyFineStrategy never charges. Plugged in per member-type.

Solves: Reference design has fine logic baked into one method. Strategy lets the librarian configure per category without recompiling.

NotificationService (Observer)

BookItem state changes publish events. NotificationService listens — emails the head of the reservation queue when status flips to RESERVED. Sends due-date reminder 1 day before due.

Solves: Inline calls to email API inside checkout would block the counter. Observer + async queue keeps the UX snappy.

AuditLog

Append-only record of every state change.

Solves: Disputes ("I returned that book on Tuesday!") become evidence, not arguments.

So in short — the catalog plane is read-heavy and indexed; the lending plane is write-heavy and transactional. BookItems are state machines that publish events; reservations are queues that consume them; fines are strategies; notifications are observers. The Library Singleton wires it all up at boot.
Step 4

Core Entities

EntityResponsibilityKey Fields
LibrarySingleton rootString name, Address addr, Catalog catalog, Map<String,ReservationQueue> queues
BookAbstract bibliographic record (one per ISBN)String isbn, String title, List<Author> authors, String subject, BookFormat format
BookItemOne physical copy with a barcodeString barcode, Rack rack, BookStatus status, boolean isReferenceOnly
Member / LibrarianAccount subclassesPerson person, MemberType type, int totalCheckedOut
BookLendingOne checkout recordString barcode, String memberId, LocalDate borrowed, LocalDate dueDate, LocalDate returnedDate
BookReservationOne waitlist entryString bookIsbn, String memberId, LocalDateTime createdAt, ReservationStatus status
FineOne fine recordString memberId, BigDecimal amount, LocalDate accruedOn, FineStatus status
CatalogSearch facadeSearchIndex titleIdx, authorIdx, subjectIdx
FineStrategyPluggable fine calculator(no fields, behavior only)
Step 5

ER / Schema Diagram

erDiagram BOOK { string isbn PK string title string subject string format } AUTHOR { string author_id PK string name } BOOK_AUTHOR { string isbn FK string author_id FK } BOOK_ITEM { string barcode PK string isbn FK string rack_id FK string status boolean reference_only } RACK { string rack_id PK string location } MEMBER { string member_id PK string name string email string member_type string status } BOOK_LENDING { string lending_id PK string barcode FK string member_id FK date borrowed date due date returned } BOOK_RESERVATION { string res_id PK string isbn FK string member_id FK datetime created_at string status int queue_pos } FINE { string fine_id PK string lending_id FK string member_id FK decimal amount string status } BOOK ||--o{ BOOK_ITEM : "has copies" BOOK }o--o{ AUTHOR : "written by" BOOK_ITEM }o--|| RACK : "shelved at" BOOK_ITEM ||--o{ BOOK_LENDING : "lent as" MEMBER ||--o{ BOOK_LENDING : "borrows" MEMBER ||--o{ BOOK_RESERVATION : "reserves" BOOK ||--o{ BOOK_RESERVATION : "queued for" BOOK_LENDING ||--o| FINE : "may incur"
Step 6

Class Diagram

classDiagram class Library { -Catalog catalog -Map queues +checkout(member,barcode) +return(barcode) +reserve(member,isbn) } class Catalog { +searchByTitle(q) +searchByAuthor(q) +searchBySubject(q) } class Book { -String isbn -String title -List authors } class BookItem { -String barcode -BookStatus status +checkout(memberId) +returnItem() +reserve() +markLost() } class ReservationQueue { -Queue waitlist +enqueue(memberId) +dequeueNext() } class FineStrategy { <> +compute(daysLate, member) BigDecimal } class FlatRateFine class StudentFine class FacultyFine class NotificationService { +onBookAvailable(memberId, isbn) } class Account { <> } class Member class Librarian Account <|-- Member Account <|-- Librarian Book <|-- BookItem FineStrategy <|.. FlatRateFine FineStrategy <|.. StudentFine FineStrategy <|.. FacultyFine Library --> Catalog Library --> ReservationQueue Library --> FineStrategy Library --> NotificationService
Step 7

Design Patterns Used

📚 Repository — Catalog

Hides storage. Tomorrow when the catalog moves to Postgres + Elasticsearch, members and librarians don't notice.

🚦 State — BookItem

AVAILABLE → RESERVED → LOANED → AVAILABLE; LOST is a sink. Illegal transitions throw — "checkout a reserved book by anyone but the head of queue" can't compile-pass.

🎯 Strategy — FineStrategy

Per-member-type fine calculation. New rule? New class. No edits to lending logic.

📡 Observer — NotificationService

BookItem fires "reserved-available" event; service emails the head of the queue. Async — doesn't block the librarian's counter.

🔢 Singleton — Library

One library per process. Wires Catalog + queues + strategies at boot.

🏭 Factory — NotificationFactory

Returns Email, SMS, or Push notifier based on member preferences.

Step 8

Sequence — Reserve & Notify

Priya reserves a book that's currently checked out. 8 days later Raj returns it. Watch how the queue and the observer collaborate.

sequenceDiagram actor Priya participant LIB as Library participant Q as ReservationQueue participant BI as BookItem participant NS as NotificationService actor Raj Priya->>LIB: reserve(isbn=Cormen) LIB->>Q: enqueue(Priya) Q-->>LIB: pos=1 LIB-->>Priya: "you are #1 in queue" Note over Raj,LIB: 8 days later Raj->>LIB: return(barcode-7) LIB->>BI: returnItem() BI->>BI: state = AVAILABLE BI->>Q: anyWaiting()? Q->>BI: yes - Priya BI->>BI: state = RESERVED for Priya BI->>NS: publish(BookAvailableEvent) NS-->>Priya: email "Cormen is ready" Note over Priya: has 24h hold; if expired, queue advances
Step 9

State Diagram — BookItem

stateDiagram-v2 [*] --> AVAILABLE : added to catalog AVAILABLE --> LOANED : checkout() AVAILABLE --> RESERVED : reserve() with no current loan LOANED --> AVAILABLE : returnItem() & no waitlist LOANED --> RESERVED : returnItem() & waitlist non-empty RESERVED --> LOANED : checkout() by head of queue RESERVED --> AVAILABLE : reservation hold expires AVAILABLE --> LOST : markLost() LOANED --> LOST : markLost() LOST --> [*]
Step 10

Java Implementation

Enums
public enum BookStatus { AVAILABLE, RESERVED, LOANED, LOST }
public enum ReservationStatus { WAITING, READY_FOR_PICKUP, COMPLETED, CANCELED, EXPIRED }
public enum MemberType { STUDENT, FACULTY, PUBLIC }
public enum BookFormat { HARDCOVER, PAPERBACK, EBOOK, AUDIOBOOK, MAGAZINE }
BookItem — State Machine
public class BookItem extends Book {
  private final String barcode;
  private BookStatus status = BookStatus.AVAILABLE;
  private String reservedFor;       // memberId at head of queue
  private LocalDateTime reservedUntil;

  public synchronized void checkout(String memberId) {
    if (status == BookStatus.RESERVED && !memberId.equals(reservedFor))
      throw new IllegalStateException("reserved for someone else");
    if (status == BookStatus.LOANED || status == BookStatus.LOST)
      throw new IllegalStateException("cannot checkout in state " + status);
    status = BookStatus.LOANED;
    reservedFor = null;
  }

  public synchronized void returnItem(ReservationQueue q, EventBus bus) {
    if (status != BookStatus.LOANED)
      throw new IllegalStateException("only LOANED items can be returned");
    Optional<String> next = q.dequeueNext();
    if (next.isPresent()) {
      status = BookStatus.RESERVED;
      reservedFor = next.get();
      reservedUntil = LocalDateTime.now().plusHours(24);
      bus.publish(new BookAvailableEvent(getIsbn(), barcode, reservedFor));
    } else status = BookStatus.AVAILABLE;
  }

  public synchronized void markLost() { status = BookStatus.LOST; }
}
ReservationQueue — FIFO per book
public class ReservationQueue {
  private final Deque<String> waitlist = new ArrayDeque<>();
  public synchronized void enqueue(String memberId) {
    if (!waitlist.contains(memberId)) waitlist.addLast(memberId);
  }
  public synchronized Optional<String> dequeueNext() {
    return Optional.ofNullable(waitlist.pollFirst());
  }
  public synchronized int positionOf(String memberId) {
    int i = 1;
    for (String id : waitlist) { if (id.equals(memberId)) return i; i++; }
    return -1;
  }
}
FineStrategy
public interface FineStrategy {
  BigDecimal compute(long daysLate, Member m);
}
public class FlatRateFine implements FineStrategy {
  public BigDecimal compute(long d, Member m) {
    return new BigDecimal(d).multiply(new BigDecimal("5"));
  }
}
public class StudentFine implements FineStrategy {
  public BigDecimal compute(long d, Member m) {
    long chargeable = Math.max(0, d - 2);  // 2-day grace
    return new BigDecimal(chargeable).multiply(new BigDecimal("3"));
  }
}
public class FacultyFine implements FineStrategy {
  public BigDecimal compute(long d, Member m) { return BigDecimal.ZERO; }
}
Library — orchestrator
public class Library {
  private static volatile Library instance;
  public static Library getInstance() {
    if (instance == null) synchronized(Library.class) {
      if (instance == null) instance = new Library();
    }
    return instance;
  }
  private final Catalog catalog = new Catalog();
  private final Map<String, ReservationQueue> queues = new ConcurrentHashMap<>();
  private final Map<MemberType, FineStrategy> fines = Map.of(
    MemberType.STUDENT, new StudentFine(),
    MemberType.FACULTY, new FacultyFine(),
    MemberType.PUBLIC,  new FlatRateFine()
  );
  private final EventBus bus = new EventBus();

  public BookLending checkout(Member m, String barcode) {
    if (m.getCheckedOut() >= 5) throw new LimitExceededException();
    BookItem bi = catalog.find(barcode);
    bi.checkout(m.getId());
    return BookLending.create(bi, m, LocalDate.now().plusDays(10));
  }

  public void returnItem(String barcode) {
    BookItem bi = catalog.find(barcode);
    BookLending bl = BookLending.currentFor(barcode);
    long daysLate = Math.max(0, ChronoUnit.DAYS.between(bl.getDueDate(), LocalDate.now()));
    if (daysLate > 0) {
      FineStrategy s = fines.get(bl.getMember().getType());
      Fine.accrue(bl.getMember(), s.compute(daysLate, bl.getMember()));
    }
    bi.returnItem(queues.computeIfAbsent(bi.getIsbn(), k -> new ReservationQueue()), bus);
  }

  public int reserve(Member m, String isbn) {
    ReservationQueue q = queues.computeIfAbsent(isbn, k -> new ReservationQueue());
    q.enqueue(m.getId());
    return q.positionOf(m.getId());
  }
}
Step 11

Intuition — How to arrive at this design from scratch

1️⃣ Two planes

Catalog (read-heavy, search) and Lending (write-heavy, transactional). Don't merge them.

2️⃣ Book ≠ BookItem

Book is the bibliographic record (one per ISBN); BookItem is a physical copy. The reservation is on the Book; the loan is on the BookItem.

3️⃣ State machine on the copy

BookItem is the only stateful entity that matters. AVAILABLE → LOANED → RESERVED → LOANED — every transition is a method.

4️⃣ Reservation is a queue

Multiple members reserve the same book. FIFO. Single-record reservation is a bug.

5️⃣ Fine is variable

Different rules per member type. Strategy.

6️⃣ Notification is async

Don't block the librarian's counter on SMTP. Observer + queue.

7️⃣ Search is indexed

HashMap of exact strings doesn't scale. Repository hides the index implementation.

8️⃣ Concurrency at the boundary

Counter Service starts a transaction; everything inside is single-threaded per BookItem.

Step 12

Improvements over the Grokking Reference Design

AreaGrokkingThis designWhy it matters
ReservationSingle BookReservation record per item; second reservation overwritesFIFO ReservationQueue per Book ISBN5 students reserving "Cormen" all get a fair-order spot, not random overwrite
SearchHashMap<query, List<Book>> — exact match onlyRepository facade over inverted indexes (title/author/subject/date)"anything by Sedgewick" is one query; partial-match works; scales to 1M books
NotificationsInline call from returnBookItemObserver pattern via EventBus, async deliverySlow SMTP doesn't block the counter; multiple subscribers (email+SMS+push) for free
FinesOne method collectFine(memberId, days) with hardcoded rateFineStrategy per MemberType (Student / Faculty / Public)Per-category rules without recompiling the lending code
BookItem stateEnum field; methods like checkout don't validate transitionsState machine — illegal transitions throw"checkout a LOST item" or "return an AVAILABLE item" can't slip through
Renew flowDecrements checkout count then re-lends — with a strange raceRenew is a single method that checks: no waitlist + no fine + within max-extensionsReference design has a bug where it decrements before knowing the renew will succeed
ConcurrencyNot addressedSynchronized on BookItem; ConcurrentHashMap for queuesTwo librarians scanning the last copy at the same instant — only one wins
E-book vs physicalSame BookItem.checkoutPolymorphism — EBookItem has no Rack, infinite copies, no late feeMixed catalog without if/else everywhere
Step 13

Extension Points

📱 SMS reminders

New SmsNotifier subscribes to the same EventBus. No edits to BookItem or Library.

🏫 Inter-library loans

Add BranchAdapter as a new BookItem source; Catalog routes searches across branches.

🎟️ Premium membership

New PremiumFineStrategy, new MemberType.PREMIUM. Wired into the fines map; nothing else changes.

Step 14

Trade-offs & Talking Points

DecisionAlternativeWhy this choice
Repository for searchHashMap by exact titleReal catalogs need fuzzy + multi-field; Repository is the abstraction that lets us swap engines
State machine on BookItemStatus enum + scattered checksCentralized validation = fewer bugs, one place to add a state
FIFO queue per BookSingle reservation slotModels the actual library policy; multiple reservations with priority ordering
Strategy for finesIf/else by member typeNew rule = new class. No lending-code edits
Synchronized BookItemOptimistic lockingOne BookItem at a time is rare contention; the simpler primitive wins
EventBus for notificationsDirect callDecouples + async; multiple subscribers without coupling them to BookItem
Step 15

Interview Q & A

5 members all reserve the same book. Who gets it when it returns?
FIFO. The reservation is a queue per Book ISBN, not a slot per BookItem. When the copy is returned, BookItem flips to RESERVED and binds itself to the head of the queue with a 24-hour pickup hold. If the head doesn't pick up in time, the hold expires and the queue advances.
Two librarians scan the last copy at the same time. What happens?
BookItem.checkout is synchronized on the BookItem instance. The second thread blocks; when it gets the lock, status is already LOANED, so it throws IllegalStateException. The Counter Service translates that into "this copy was just borrowed — try a different copy" on the second screen.
Why is fine a Strategy instead of a method on Member?
Fines change independently of member identity. The library may decide tomorrow to waive late fees during exam week — that's a strategy swap, not a member-data change. Strategy isolates the policy from the people.
A book is lost. What happens to the reservation queue?
markLost() moves the BookItem to LOST. The queue stays — there might be other copies. If all copies of the book are LOST, the librarian explicitly cancels the queue and notifies waiting members. We don't auto-cancel, because the library may purchase a replacement.
How do you support search across 1 million books with sub-200ms latency?
Behind the Repository, swap the in-memory HashMap for an inverted index — Lucene or Postgres FTS. Tokenize on insert; query against tokens. The Catalog interface stays the same; callers don't notice. The reference design's HashMap-by-string-key would linear-scan and time out.
Renew: what's the race condition?
Grokking's renew decrements the member's checkout count before checking the reservation queue. If renew fails (because someone reserved the book), the count is wrong. Our renew is a single transaction: check waitlist + check fine + extend due-date — all-or-nothing.
E-books?
Subclass: EBookItem extends BookItem. Override: checkout doesn't decrement availability (infinite copies); returnItem is a no-op (auto-revoke after due date); no Rack; no late fee. Polymorphism handles the rest — Library doesn't know or care.