- Start from actors and use cases (member, librarian, system scheduler). They tell you which classes and methods must exist.
- Separate
Book(title, ISBN) fromBookCopy(barcode, status). Loans and holds attach to copies; search and reservations attach to titles. - Reservations are a FIFO queue per title. A returned copy goes ON_HOLD for the first in line with an expiry; expired holds pass to the next member.
- Fines are a
FinePolicystrategy (per-day rate by member type, capped). Unpaid fines block new loans; reservations block renewals. - Notifications use Observer, so email, SMS or WhatsApp channels plug in without touching loan logic. Full Java code with a dated scenario below.
The library management system is the LLD question that most rewards structured thinking. There is no clever algorithm and no tricky concurrency puzzle at its centre. Instead there are a lot of business rules (limits, due dates, renewals, holds, fines), and the interviewer is watching whether you can organise them into a model where each rule has exactly one home.
That makes it a great problem to practise the classic LLD workflow: actors → use cases → rules → entities → classes → code. If you are new to that workflow, the LLD interview preparation guide explains it; this post applies it end to end.
Actors and Use Cases
Listing actors first stops you from designing a system for nobody. Each actor's goals become use cases, and each use case becomes a method on the service.
| Actor | Use cases |
|---|---|
| Member (student, faculty) | Search catalog, check out, return, renew, reserve a title, cancel reservation, view loans and fines, pay fine |
| Librarian | Add titles and copies, register members, issue and accept returns at the desk, mark copies lost or damaged, waive fines |
| System (daily scheduler) | Expire uncollected holds, send due-soon reminders, send overdue notices |
| Notification channels | Deliver messages (email, SMS, in-app); passive receivers |
Notice that the scheduler is an actor. Many designs forget that holds expire and reminders go out without any human pressing a button, and end up with no place to put that logic.
Business Rules to Confirm
Propose concrete defaults; interviewers prefer that to open-ended questions. These are the ones this design implements:
| Rule | Student | Faculty |
|---|---|---|
| Max books on loan | 5 | 10 |
| Loan period | 14 days | 30 days |
| Fine per late day (example) | ₹5 | ₹2 |
| Fine cap per loan | ₹500 | |
| Renewals | Max 2; not if overdue; not if anyone has reserved the title | |
| Borrowing with unpaid fines | Blocked until paid | |
| Hold pickup window | 3 days, then passes to the next in queue | |
| Reserve when a copy is on the shelf | Not allowed; borrow it instead | |
Rates and limits differ between institutions; the point is that they live in data (an enum or config), not scattered through if statements.
The Key Modelling Decision: Book vs BookCopy
A library may own five copies of Designing Data-Intensive Applications. "The book" means two different things:
Book: the title. ISBN, title, author, subject. Shared by all copies. This is what members search for and reserve ("I want any copy").BookCopy: one physical object with a barcode. It has a status (AVAILABLE, LOANED, ON_HOLD, LOST), a shelf location, a condition. This is what is checked out, returned and held.
Get this wrong and everything downstream breaks: you cannot have two copies on loan at once, or a reservation waits for one specific copy while another identical copy sits on the shelf. Interviewers often ask "how many copies of this book are available?" specifically to see if your model can answer it.
borrowedBy and dueDate on BookCopy. A Loan has its own lifecycle (issued, renewed, returned, fined) and history. Making it a separate class keeps BookCopy simple and gives you a loan history for free.BookCopy lifecycle
Library Management System Class Design
| Class | Owns | Does not own |
|---|---|---|
Catalog | Titles, copies, search index | Who borrowed what |
Member | Type, unpaid fine, active loan barcodes | Fine rules (those are policy) |
Loan | Dates, renewals | Fine computation |
FinePolicy | How much a late return costs | Whether borrowing is allowed |
LibraryService | Orchestration, rule enforcement, reservation queues | How messages are delivered |
That "does not own" column is the single responsibility principle made concrete, and it is a good thing to say out loud. The SOLID principles guide has more examples of this style of reasoning.
Reservations and Holds: A Fair Queue
Reservation is the most interesting part of this problem, because it couples several rules:
- Reserve the title, not a copy. When no copy is available, the member joins a FIFO queue keyed by ISBN.
- On return, check the queue first. If someone is waiting, the copy does not go back to the shelf. It becomes ON_HOLD for the first member, with a pickup deadline, and that member is notified.
- Only the holder can check out a held copy. Anyone else is told it is on hold.
- Holds expire. A daily job finds holds past their deadline, notifies the member, and passes the copy to the next in queue (or back to AVAILABLE).
- Reservations block renewals. Otherwise a borrower could renew forever while others wait.
Why FIFO? It is what members perceive as fair, and it is easy to explain. Priority queues (faculty first) are a reasonable extension: replace the Deque with a PriorityQueue ordered by (priority, reservation time). The rest of the logic is unchanged.
Fine Calculation as a Strategy
The basic rule is lateDays × ratePerDay, capped. In Java, ChronoUnit.DAYS.between(dueOn, returnedOn) gives late days directly; clamp negatives to zero. Libraries change fine rules (grace periods, exam-season amnesty, weekends excluded, higher rates for reference books), so the rule lives behind a FinePolicy interface. The service calls finePolicy.fineFor(loan, today) and does not know the formula.
Dates come from an injected "today" rather than LocalDate.now() inside the logic. That one choice makes every date-based rule testable: the scenario below moves time forward by setting the date.
Notifications with the Observer Pattern
Members need messages when a hold is ready, when it expires, when a book is due tomorrow, when it is overdue, and when a fine is charged. The tempting implementation calls emailService.send(...) inside returnCopy. Then the institution asks for SMS, then WhatsApp, then an in-app inbox, and the core method grows a dependency on each.
Instead, LibraryService publishes domain events to a list of LibraryListeners. An EmailNotifier, an SmsNotifier and an analytics recorder each subscribe. The service has no idea how messages are delivered. That is Observer, and it is also the Dependency Inversion principle: high-level policy depends on an abstraction, not on SMTP. In a larger system the same events would go onto a message queue so delivery failures do not affect checkout; see Kafka vs RabbitMQ vs SQS for that next step.
Full Java Code for the Library Management System
Java 17+, single file, run with java LibraryDemo.java.
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
public class LibraryDemo {
enum MemberType {
STUDENT(5, 14, 5), FACULTY(10, 30, 2);
final int maxLoans;
final int loanDays;
final long finePerDay;
MemberType(int maxLoans, int loanDays, long finePerDay) {
this.maxLoans = maxLoans;
this.loanDays = loanDays;
this.finePerDay = finePerDay;
}
}
enum CopyStatus { AVAILABLE, LOANED, ON_HOLD, LOST }
record Book(String isbn, String title, String author, String subject) { }
static final class BookCopy {
final String barcode;
final Book book;
CopyStatus status = CopyStatus.AVAILABLE;
String heldFor; // member id while ON_HOLD
LocalDate holdUntil;
BookCopy(String barcode, Book book) {
this.barcode = barcode;
this.book = book;
}
}
static final class Member {
final String id;
final String name;
final MemberType type;
long fineDue;
final Set<String> activeLoans = new HashSet<>(); // barcodes
Member(String id, String name, MemberType type) {
this.id = id;
this.name = name;
this.type = type;
}
}
static final class Loan {
final BookCopy copy;
final Member member;
final LocalDate issuedOn;
LocalDate dueOn;
int renewals;
Loan(BookCopy copy, Member member, LocalDate issuedOn, LocalDate dueOn) {
this.copy = copy;
this.member = member;
this.issuedOn = issuedOn;
this.dueOn = dueOn;
}
}
static final class LibraryException extends RuntimeException {
LibraryException(String message) { super(message); }
}
// ===================== Fine policy (Strategy) =====================
interface FinePolicy {
long fineFor(Loan loan, LocalDate returnedOn);
}
static final class PerDayFinePolicy implements FinePolicy {
private final long capPerLoan;
PerDayFinePolicy(long capPerLoan) { this.capPerLoan = capPerLoan; }
public long fineFor(Loan loan, LocalDate returnedOn) {
long lateDays = Math.max(0, ChronoUnit.DAYS.between(loan.dueOn, returnedOn));
return Math.min(capPerLoan, lateDays * loan.member.type.finePerDay);
}
}
// ===================== Notifications (Observer) =====================
interface LibraryListener {
default void holdReady(Member m, BookCopy c) { }
default void holdExpired(Member m, BookCopy c) { }
default void dueSoon(Member m, Loan l) { }
default void overdue(Member m, Loan l, long days) { }
default void fineCharged(Member m, long amount) { }
}
static final class EmailNotifier implements LibraryListener {
public void holdReady(Member m, BookCopy c) {
send(m, "'" + c.book.title() + "' is ready for pickup until " + c.holdUntil);
}
public void holdExpired(Member m, BookCopy c) { send(m, "Your hold on '" + c.book.title() + "' expired"); }
public void dueSoon(Member m, Loan l) { send(m, "'" + l.copy.book.title() + "' is due on " + l.dueOn); }
public void overdue(Member m, Loan l, long days) {
send(m, "'" + l.copy.book.title() + "' is " + days + " day(s) overdue");
}
public void fineCharged(Member m, long amount) { send(m, "Fine of Rs " + amount + " charged"); }
private void send(Member m, String msg) { System.out.println(" [email to " + m.name + "] " + msg); }
}
// ===================== Catalog =====================
static final class Catalog {
private final Map<String, Book> books = new HashMap<>();
private final Map<String, List<BookCopy>> copiesByIsbn = new HashMap<>();
private final Map<String, BookCopy> copiesByBarcode = new LinkedHashMap<>();
private final Map<String, Set<String>> index = new HashMap<>(); // token -> ISBNs
void addBook(Book b) {
books.put(b.isbn(), b);
copiesByIsbn.putIfAbsent(b.isbn(), new ArrayList<>());
for (String field : List.of(b.title(), b.author(), b.subject())) {
for (String token : field.toLowerCase().split("\\W+")) {
if (!token.isBlank()) index.computeIfAbsent(token, k -> new HashSet<>()).add(b.isbn());
}
}
}
void addCopy(String isbn, String barcode) {
Book b = books.get(isbn);
if (b == null) throw new LibraryException("Unknown ISBN " + isbn);
if (copiesByBarcode.containsKey(barcode)) throw new LibraryException("Duplicate barcode " + barcode);
BookCopy c = new BookCopy(barcode, b);
copiesByBarcode.put(barcode, c);
copiesByIsbn.get(isbn).add(c);
}
/** Every query word must match the title, author or subject. */
List<Book> search(String query) {
Set<String> result = null;
for (String token : query.toLowerCase().split("\\W+")) {
if (token.isBlank()) continue;
Set<String> hits = index.getOrDefault(token, Set.of());
if (result == null) result = new HashSet<>(hits);
else result.retainAll(hits);
}
if (result == null) return List.of();
return result.stream().map(books::get).sorted(Comparator.comparing(Book::title)).toList();
}
BookCopy copy(String barcode) {
BookCopy c = copiesByBarcode.get(barcode);
if (c == null) throw new LibraryException("Unknown barcode " + barcode);
return c;
}
List<BookCopy> copiesOf(String isbn) {
List<BookCopy> list = copiesByIsbn.get(isbn);
if (list == null) throw new LibraryException("Unknown ISBN " + isbn);
return list;
}
Collection<BookCopy> allCopies() { return copiesByBarcode.values(); }
}
// ===================== Service (Facade) =====================
static final class LibraryService {
private static final int HOLD_DAYS = 3;
private static final int MAX_RENEWALS = 2;
private final Catalog catalog = new Catalog();
private final Map<String, Member> members = new HashMap<>();
private final Map<String, Loan> activeLoans = new HashMap<>(); // barcode -> loan
private final Map<String, Deque<String>> reservations = new HashMap<>(); // ISBN -> member ids
private final FinePolicy finePolicy;
private final List<LibraryListener> listeners = new ArrayList<>();
private LocalDate today;
LibraryService(FinePolicy finePolicy, LocalDate today) {
this.finePolicy = finePolicy;
this.today = today;
}
Catalog catalog() { return catalog; }
synchronized void setToday(LocalDate d) { today = d; }
void subscribe(LibraryListener l) { listeners.add(l); }
synchronized void addBook(Book b, String... barcodes) {
catalog.addBook(b);
for (String code : barcodes) catalog.addCopy(b.isbn(), code);
}
synchronized void registerMember(String id, String name, MemberType type) {
if (members.putIfAbsent(id, new Member(id, name, type)) != null) throw new LibraryException("Duplicate member " + id);
}
synchronized Loan checkout(String memberId, String barcode) {
Member m = member(memberId);
BookCopy c = catalog.copy(barcode);
if (m.fineDue > 0) throw new LibraryException(m.name + " has unpaid fines of Rs " + m.fineDue);
if (m.activeLoans.size() >= m.type.maxLoans) {
throw new LibraryException(m.name + " already has " + m.type.maxLoans + " books");
}
boolean heldForMe = c.status == CopyStatus.ON_HOLD && memberId.equals(c.heldFor);
if (c.status == CopyStatus.ON_HOLD && !heldForMe) {
throw new LibraryException("'" + c.book.title() + "' (" + barcode + ") is on hold for another member");
}
if (c.status != CopyStatus.AVAILABLE && !heldForMe) {
throw new LibraryException("'" + c.book.title() + "' (" + barcode + ") is " + c.status);
}
c.status = CopyStatus.LOANED;
c.heldFor = null;
c.holdUntil = null;
Loan loan = new Loan(c, m, today, today.plusDays(m.type.loanDays));
activeLoans.put(barcode, loan);
m.activeLoans.add(barcode);
return loan;
}
synchronized long returnCopy(String barcode) {
Loan loan = activeLoans.remove(barcode);
if (loan == null) throw new LibraryException(barcode + " is not on loan");
Member m = loan.member;
m.activeLoans.remove(barcode);
long fine = finePolicy.fineFor(loan, today);
if (fine > 0) {
m.fineDue += fine;
listeners.forEach(l -> l.fineCharged(m, fine));
}
passToNextReserver(loan.copy);
return fine;
}
synchronized void reserve(String memberId, String isbn) {
Member m = member(memberId);
List<BookCopy> copies = catalog.copiesOf(isbn);
if (copies.stream().anyMatch(c -> c.status == CopyStatus.AVAILABLE)) {
throw new LibraryException("A copy is on the shelf; borrow it instead");
}
if (copies.stream().anyMatch(c -> m.activeLoans.contains(c.barcode))) {
throw new LibraryException(m.name + " already has this title");
}
Deque<String> queue = reservations.computeIfAbsent(isbn, k -> new ArrayDeque<>());
if (queue.contains(memberId)) throw new LibraryException(m.name + " already reserved this title");
queue.addLast(memberId);
}
synchronized Loan renew(String memberId, String barcode) {
Loan loan = activeLoans.get(barcode);
if (loan == null || !loan.member.id.equals(memberId)) throw new LibraryException("No such loan");
if (!reservations.getOrDefault(loan.copy.book.isbn(), new ArrayDeque<>()).isEmpty()) {
throw new LibraryException("Cannot renew: other members are waiting for this book");
}
if (today.isAfter(loan.dueOn)) throw new LibraryException("Cannot renew an overdue book");
if (loan.renewals >= MAX_RENEWALS) throw new LibraryException("Renewal limit reached");
loan.dueOn = loan.dueOn.plusDays(loan.member.type.loanDays);
loan.renewals++;
return loan;
}
synchronized void payFine(String memberId, long amount) {
Member m = member(memberId);
if (amount <= 0 || amount > m.fineDue) throw new LibraryException("Invalid amount");
m.fineDue -= amount;
}
/** Run once a day by a scheduler. */
synchronized void runDailyJobs() {
for (BookCopy c : catalog.allCopies()) {
if (c.status == CopyStatus.ON_HOLD && today.isAfter(c.holdUntil)) {
Member m = members.get(c.heldFor);
listeners.forEach(l -> l.holdExpired(m, c));
passToNextReserver(c);
}
}
for (Loan loan : activeLoans.values()) {
long daysLeft = ChronoUnit.DAYS.between(today, loan.dueOn);
if (daysLeft == 1) listeners.forEach(l -> l.dueSoon(loan.member, loan));
else if (daysLeft < 0) listeners.forEach(l -> l.overdue(loan.member, loan, -daysLeft));
}
}
private void passToNextReserver(BookCopy c) {
Deque<String> queue = reservations.getOrDefault(c.book.isbn(), new ArrayDeque<>());
String next = queue.pollFirst();
if (next == null) {
c.status = CopyStatus.AVAILABLE;
c.heldFor = null;
c.holdUntil = null;
return;
}
c.status = CopyStatus.ON_HOLD;
c.heldFor = next;
c.holdUntil = today.plusDays(HOLD_DAYS);
Member m = members.get(next);
listeners.forEach(l -> l.holdReady(m, c));
}
private Member member(String id) {
Member m = members.get(id);
if (m == null) throw new LibraryException("Unknown member " + id);
return m;
}
}
static void attempt(Runnable action) {
try {
action.run();
} catch (LibraryException e) {
System.out.println(" ! " + e.getMessage());
}
}
static List<String> titles(List<Book> books) { return books.stream().map(Book::title).toList(); }
public static void main(String[] args) {
LibraryService lib = new LibraryService(new PerDayFinePolicy(500), LocalDate.of(2026, 10, 1));
lib.subscribe(new EmailNotifier());
lib.addBook(new Book("9781449373320", "Designing Data-Intensive Applications",
"Martin Kleppmann", "Distributed Systems"), "DDIA-1");
lib.addBook(new Book("9780132350884", "Clean Code", "Robert C. Martin", "Software Craftsmanship"),
"CC-1", "CC-2");
lib.registerMember("M1", "Riya", MemberType.STUDENT);
lib.registerMember("M2", "Arjun", MemberType.STUDENT);
lib.registerMember("M3", "Prof. Mehta", MemberType.FACULTY);
System.out.println("Search 'data systems': " + titles(lib.catalog().search("data systems")));
Loan riyaLoan = lib.checkout("M1", "DDIA-1");
System.out.println("Riya borrowed DDIA, due " + riyaLoan.dueOn);
lib.reserve("M2", "9781449373320"); // Arjun joins the queue
lib.setToday(LocalDate.of(2026, 10, 10));
attempt(() -> lib.renew("M1", "DDIA-1")); // blocked by Arjun's reservation
lib.setToday(LocalDate.of(2026, 10, 18));
System.out.println("Riya returned DDIA 3 days late, fine Rs " + lib.returnCopy("DDIA-1"));
attempt(() -> lib.checkout("M3", "DDIA-1")); // on hold for Arjun
attempt(() -> lib.checkout("M1", "CC-1")); // Riya has an unpaid fine
lib.payFine("M1", 15);
System.out.println("Riya borrowed Clean Code, due " + lib.checkout("M1", "CC-1").dueOn);
lib.setToday(LocalDate.of(2026, 10, 22));
lib.runDailyJobs(); // Arjun never collected
System.out.println("DDIA-1 is now " + lib.catalog().copy("DDIA-1").status);
}
}
Scenario Walkthrough
Search 'data systems': [Designing Data-Intensive Applications]
Riya borrowed DDIA, due 2026-10-15
! Cannot renew: other members are waiting for this book
[email to Riya] Fine of Rs 15 charged
[email to Arjun] 'Designing Data-Intensive Applications' is ready for pickup until 2026-10-21
Riya returned DDIA 3 days late, fine Rs 15
! 'Designing Data-Intensive Applications' (DDIA-1) is on hold for another member
! Riya has unpaid fines of Rs 15
Riya borrowed Clean Code, due 2026-11-01
[email to Arjun] Your hold on 'Designing Data-Intensive Applications' expired
DDIA-1 is now AVAILABLE
- Search. "data" matches the title, "systems" matches the subject; both must match, so only DDIA is returned. The inverted index makes this O(tokens), not a scan of every book.
- 1 Oct: Riya borrows the only copy. Student loan period 14 days, so due 15 Oct. Arjun reserves the title and joins the queue.
- 10 Oct: renewal refused because the reservation queue for that ISBN is not empty.
- 18 Oct: late return. Three days late × ₹5 = ₹15. The fine email fires, then the copy goes ON_HOLD for Arjun until 21 Oct and he is notified. The emails print before the return line because
returnCopyruns while the string is being built. - Prof. Mehta cannot take the held copy, and Riya cannot borrow anything while she owes ₹15. After paying, she borrows Clean Code until 1 Nov.
- 22 Oct: hold expires. The daily job notifies Arjun, finds the queue empty, and returns the copy to AVAILABLE.
Persistence, Concurrency and Scale
Interviewers often follow up with "how would this look with a database?" A minimal relational schema maps directly from the classes:
| Table | Key columns | Notes |
|---|---|---|
book | isbn (PK), title, author, subject | Full-text index or a search engine for discovery |
book_copy | barcode (PK), isbn (FK), status, held_for, hold_until, version | Index on (isbn, status) |
member | id (PK), name, type, fine_due | |
loan | id (PK), barcode, member_id, issued_on, due_on, returned_on, renewals | Partial index on active loans; index on due_on for reminder jobs |
reservation | id (PK), isbn, member_id, created_at, status | Queue order = created_at; unique (isbn, member_id) for active rows |
fine_txn | id, member_id, amount, reason, created_at | Ledger instead of a single mutable number for audits |
Concurrency: two desks scanning the same copy, or a member reserving while a librarian processes a return. In memory, the facade's synchronized methods serialise operations, which is fine for one branch. In a database, checkout becomes one transaction: UPDATE book_copy SET status='LOANED', version=version+1 WHERE barcode=? AND status='AVAILABLE' AND version=?, and if zero rows change, someone else won. The ACID and isolation levels guide explains why this conditional update is safe, and the DBMS interview questions cover locking in more depth.
Scale: a university library is small data; one relational database is plenty. Search can move to Elasticsearch when the catalogue includes full text or many branches; the reasoning is in Elasticsearch and inverted indexes. The daily jobs become scheduled tasks that page through holds and loans by date index.
Interview Tips for Library Management LLD
Follow-ups to expect
- Multiple branches: add a
BranchtoBookCopy; reservations may specify a pickup branch; inter-branch transfer becomes a copy state (IN_TRANSIT). - E-books with licence limits: a
DigitalCopywith concurrent-licence count instead of a physical status. Loans auto-expire; no fines. - Lost and damaged books: mark LOST, close the loan, charge replacement cost through a separate
FinePolicyor a lost-item rule. - Waivers and audits: fines as a ledger of transactions (charge, payment, waiver with librarian id), not a single number.
- Notification preferences: each member picks channels; the notifier consults preferences. The service still only publishes events.
Mistakes that cost points
- No distinction between title and copy.
- Putting fine calculation inside
MemberorBookCopy. - Returned copies going straight back to AVAILABLE while people are waiting.
- Holds that never expire because no actor runs time-based jobs.
- Email-sending code inside
returnCopy. - Using
LocalDate.now()inside rules, making them untestable. - A
Librarian extends Memberhierarchy that adds nothing; roles are usually better as permissions on an account.
For more LLD problems with a similar "many rules, one home each" flavour, compare with the parking lot design, and keep the design patterns cheat sheet nearby for Observer and Strategy vocabulary.
Frequently Asked Questions
Turn Use Cases into Clean Designs
Prepflix's LLD course trains the exact skill this problem tests: going from actors and use cases to classes, patterns and working code under interview time limits.
Explore the Prepflix Course →