Design a Library Management System
Model books, physical copies, members, loans, holds and fines with clean responsibilities, a pluggable fine policy and race-free checkout and return.
Requirements
A library system looks simple, which is exactly why interviewers like it: the grading is about modelling (what is a "book"?), business rules (limits, holds, fines) and state changes that stay consistent under concurrent use.
Functional
- Librarians add books (title, author, subject, ISBN) and the physical copies of each book, every copy with its own barcode and rack location.
- Members search the catalog by title, author or subject and see how many copies are on the shelf.
- A member can borrow a copy for 14 days, up to 5 copies at a time, and only if they owe no fines.
- A member can renew a loan (at most twice) unless someone else is waiting for that book.
- If no copy is available, a member can place a hold on the book. When a copy comes back, it is set aside for the first person in the queue and that person is notified.
- Returning late produces a fine per day, capped at a maximum. Members pay fines to borrow again.
Non-functional
- Several librarian desks and self-service kiosks work at the same time: one copy must never be lent to two people, and a member must never exceed the loan limit.
- Fine rules and notification channels (email, SMS) should be swappable without editing core classes.
Out of scope (confirm first)
- Payment processing, authentication, inter-library loans, persistence and hold pickup deadlines (we discuss the last one under Extensions).
Say the numbers out loud ("5 books, 14 days, 25 cents a day, capped at $10"). Concrete rules make your code concrete, and the interviewer can change them later to see whether your design bends or breaks.
Core Entities
The most important modelling decision is splitting the title from the copy. "Clean Code" is one catalog entry, but the library may own four physical copies, each of which can be on a different shelf, lent to a different member, or lost.
| Entity | Responsibility | Key fields |
|---|---|---|
Book |
Catalog entry, shared by all copies (immutable) | isbn, title, author, subject |
BookItem |
One physical copy; owns its own status transitions | barcode, book, rack, status, holdFor |
Member |
Borrower; enforces loan limit and fine block | id, name, status, loanedBarcodes, unpaidFineCents |
Loan |
Who has which copy, since when, due when | id, item, member, issueDate, dueDate, renewals |
Catalog |
Stores books and copies; answers searches | books, copiesByIsbn |
FinePolicy |
Computes the fine for a returned loan | interface |
LibraryObserver |
Receives events such as "your hold is ready" | interface |
Library |
Facade: checkout, return, renew, hold, pay | catalog, members, active loans, hold queues |
ItemStatus (AVAILABLE, LOANED, ON_HOLD, LOST) and MemberStatus (ACTIVE, SUSPENDED) are enums. A hold is placed on a Book (any copy will do) and lives in a per-ISBN FIFO queue, not on a specific copy.
Class Diagram
classDiagram
class Book {
+String isbn
+String title
+String author
+String subject
}
class BookItem {
-String barcode
-String rack
-ItemStatus status
-String holdFor
+checkoutTo(String) boolean
+markReturned() void
+holdFor(String) void
}
class Member {
-String id
-MemberStatus status
-Set loanedBarcodes
-long unpaidFineCents
+canBorrow() boolean
+addFine(long) void
}
class Loan {
-String id
-LocalDate issueDate
-LocalDate dueDate
-int renewals
+extend(int) void
}
class Catalog {
+addBook(Book) void
+addCopy(BookItem) void
+search(SearchField, String) List~Book~
+availableCopies(String) long
}
class FinePolicy {
<<interface>>
+fineCents(Loan, LocalDate) long
}
class PerDayFinePolicy
class LibraryObserver {
<<interface>>
+notify(String, String) void
}
class ConsoleNotifier
class Library {
-Map activeLoans
-Map holdQueues
+checkout(String, String, LocalDate) Loan
+returnItem(String, LocalDate) long
+renew(String, LocalDate) Loan
+placeHold(String, String) int
+payFine(String, long) void
}
Book "1" <-- "many" BookItem
Catalog "1" o-- "many" Book
Catalog "1" o-- "many" BookItem
Loan --> BookItem
Loan --> Member
Library *-- Catalog
Library "1" o-- "many" Member
Library "1" o-- "many" Loan
Library --> FinePolicy
Library --> LibraryObserver
FinePolicy <|.. PerDayFinePolicy
LibraryObserver <|.. ConsoleNotifier
Design Patterns Used
| Pattern | Where | Why it is justified |
|---|---|---|
| Facade | Library |
Desks and kiosks call five verbs; they never coordinate copies, members and queues themselves |
| Strategy | FinePolicy |
Fine rules differ per member type and change with policy (grace days, caps, waived for children). New rule, new class |
| Observer | LibraryObserver |
The library publishes "hold ready" or "overdue" events; email, SMS and push are subscribers it never knows about |
| State (lightweight) | BookItem.status |
Each copy guards its own legal transitions (AVAILABLE -> LOANED, LOANED -> ON_HOLD), so illegal ones fail in one place |
| Information Expert | Member.canBorrow() |
The member owns the data needed for the eligibility check, so it owns the check |
We deliberately do not create Librarian and Member subclasses of an Account hierarchy with permissions baked in. Role checks belong in an authorization layer in front of the facade; mixing them into domain classes makes both harder to test.
The rule "an on-hold copy may only be checked out by the member it is held for" lives inside BookItem.checkoutTo(). Because the copy enforces it, no caller (desk, kiosk, batch job) can bypass the hold queue by accident.
Java Implementation
The code compiles as a single Main.java (Java 17+). Dates are passed in explicitly so the demo is deterministic; production code would inject a java.time.Clock. Money is held in integer cents.
import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicLong; enum ItemStatus { AVAILABLE, LOANED, ON_HOLD, LOST } enum MemberStatus { ACTIVE, SUSPENDED } enum SearchField { TITLE, AUTHOR, SUBJECT } record Book(String isbn, String title, String author, String subject) {} class BookItem { private final String barcode; private final Book book; private final String rack; private ItemStatus status = ItemStatus.AVAILABLE; private String holdFor; BookItem(String barcode, Book book, String rack) { this.barcode = barcode; this.book = book; this.rack = rack; } String getBarcode() { return barcode; } Book getBook() { return book; } String getRack() { return rack; } synchronized ItemStatus getStatus() { return status; } // Atomic check-and-set: an on-hold copy is reserved for exactly one member. synchronized boolean checkoutTo(String memberId) { boolean allowed = status == ItemStatus.AVAILABLE || (status == ItemStatus.ON_HOLD && memberId.equals(holdFor)); if (!allowed) return false; status = ItemStatus.LOANED; holdFor = null; return true; } synchronized void markReturned() { status = ItemStatus.AVAILABLE; holdFor = null; } synchronized void holdFor(String memberId) { status = ItemStatus.ON_HOLD; holdFor = memberId; } } class Member { static final int MAX_LOANS = 5; private final String id; private final String name; private MemberStatus status = MemberStatus.ACTIVE; private final Set<String> loanedBarcodes = new HashSet<>(); private long unpaidFineCents; Member(String id, String name) { this.id = id; this.name = name; } String getId() { return id; } String getName() { return name; } synchronized boolean canBorrow() { return status == MemberStatus.ACTIVE && unpaidFineCents == 0 && loanedBarcodes.size() < MAX_LOANS; } synchronized void addLoan(String barcode) { loanedBarcodes.add(barcode); } synchronized void removeLoan(String barcode) { loanedBarcodes.remove(barcode); } synchronized int loanCount() { return loanedBarcodes.size(); } synchronized void addFine(long cents) { unpaidFineCents += cents; } synchronized long getUnpaidFineCents() { return unpaidFineCents; } synchronized void pay(long cents) { if (cents <= 0 || cents > unpaidFineCents) throw new IllegalArgumentException("Invalid amount"); unpaidFineCents -= cents; } } class Loan { private final String id; private final BookItem item; private final Member member; private final LocalDate issueDate; private LocalDate dueDate; private int renewals; Loan(String id, BookItem item, Member member, LocalDate issueDate, int days) { this.id = id; this.item = item; this.member = member; this.issueDate = issueDate; this.dueDate = issueDate.plusDays(days); } String getId() { return id; } BookItem getItem() { return item; } Member getMember() { return member; } LocalDate getIssueDate() { return issueDate; } synchronized LocalDate getDueDate() { return dueDate; } synchronized int getRenewals() { return renewals; } synchronized void extend(int days) { dueDate = dueDate.plusDays(days); renewals++; } } class Catalog { private final Map<String, Book> books = new ConcurrentHashMap<>(); private final Map<String, List<BookItem>> copiesByIsbn = new ConcurrentHashMap<>(); private final Map<String, BookItem> itemsByBarcode = new ConcurrentHashMap<>(); void addBook(Book book) { books.put(book.isbn(), book); } void addCopy(BookItem item) { if (!books.containsKey(item.getBook().isbn())) throw new IllegalArgumentException("Add the book first"); // Copies are added rarely and read often, so a copy-on-write list fits. copiesByIsbn.computeIfAbsent(item.getBook().isbn(), k -> new CopyOnWriteArrayList<>()).add(item); itemsByBarcode.put(item.getBarcode(), item); } Optional<BookItem> item(String barcode) { return Optional.ofNullable(itemsByBarcode.get(barcode)); } List<BookItem> copiesOf(String isbn) { return copiesByIsbn.getOrDefault(isbn, List.of()); } long availableCopies(String isbn) { return copiesOf(isbn).stream().filter(i -> i.getStatus() == ItemStatus.AVAILABLE).count(); } List<Book> search(SearchField field, String query) { String q = query.toLowerCase(); return books.values().stream().filter(b -> switch (field) { case TITLE -> b.title().toLowerCase().contains(q); case AUTHOR -> b.author().toLowerCase().contains(q); case SUBJECT -> b.subject().toLowerCase().contains(q); }).sorted(Comparator.comparing(Book::title)).toList(); } } interface FinePolicy { long fineCents(Loan loan, LocalDate returnDate); } class PerDayFinePolicy implements FinePolicy { private final long centsPerDay; private final long capCents; PerDayFinePolicy(long centsPerDay, long capCents) { this.centsPerDay = centsPerDay; this.capCents = capCents; } public long fineCents(Loan loan, LocalDate returnDate) { long lateDays = Math.max(0, ChronoUnit.DAYS.between(loan.getDueDate(), returnDate)); return Math.min(capCents, lateDays * centsPerDay); } } interface LibraryObserver { void notify(String memberId, String message); } class ConsoleNotifier implements LibraryObserver { public void notify(String memberId, String message) { System.out.println(" [notify " + memberId + "] " + message); } } class Library { static final int LOAN_DAYS = 14; static final int MAX_RENEWALS = 2; private final Catalog catalog; private final FinePolicy finePolicy; private final List<LibraryObserver> observers = new CopyOnWriteArrayList<>(); private final Map<String, Member> members = new ConcurrentHashMap<>(); private final Map<String, Loan> activeLoans = new ConcurrentHashMap<>(); // barcode -> loan private final Map<String, Deque<String>> holdQueues = new ConcurrentHashMap<>(); // isbn -> member ids private final AtomicLong loanSequence = new AtomicLong(); Library(Catalog catalog, FinePolicy finePolicy) { this.catalog = catalog; this.finePolicy = finePolicy; } void subscribe(LibraryObserver observer) { observers.add(observer); } void addMember(Member m) { members.put(m.getId(), m); } private Member member(String id) { Member m = members.get(id); if (m == null) throw new IllegalArgumentException("Unknown member " + id); return m; } private Deque<String> queue(String isbn) { return holdQueues.computeIfAbsent(isbn, k -> new ArrayDeque<>()); } private void publish(String memberId, String message) { observers.forEach(o -> o.notify(memberId, message)); } Loan checkout(String memberId, String barcode, LocalDate today) { Member m = member(memberId); BookItem item = catalog.item(barcode).orElseThrow(() -> new IllegalArgumentException("Unknown copy " + barcode)); Loan loan; synchronized (m) { // limit check and loan registration must be one step if (!m.canBorrow()) throw new IllegalStateException(m.getName() + " cannot borrow (limit or unpaid fine)"); if (!item.checkoutTo(memberId)) throw new IllegalStateException(barcode + " is not available to " + m.getName()); loan = new Loan("L" + loanSequence.incrementAndGet(), item, m, today, LOAN_DAYS); m.addLoan(barcode); activeLoans.put(barcode, loan); } Deque<String> q = queue(item.getBook().isbn()); synchronized (q) { q.remove(memberId); } // a fulfilled hold leaves the queue return loan; } long returnItem(String barcode, LocalDate today) { Loan loan = activeLoans.remove(barcode); // atomic: a double scan cannot return twice if (loan == null) throw new IllegalArgumentException(barcode + " is not on loan"); Member m = loan.getMember(); long fine = finePolicy.fineCents(loan, today); m.removeLoan(barcode); if (fine > 0) m.addFine(fine); BookItem item = loan.getItem(); Deque<String> q = queue(item.getBook().isbn()); String next; synchronized (q) { // same lock as placeHold, so no waiter is missed next = q.poll(); if (next != null) item.holdFor(next); else item.markReturned(); } if (next != null) publish(next, "'" + item.getBook().title() + "' is ready at the desk (" + barcode + ")"); return fine; } Loan renew(String barcode, LocalDate today) { Loan loan = activeLoans.get(barcode); if (loan == null) throw new IllegalArgumentException(barcode + " is not on loan"); Deque<String> q = queue(loan.getItem().getBook().isbn()); synchronized (q) { if (!q.isEmpty()) throw new IllegalStateException("Cannot renew: other members are waiting"); if (loan.getRenewals() >= MAX_RENEWALS) throw new IllegalStateException("Renewal limit reached"); if (today.isAfter(loan.getDueDate())) throw new IllegalStateException("Overdue loans must be returned"); loan.extend(LOAN_DAYS); } return loan; } int placeHold(String memberId, String isbn) { Member m = member(memberId); Deque<String> q = queue(isbn); synchronized (q) { if (catalog.availableCopies(isbn) > 0) throw new IllegalStateException("A copy is on the shelf; borrow it"); if (q.contains(memberId)) throw new IllegalStateException(m.getName() + " already holds " + isbn); q.addLast(memberId); return q.size(); } } void payFine(String memberId, long cents) { member(memberId).pay(cents); } Catalog getCatalog() { return catalog; } } public class Main { public static void main(String[] args) { Catalog catalog = new Catalog(); Book ddd = new Book("978-0321125217", "Domain-Driven Design", "Eric Evans", "Software"); Book sicp = new Book("978-0262510875", "Structure and Interpretation of Computer Programs", "Abelson", "Software"); Book dune = new Book("978-0441172719", "Dune", "Frank Herbert", "Fiction"); List.of(ddd, sicp, dune).forEach(catalog::addBook); catalog.addCopy(new BookItem("B1", ddd, "R1")); catalog.addCopy(new BookItem("B2", sicp, "R1")); catalog.addCopy(new BookItem("B3", sicp, "R1")); catalog.addCopy(new BookItem("B4", dune, "R7")); Library library = new Library(catalog, new PerDayFinePolicy(25, 1000)); library.subscribe(new ConsoleNotifier()); library.addMember(new Member("M1", "Asha")); library.addMember(new Member("M2", "Ben")); library.addMember(new Member("M3", "Chen")); System.out.println("Search 'software': " + catalog.search(SearchField.SUBJECT, "software").stream().map(Book::title).toList()); LocalDate day0 = LocalDate.of(2026, 11, 2); Loan l1 = library.checkout("M1", "B1", day0); System.out.println("Asha borrowed " + l1.getItem().getBook().title() + ", due " + l1.getDueDate()); attempt("Ben borrows B1", () -> library.checkout("M2", "B1", day0)); System.out.println("Ben's hold position: " + library.placeHold("M2", ddd.isbn())); System.out.println("Chen's hold position: " + library.placeHold("M3", ddd.isbn())); attempt("Asha renews B1", () -> library.renew("B1", day0.plusDays(5))); System.out.println("Asha returns B1 on day 17:"); long fine = library.returnItem("B1", day0.plusDays(17)); System.out.printf(" fine charged: $%.2f%n", fine / 100.0); attempt("Chen borrows held copy B1", () -> library.checkout("M3", "B1", day0.plusDays(17))); Loan l2 = library.checkout("M2", "B1", day0.plusDays(18)); System.out.println("Ben borrowed held copy, due " + l2.getDueDate()); attempt("Asha borrows B4 with unpaid fine", () -> library.checkout("M1", "B4", day0.plusDays(18))); library.payFine("M1", 75); Loan l3 = library.checkout("M1", "B4", day0.plusDays(18)); System.out.println("After paying, Asha borrowed " + l3.getItem().getBook().title()); Loan renewed = library.renew("B4", day0.plusDays(20)); System.out.println("Asha renewed Dune, new due " + renewed.getDueDate() + ", renewals " + renewed.getRenewals()); System.out.println("SICP copies on shelf: " + catalog.availableCopies(sicp.isbn())); attempt("Return B1 twice", () -> { library.returnItem("B1", day0.plusDays(19)); library.returnItem("B1", day0.plusDays(19)); }); } static void attempt(String label, Runnable action) { try { action.run(); System.out.println(label + ": ok"); } catch (RuntimeException e) { System.out.println(label + ": rejected - " + e.getMessage()); } } }
Demo output
Search 'software': [Domain-Driven Design, Structure and Interpretation of Computer Programs] Asha borrowed Domain-Driven Design, due 2026-11-16 Ben borrows B1: rejected - B1 is not available to Ben Ben's hold position: 1 Chen's hold position: 2 Asha renews B1: rejected - Cannot renew: other members are waiting Asha returns B1 on day 17: [notify M2] 'Domain-Driven Design' is ready at the desk (B1) fine charged: $0.75 Chen borrows held copy B1: rejected - B1 is not available to Chen Ben borrowed held copy, due 2026-12-04 Asha borrows B4 with unpaid fine: rejected - Asha cannot borrow (limit or unpaid fine) After paying, Asha borrowed Dune Asha renewed Dune, new due 2026-12-18, renewals 1 SICP copies on shelf: 2 [notify M3] 'Domain-Driven Design' is ready at the desk (B1) Return B1 twice: rejected - B1 is not on loan
Walkthrough of the key decisions:
- Ben cannot borrow B1 while Asha has it, so he places a hold (position 1); Chen queues behind him (position 2).
- Asha cannot renew because two members are waiting, which is the fair behaviour libraries actually use.
- She returns 3 days late: 3 × $0.25 = $0.75. The copy skips the shelf, goes
ON_HOLDfor Ben and he is notified. - Chen cannot take the held copy even though he is in the queue; only Ben can. After Ben borrows it, Ben leaves the queue.
- An unpaid fine blocks borrowing until it is paid. When Ben returns B1, Chen (still queued) is notified. Returning the same barcode a second time is rejected because
activeLoans.removesucceeds only once.
Concurrency Considerations
The danger points are classic check-then-act races:
- Same copy, two desks.
BookItem.checkoutTois a synchronized compare-and-set on the copy's status. Exactly one caller seesAVAILABLEand flips it toLOANED; the other getsfalse. - Loan limit. Two kiosks could each see "4 loans" and both lend a fifth and sixth book.
checkoutholds the member's monitor acrosscanBorrow()andaddLoan(), making eligibility plus registration one atomic step per member. - Lost wake-up on holds. Without care,
placeHoldmight see "no copy available" while a concurrent return sees "queue empty" and shelves the copy, leaving the waiter stuck forever. Both operations synchronize on the same per-ISBN queue, so one of them always observes the other. - Double return.
ConcurrentHashMap.removereturns the loan to only one caller, so a double-scanned barcode is not fined twice.
Mind the lock order. Here the order is always member, then copy (checkout) and queue, then copy (return, hold). A copy never acquires another lock, so no cycle exists and the design cannot deadlock. State your lock order explicitly in the interview.
In a real deployment, state lives in a database. The per-copy lock becomes a conditional update (UPDATE book_item SET status='LOANED' WHERE barcode=? AND status='AVAILABLE', then check that one row changed), and the loan limit becomes a check inside the same transaction, or a SELECT ... FOR UPDATE on the member row.
Extensions
| Requirement | Change |
|---|---|
| Hold pickup deadline (e.g. 3 days) | Store holdExpiresAt on the copy; a daily job or lazy check passes the copy to the next member in the queue |
| Different member types (student, faculty) | MembershipPlan value object with loan limit, loan days and its own FinePolicy; Member holds a plan |
| Fast search on millions of titles | Inverted indexes per field, or delegate to a search engine; Catalog.search keeps the same signature |
| Overdue reminders | Scheduled job scans loans due tomorrow and publishes through the same observers |
| Lost or damaged copies | ItemStatus.LOST plus a replacement-cost FinePolicy decorator |
| E-books with license counts | DigitalItem that allows N concurrent loans instead of one; checkout logic is polymorphic |
| Audit history | Keep closed Loan records instead of discarding them; useful for "who had it last" questions |
Interview Tips
- Lead with the Book vs BookItem split; it is the first thing interviewers look for.
- List the business rules as numbers, then show exactly which class enforces each one.
- Keep the facade thin: it orchestrates, while copies and members guard their own invariants.
- Mention the lost wake-up race on holds; few candidates spot it and it shows real concurrency thinking.
- Use integer cents for fines and inject dates or a clock for testability.
- Do not over-model: skip
Librariansubclasses, racks as objects and payment flows unless asked.