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.
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.
Student / Faculty / Public reader. Searches, borrows, reserves, returns, pays fines.
Counter staff. Adds books, processes check-outs, blocks bad-actor members, settles fines.
Silent actor. Runs the daily fine accrual job, fires reservation-available notifications, sends due-date reminders.
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.
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.
"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.
Member wants "anything by Sedgewick". The HashMap-by-title can't answer that. Search needs proper indexing.
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.
Faculty get longer grace; students don't. Public members pay double. Fine is a strategy.
Two librarians at two counters scan the same last copy at the same instant. Both succeed. No locking.
System tightly calls email API inside checkout method. SMTP slow → counter slow. Notification needs Observer + async.
What books exist, how to search them, where their copies live. Read-heavy, indexed by Repository. Source of truth = BookItem state machine.
Who has what right now, who's waiting, what fines are due. Write-heavy, audited, transactional. BookLending, BookReservation, Fine live here.
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.
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.
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.
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.
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".
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.
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.
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.
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.
Append-only record of every state change.
Solves: Disputes ("I returned that book on Tuesday!") become evidence, not arguments.
| Entity | Responsibility | Key Fields |
|---|---|---|
Library | Singleton root | String name, Address addr, Catalog catalog, Map<String,ReservationQueue> queues |
Book | Abstract bibliographic record (one per ISBN) | String isbn, String title, List<Author> authors, String subject, BookFormat format |
BookItem | One physical copy with a barcode | String barcode, Rack rack, BookStatus status, boolean isReferenceOnly |
Member / Librarian | Account subclasses | Person person, MemberType type, int totalCheckedOut |
BookLending | One checkout record | String barcode, String memberId, LocalDate borrowed, LocalDate dueDate, LocalDate returnedDate |
BookReservation | One waitlist entry | String bookIsbn, String memberId, LocalDateTime createdAt, ReservationStatus status |
Fine | One fine record | String memberId, BigDecimal amount, LocalDate accruedOn, FineStatus status |
Catalog | Search facade | SearchIndex titleIdx, authorIdx, subjectIdx |
FineStrategy | Pluggable fine calculator | (no fields, behavior only) |
CatalogHides storage. Tomorrow when the catalog moves to Postgres + Elasticsearch, members and librarians don't notice.
BookItemAVAILABLE → 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.
FineStrategyPer-member-type fine calculation. New rule? New class. No edits to lending logic.
NotificationServiceBookItem fires "reserved-available" event; service emails the head of the queue. Async — doesn't block the librarian's counter.
LibraryOne library per process. Wires Catalog + queues + strategies at boot.
NotificationFactoryReturns Email, SMS, or Push notifier based on member preferences.
Priya reserves a book that's currently checked out. 8 days later Raj returns it. Watch how the queue and the observer collaborate.
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 }
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; } }
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; } }
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; } }
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()); } }
Catalog (read-heavy, search) and Lending (write-heavy, transactional). Don't merge them.
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.
BookItem is the only stateful entity that matters. AVAILABLE → LOANED → RESERVED → LOANED — every transition is a method.
Multiple members reserve the same book. FIFO. Single-record reservation is a bug.
Different rules per member type. Strategy.
Don't block the librarian's counter on SMTP. Observer + queue.
HashMap of exact strings doesn't scale. Repository hides the index implementation.
Counter Service starts a transaction; everything inside is single-threaded per BookItem.
| Area | Grokking | This design | Why it matters |
|---|---|---|---|
| Reservation | Single BookReservation record per item; second reservation overwrites | FIFO ReservationQueue per Book ISBN | 5 students reserving "Cormen" all get a fair-order spot, not random overwrite |
| Search | HashMap<query, List<Book>> — exact match only | Repository facade over inverted indexes (title/author/subject/date) | "anything by Sedgewick" is one query; partial-match works; scales to 1M books |
| Notifications | Inline call from returnBookItem | Observer pattern via EventBus, async delivery | Slow SMTP doesn't block the counter; multiple subscribers (email+SMS+push) for free |
| Fines | One method collectFine(memberId, days) with hardcoded rate | FineStrategy per MemberType (Student / Faculty / Public) | Per-category rules without recompiling the lending code |
| BookItem state | Enum field; methods like checkout don't validate transitions | State machine — illegal transitions throw | "checkout a LOST item" or "return an AVAILABLE item" can't slip through |
| Renew flow | Decrements checkout count then re-lends — with a strange race | Renew is a single method that checks: no waitlist + no fine + within max-extensions | Reference design has a bug where it decrements before knowing the renew will succeed |
| Concurrency | Not addressed | Synchronized on BookItem; ConcurrentHashMap for queues | Two librarians scanning the last copy at the same instant — only one wins |
| E-book vs physical | Same BookItem.checkout | Polymorphism — EBookItem has no Rack, infinite copies, no late fee | Mixed catalog without if/else everywhere |
New SmsNotifier subscribes to the same EventBus. No edits to BookItem or Library.
Add BranchAdapter as a new BookItem source; Catalog routes searches across branches.
New PremiumFineStrategy, new MemberType.PREMIUM. Wired into the fines map; nothing else changes.
| Decision | Alternative | Why this choice |
|---|---|---|
| Repository for search | HashMap by exact title | Real catalogs need fuzzy + multi-field; Repository is the abstraction that lets us swap engines |
| State machine on BookItem | Status enum + scattered checks | Centralized validation = fewer bugs, one place to add a state |
| FIFO queue per Book | Single reservation slot | Models the actual library policy; multiple reservations with priority ordering |
| Strategy for fines | If/else by member type | New rule = new class. No lending-code edits |
| Synchronized BookItem | Optimistic locking | One BookItem at a time is rare contention; the simpler primitive wins |
| EventBus for notifications | Direct call | Decouples + async; multiple subscribers without coupling them to BookItem |
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.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.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.