Skip to main content

Library Management

Design the desk software for a library: members check books out, return them, put a hold on a copy that's out, and get charged if they're late. It's a small domain, which is exactly why it's a good interview prompt - there's nowhere to hide a sloppy model behind sheer size, and the fee math has a habit of leaking into three classes if you let it.

Requirements

Functional

  • A Catalog maps a Book (title, author, ISBN) to one or more physical Copys.
  • A Member checks out an available copy, creating a Loan; returning it closes the loan and frees the copy.
  • If every copy of a book is checked out, a member can place a Hold; the next return of that book should go to the earliest hold in line, not back onto the open shelf.
  • Overdue loans accrue a fee, calculated per day late.

Non-functional

  • The overdue fee formula (flat per-day today, tiered or capped tomorrow) must not require editing Loan or Member.
  • Checking whether a book is available, and who is next in line for it, must not require scanning every loan or hold in the system.

Design

Copy is the unit that actually moves between "on the shelf" and "checked out" - Book is just catalog metadata shared by every copy of it. Fee math is pulled into a FeePolicy the same way FeeStrategy handles pricing in Parking Lot: Loan asks it for a number and never computes one itself.

MemberCatalogBookLoancheckout(isbn, member)1findAvailableCopy()2new Loan(copy, member)3returnBook()4resolveReturn(copy)5
  1. 1The member never touches a Copy directly - the catalog is the only class that finds one.
  2. 2The book (not the catalog) knows which of its own copies are on the shelf.
  3. 3A loan is created for the specific copy found - not for "a copy of this book" in the abstract.
  4. 4Returning goes through the loan itself, since it is the one record of who has this exact copy.
  5. 5The book decides whether the returned copy satisfies its own hold queue or goes back on the shelf.

Each Book keeps its own hold queue, so "who's next" is a peek at that book's queue, and returning a copy either satisfies the head of that queue or puts the copy back on the shelf - never both.

checkout()returnBook() [no hold waiting]returnBook() [hold waiting]AVAILABLECHECKED_OUTON_HOLD
Click a state to see its legal transitions.

ON_HOLD is drawn as terminal on purpose, not as an oversight: nowhere in this code does a copy ever leave ON_HOLD - findAvailableCopy() filters for AVAILABLE only, so an on-hold copy cannot even be checked out by the member it was held for. That gap is exactly what the "what's missing" section below calls out in prose; the diagram just makes it visible without needing the prose to be read first.

Class diagram

«interface»FeePolicy+ calculateFee(daysLate): doubleCatalog- booksByIsbn: Map<string, Book>+ checkout(isbn, member): Loan+ placeHold(isbn, member): HoldBook- isbn: string- title: string- copies: List<Copy>- holdQueue: Queue<Hold>+ findAvailableCopy(): Copy+ resolveReturn(copy)Copy- id: string- status: CopyStatusLoan- copy: Copy- member: Member- dueDate: date+ returnBook(): doubleHold- member: Member- placedAt: dateFlatPerDayFeePolicy+ calculateFee(daysLate): doubleMember- id: string- name: string
implementsusescreates
Catalog groups Copies under a Book; Loan asks FeePolicy for the overdue charge; each Book owns its own hold queue.

Code

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
 
enum CopyStatus { AVAILABLE, CHECKED_OUT, ON_HOLD }
 
class Member {
final String id;
final String name;
 
Member(String id, String name) {
this.id = id;
this.name = name;
}
}
 
class Copy {
final String id;
CopyStatus status = CopyStatus.AVAILABLE;
 
Copy(String id) {
this.id = id;
}
}
 
class Hold {
final Member member;
final LocalDate placedAt;
 
Hold(Member member) {
this.member = member;
this.placedAt = LocalDate.now();
}
}
 
interface FeePolicy {
double calculateFee(long daysLate);
}
 
class FlatPerDayFeePolicy implements FeePolicy {
private final double ratePerDay;
 
FlatPerDayFeePolicy(double ratePerDay) {
this.ratePerDay = ratePerDay;
}
 
public double calculateFee(long daysLate) {
return daysLate <= 0 ? 0.0 : daysLate * ratePerDay;
}
}
 
