What this prompt is really asking
Copies · limits · fines
Book ≠ BookItem
checkout / return
holds · renew
Analogy: catalog card vs shelf copy
The catalog card (Book/ISBN) is not the thing in your backpack. The barcode sticker (BookItem) is. Holds wait on the card — “any copy of this title” — while loans attach to a specific sticker.
Clarifying questions → locked requirements
- You: Multiple copies per title? They: Yes — search by title/ISBN; borrow a specific copy.
- You: Loan limit and loan period? They: Cap concurrent loans per member; fixed due days (e.g. 14).
- You: Late returns? They: Fine = days overdue × rate; block checkout if unpaid fines exceed a threshold (or keep fines simple and just record them).
- You: Reservations when all copies are out? They: Nice-to-have — queue on the title; notify on return.
- You: Persistence / multi-branch / payments UI? They: In-memory OO model; one library; no payment gateway.
Entities: Book vs BookItem (the core insight)
Book with a copies: int and borrower_id, you cannot answer “which copy does Alice have?” or “put copy #3 on the repair rack.” Split identity from instance.- Book — catalog record: ISBN, title, authors. Owns a list of BookItems and (optionally) a reservation queue. Members search Books.
- BookItem — one physical copy: barcode, rack/location,
status(AVAILABLE / LOANED / RESERVED / LOST). Members borrow BookItems. - Member — id, name, active loan count / loan ids, outstanding fines, optional blocked flag.
- Loan (or BookLending) — member + item + checkout date + due date + optional return date. The transaction record.
- Catalog — indexes Books for search; not a second source of truth for stock (stock lives on BookItems).
- LibraryService — orchestrates checkout/return/search; enforces limits and fine policy.
Class design from the requirements
from enum import Enum, auto
from datetime import date, timedelta
from typing import Optional
from collections import defaultdict, deque
class ItemStatus(Enum):
AVAILABLE = auto()
LOANED = auto()
RESERVED = auto()
LOST = auto()
class Book:
def __init__(self, isbn: str, title: str, authors: list[str]):
self.isbn, self.title, self.authors = isbn, title, authors
self.items: list["BookItem"] = []
self.hold_queue: deque[str] = deque() # member ids
class BookItem:
def __init__(self, barcode: str, book: Book, rack: str = ""):
self.barcode, self.book, self.rack = barcode, book, rack
self.status = ItemStatus.AVAILABLE
class Member:
def __init__(self, member_id: str, name: str, loan_limit: int = 5):
self.id, self.name = member_id, name
self.loan_limit = loan_limit
self.active_loans: dict[str, "Loan"] = {} # barcode → Loan
self.fines_owed: float = 0.0
def can_borrow(self) -> bool:
return len(self.active_loans) < self.loan_limit and self.fines_owed < 50.0
class Loan:
def __init__(self, member: Member, item: BookItem,
checkout: date, due: date):
self.member, self.item = member, item
self.checkout, self.due = checkout, due
self.returned: Optional[date] = None
class Catalog:
def __init__(self):
self.by_isbn: dict[str, Book] = {}
self.by_title: dict[str, list[Book]] = defaultdict(list)
self.by_author: dict[str, list[Book]] = defaultdict(list)
def add_book(self, book: Book) -> None: ...
def search(self, *, isbn=None, title=None, author=None) -> list[Book]: ...
class LibraryService:
LOAN_DAYS = 14
FINE_PER_DAY = 1.0
def __init__(self):
self.catalog = Catalog()
self.members: dict[str, Member] = {}
self.items: dict[str, BookItem] = {} # barcode → item
def checkout(self, member_id: str, barcode: str,
today: date) -> Optional[Loan]: ...
def return_item(self, member_id: str, barcode: str,
today: date) -> float: ... # fine charged
def reserve(self, member_id: str, isbn: str) -> bool: ...
import java.time.LocalDate;
import java.util.*;
enum ItemStatus { AVAILABLE, LOANED, RESERVED, LOST }
class Book {
String isbn, title;
List<String> authors;
List<BookItem> items = new ArrayList<>();
Deque<String> holdQueue = new ArrayDeque<>(); // member ids
Book(String isbn, String title, List<String> authors) {
this.isbn = isbn; this.title = title; this.authors = authors;
}
}
class BookItem {
String barcode, rack;
Book book;
ItemStatus status = ItemStatus.AVAILABLE;
BookItem(String barcode, Book book, String rack) {
this.barcode = barcode; this.book = book; this.rack = rack == null ? "" : rack;
}
}
class Member {
String id, name;
int loanLimit;
Map<String, Loan> activeLoans = new HashMap<>(); // barcode → Loan
double finesOwed = 0.0;
Member(String memberId, String name, int loanLimit) {
this.id = memberId; this.name = name; this.loanLimit = loanLimit;
}
boolean canBorrow() {
return activeLoans.size() < loanLimit && finesOwed < 50.0;
}
}
class Loan {
Member member;
BookItem item;
LocalDate checkout, due;
LocalDate returned; // nullable
Loan(Member member, BookItem item, LocalDate checkout, LocalDate due) {
this.member = member; this.item = item;
this.checkout = checkout; this.due = due;
}
}
class Catalog {
Map<String, Book> byIsbn = new HashMap<>();
Map<String, List<Book>> byTitle = new HashMap<>();
Map<String, List<Book>> byAuthor = new HashMap<>();
void addBook(Book book) { /* ... */ }
List<Book> search(String isbn, String title, String author) { /* ... */ return List.of(); }
}
class LibraryService {
static final int LOAN_DAYS = 14;
static final double FINE_PER_DAY = 1.0;
Catalog catalog = new Catalog();
Map<String, Member> members = new HashMap<>();
Map<String, BookItem> items = new HashMap<>(); // barcode → item
Loan checkout(String memberId, String barcode, LocalDate today) { /* ... */ return null; }
double returnItem(String memberId, String barcode, LocalDate today) { /* ... */ return 0; }
boolean reserve(String memberId, String isbn) { /* ... */ return false; }
}
Implementation highlights
checkout and return_item in real code — status transitions, limits, and fine math.def checkout(self, member_id: str, barcode: str,
today: date) -> Optional[Loan]:
member = self.members.get(member_id)
item = self.items.get(barcode)
if member is None or item is None:
return None
if not member.can_borrow():
return None
if item.status is ItemStatus.RESERVED:
# only the head of the hold queue may take it
if not item.book.hold_queue or item.book.hold_queue[0] != member_id:
return None
elif item.status is not ItemStatus.AVAILABLE:
return None
item.status = ItemStatus.LOANED
due = today + timedelta(days=self.LOAN_DAYS)
loan = Loan(member, item, today, due)
member.active_loans[barcode] = loan
if item.book.hold_queue and item.book.hold_queue[0] == member_id:
item.book.hold_queue.popleft()
return loan
def return_item(self, member_id: str, barcode: str, today: date) -> float:
member = self.members.get(member_id)
item = self.items.get(barcode)
if member is None or barcode not in member.active_loans:
raise ValueError("no active loan")
loan = member.active_loans.pop(barcode)
loan.returned = today
days_late = max(0, (today - loan.due).days)
fine = days_late * self.FINE_PER_DAY
member.fines_owed += fine
if item.book.hold_queue:
item.status = ItemStatus.RESERVED
# notify(item.book.hold_queue[0], item) # Observer / callback
else:
item.status = ItemStatus.AVAILABLE
return fine
def reserve(self, member_id: str, isbn: str) -> bool:
book = self.catalog.by_isbn.get(isbn)
if book is None or member_id not in self.members:
return False
if any(i.status is ItemStatus.AVAILABLE for i in book.items):
return False # go checkout instead
if member_id in book.hold_queue:
return False
book.hold_queue.append(member_id)
return True
Loan checkout(String memberId, String barcode, LocalDate today) {
Member member = members.get(memberId);
BookItem item = items.get(barcode);
if (member == null || item == null) return null;
if (!member.canBorrow()) return null;
if (item.status == ItemStatus.RESERVED) {
// only the head of the hold queue may take it
if (item.book.holdQueue.isEmpty()
|| !item.book.holdQueue.peekFirst().equals(memberId)) {
return null;
}
} else if (item.status != ItemStatus.AVAILABLE) {
return null;
}
item.status = ItemStatus.LOANED;
LocalDate due = today.plusDays(LOAN_DAYS);
Loan loan = new Loan(member, item, today, due);
member.activeLoans.put(barcode, loan);
if (!item.book.holdQueue.isEmpty()
&& item.book.holdQueue.peekFirst().equals(memberId)) {
item.book.holdQueue.pollFirst();
}
return loan;
}
double returnItem(String memberId, String barcode, LocalDate today) {
Member member = members.get(memberId);
BookItem item = items.get(barcode);
if (member == null || !member.activeLoans.containsKey(barcode)) {
throw new IllegalArgumentException("no active loan");
}
Loan loan = member.activeLoans.remove(barcode);
loan.returned = today;
long daysLate = Math.max(0, java.time.temporal.ChronoUnit.DAYS.between(loan.due, today));
double fine = daysLate * FINE_PER_DAY;
member.finesOwed += fine;
if (!item.book.holdQueue.isEmpty()) {
item.status = ItemStatus.RESERVED;
// notify(item.book.holdQueue.peekFirst(), item); // Observer / callback
} else {
item.status = ItemStatus.AVAILABLE;
}
return fine;
}
boolean reserve(String memberId, String isbn) {
Book book = catalog.byIsbn.get(isbn);
if (book == null || !members.containsKey(memberId)) return false;
for (BookItem i : book.items) {
if (i.status == ItemStatus.AVAILABLE) return false; // go checkout instead
}
if (book.holdQueue.contains(memberId)) return false;
book.holdQueue.addLast(memberId);
return true;
}
Verification trace
- Catalog has Book ISBN-1 with items A (AVAILABLE) and B (AVAILABLE). Member M has 0 loans.
- checkout(M, A, day0) → Loan due day14; A=LOANED; M.active=1.
- checkout(M, B, …) until loan_limit → further checkout returns None.
- return_item(M, A, day20) → 6 days late → fine 6×rate; A=AVAILABLE (or RESERVED if queue non-empty).
- Another member reserved ISBN-1 while both copies were out → on return, status=RESERVED and notify head of queue.
len(active_loans); never checkout an item that isn’t AVAILABLE (or RESERVED for that member).Extensibility follow-ups
- Renew — extend
loan.dueif no one else is waiting onbook.hold_queueand member isn’t over fine threshold. - Lost / damaged —
ItemStatus.LOST; close loan; charge replacement fee via FinePolicy. - Observer notifications —
ReservationNotifier.on_item_returned(book, item); LibraryService calls it after setting RESERVED — don’t wire SMTP in the domain. - Multi-branch — BookItem gains
branch_id; search filters by branch; transfer between branches is inventory-move (see inventory). - Concurrency — lock per BookItem barcode on checkout/return; hold_queue mutations under the Book’s lock. Ordered locks if you ever touch two items in one transaction.
Common interview pitfalls
These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.
- Collapsing Book (title) and BookItem (copy) into one class — checkout becomes wrong.
- No due dates / fines model when interviewer asks “what happens if late?”
- Reservation queue ignored when all copies are out.
- Member borrowing limits not enforced.
- Return that doesn’t clear the right BookItem state.
- Searching catalog mutates inventory.
Interview script (say this)
Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.
- Clarify catalog vs copies, checkout/return, due dates, fines, reservations, member limits.
- Entities: Book (ISBN/metadata), BookItem (barcode, state), Member, Loan, ReservationQueue.
- API: checkout(item, member), return(item), reserve(book, member), calculate_fine.
- States on BookItem: AVAILABLE, LOANED, RESERVED, LOST.
- Trace: two members want last copy — loan vs reserve queue.
- Extensions: renewals, digital licenses as different LoanPolicy.
Extra verification traces
Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.
def checkout(self, item_id, member_id):
item = self.items[item_id]
with item.lock:
member = self.members[member_id]
if not member.can_borrow():
raise RuntimeError("limit")
if item.state != "AVAILABLE":
raise RuntimeError("unavailable")
item.state = "LOANED"
return Loan(item_id, member_id, due=self.now()+self.loan_period)
Loan checkout(String itemId, String memberId) {
BookItem item = items.get(itemId);
synchronized (item.lock) {
Member member = members.get(memberId);
if (!member.canBorrow()) throw new RuntimeException("limit");
if (!"AVAILABLE".equals(item.state)) throw new RuntimeException("unavailable");
item.state = "LOANED";
return new Loan(itemId, memberId, now().plus(loanPeriod));
}
}
Staff-level follow-ups
At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.
- Inter-library loan? — ExternalSource port; BookItem may be VIRTUAL_IN_TRANSIT.
- Fine rules change? — FinePolicy strategy by membership tier.
- eBooks? — LicensePool with concurrent seat count instead of physical BookItem.
- Hold shelf expiry? — RESERVED with TTL; expire job notifies next in queue.
Complete solution: checkout under lock
from datetime import date, timedelta
from enum import Enum, auto
from threading import Lock
from typing import Optional
class ItemStatus(Enum):
AVAILABLE = auto()
LOANED = auto()
RESERVED = auto()
class BookItem:
def __init__(self, barcode: str, isbn: str):
self.barcode, self.isbn = barcode, isbn
self.status = ItemStatus.AVAILABLE
class Member:
def __init__(self, mid: str, limit: int = 5):
self.id = mid
self.limit = limit
self.loans: dict[str, "Loan"] = {}
self.fines = 0.0
class Loan:
def __init__(self, member: Member, item: BookItem, due: date):
self.member, self.item, self.due = member, item, due
class Library:
LOAN_DAYS = 14
FINE = 1.0
def __init__(self):
self.items: dict[str, BookItem] = {}
self.members: dict[str, Member] = {}
self._lock = Lock()
def checkout(self, member_id: str, barcode: str, today: date) -> Optional[Loan]:
with self._lock:
m = self.members[member_id]
it = self.items[barcode]
if len(m.loans) >= m.limit or it.status is not ItemStatus.AVAILABLE:
return None
it.status = ItemStatus.LOANED
loan = Loan(m, it, today + timedelta(days=self.LOAN_DAYS))
m.loans[barcode] = loan
return loan
def return_item(self, member_id: str, barcode: str, today: date) -> float:
with self._lock:
m = self.members[member_id]
loan = m.loans.pop(barcode, None)
if loan is None:
raise ValueError("no loan")
fine = max(0, (today - loan.due).days) * self.FINE
m.fines += fine
loan.item.status = ItemStatus.AVAILABLE
return fine
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
enum ItemStatus { AVAILABLE, LOANED, RESERVED }
class BookItem {
String barcode, isbn;
ItemStatus status = ItemStatus.AVAILABLE;
BookItem(String barcode, String isbn) {
this.barcode = barcode; this.isbn = isbn;
}
}
class Member {
String id;
int limit;
Map<String, Loan> loans = new HashMap<>();
double fines = 0.0;
Member(String mid, int limit) { this.id = mid; this.limit = limit; }
}
class Loan {
Member member;
BookItem item;
LocalDate due;
Loan(Member member, BookItem item, LocalDate due) {
this.member = member; this.item = item; this.due = due;
}
}
class Library {
static final int LOAN_DAYS = 14;
static final double FINE = 1.0;
Map<String, BookItem> items = new HashMap<>();
Map<String, Member> members = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
Loan checkout(String memberId, String barcode, LocalDate today) {
lock.lock();
try {
Member m = members.get(memberId);
BookItem it = items.get(barcode);
if (m.loans.size() >= m.limit || it.status != ItemStatus.AVAILABLE) {
return null;
}
it.status = ItemStatus.LOANED;
Loan loan = new Loan(m, it, today.plusDays(LOAN_DAYS));
m.loans.put(barcode, loan);
return loan;
} finally {
lock.unlock();
}
}
double returnItem(String memberId, String barcode, LocalDate today) {
lock.lock();
try {
Member m = members.get(memberId);
Loan loan = m.loans.remove(barcode);
if (loan == null) throw new IllegalArgumentException("no loan");
long days = Math.max(0, ChronoUnit.DAYS.between(loan.due, today));
double fine = days * FINE;
m.fines += fine;
loan.item.status = ItemStatus.AVAILABLE;
return fine;
} finally {
lock.unlock();
}
}
}
Concurrency cases
- Two members, one copy — checkout race; lock on Library (or per barcode).
- Return vs checkout — serialize so AVAILABLE isn’t torn.
- Hold queue notify — mutate status under lock; send email after.