class Loan {
final Copy copy;
final Member member;
final LocalDate dueDate;
private final Book book;
private final FeePolicy feePolicy;
 
Loan(Copy copy, Member member, Book book, LocalDate dueDate, FeePolicy feePolicy) {
this.copy = copy;
this.member = member;
this.book = book;
this.dueDate = dueDate;
this.feePolicy = feePolicy;
copy.status = CopyStatus.CHECKED_OUT;
}
 
double returnBook() {
long daysLate = ChronoUnit.DAYS.between(dueDate, LocalDate.now());
book.resolveReturn(copy);
return feePolicy.calculateFee(daysLate);
}
}
 
class Book {
final String isbn;
final String title;
private final List<Copy> copies;
private final Queue<Hold> holdQueue = new LinkedList<>();
 
Book(String isbn, String title, List<Copy> copies) {
this.isbn = isbn;
this.title = title;
this.copies = copies;
}
 
Optional<Copy> findAvailableCopy() {
return copies.stream().filter(c -> c.status == CopyStatus.AVAILABLE).findFirst();
}
 
void placeHold(Member member) {
holdQueue.add(new Hold(member));
}
 
void resolveReturn(Copy copy) {
Hold nextHold = holdQueue.poll();
if (nextHold != null) {
copy.status = CopyStatus.ON_HOLD;
} else {
copy.status = CopyStatus.AVAILABLE;
}
}
}
 
class Catalog {
private final Map<String, Book> booksByIsbn = new HashMap<>();
private final FeePolicy feePolicy;
 
Catalog(FeePolicy feePolicy) {
this.feePolicy = feePolicy;
}
 
void addBook(Book book) {
booksByIsbn.put(book.isbn, book);
}
 
Loan checkout(String isbn, Member member) {
Book book = booksByIsbn.get(isbn);
Copy copy = book.findAvailableCopy()
.orElseThrow(() -> new IllegalStateException("No copies available for " + isbn));
return new Loan(copy, member, book, LocalDate.now().plusDays(14), feePolicy);
}
 
void placeHold(String isbn, Member member) {
booksByIsbn.get(isbn).placeHold(member);
}
}

Design decisions

  • Book and Copy are separate classes, not one class with a copiesAvailable counter. A counter can tell you how many copies are free, but not which one - and a hold needs to be satisfied by a specific physical copy being returned, not by a number ticking up. Splitting them means a Loan and a Hold can each point at the exact copy they care about.
  • Overdue fee calculation is a FeePolicy, resolved once per Loan.returnBook() call. Same reasoning as the parking lot's FeeStrategy: pricing is the part of a library that changes when policy changes (a fee cap, a grace period, different rates for reference vs. lending copies), so isolating it means Loan never edits when the fee schedule does.
  • The hold queue lives on Book, not on a global HoldManager. A hold is always "next in line for this book," so keeping the queue where the book already lives makes "who gets this copy next" a local check on return, instead of a query that has to filter a system-wide hold table by book first.
  • What's missing for a real system: hold expiration (a member who doesn't pick up within 48 hours loses their place) needs a scheduled sweep this design doesn't include, and multi-branch libraries need Copy to carry a branch and holds to express a pickup-location preference - both left out to keep checkout/return/hold the whole focus.

Common follow-ups

  • How would you let a member choose a pickup branch for a hold? Add a branch field to Hold and change Book.resolveReturn to only satisfy a hold whose branch matches the returning copy's branch, skipping (not popping) a hold for a different branch - a change local to Book and Hold, not to Catalog or Loan.
  • What happens if a member never picks up a satisfied hold? Needs a scheduled sweep that checks how long a copy has sat ON_HOLD and, past a cutoff, calls Book.resolveReturn again to offer it to the next hold or the shelf - a new process, not a change to checkout/return themselves.
  • How would you cap unpaid overdue fees before blocking further checkouts? Catalog.checkout would need to check the member's accumulated unpaid fees (a new field or lookup on Member) before calling findAvailableCopy - Loan and FeePolicy stay unchanged since they only ever calculate a fee, never enforce account-wide limits.
  • How do you support reference-only copies that can never be checked out? Add a checkoutable flag to Copy and have Book.findAvailableCopy skip copies where it's false - Loan and FeePolicy are unaffected since they only ever operate on a copy already handed to them.

Check yourself

Question 1 of 4

Why are Book and Copy separate classes instead of Book holding a copiesAvailable counter?