The idea
What it is
“Design a library management system.” It is the friendliest-sounding prompt in the set. Everybody has been in a library, nobody has to ask what a book is, and there is no concurrency puzzle waiting to ambush you.
That is exactly why it is dangerous. With no algorithm to hide behind, the interviewer is grading one thing: can you turn a messy real-world domain into classes that hold up? And this problem has a single specific carve that separates a good answer from a bad one — one that most candidates get wrong in the first three minutes and never recover from.
The whole system in three sentences
A Book is a catalogue record — one per title. A BookItem is one physical copy of that title, with a barcode and a status. A BookLending is the act of one member taking one item home on one date — and because it is an object rather than a field, the library still has it next year.
What is actually being graded
- Did you split the title from the copy?
BookversusBookItem. This is the single most common failure in this round, and there is no recovering from it at minute 45 — every method you have written will need changing. - Is a loan an object, or a field?
BookItem.borrowedBycan only hold the present. Fines, late-return history and member standing are all computed from loans that are finished. - Do the rules live on the objects that own the data?
member.canBorrow(today)versus aLibraryServicethat reads the member's loans, reads their fines, and decides for them. - Is time an argument?
returnItem(barcode, on)is testable in one line. A method that reads the clock internally means a fine calculation you cannot test — which means a fine calculation that is wrong. - Does it run? Three titles, several copies, an issue, a return with a fine, and a reservation that intercepts a returned copy. Print it.
Mechanics
How it works
Step 1 · A Book is not a book
The instinct is one class. It has a title, an author, an ISBN — and, because you need to know whether someone can borrow it, an isAvailable boolean. It reads fine. It is wrong the moment the library buys a second copy.
Name the rule, because it transfers
The catalogue entry is a description; the copy is an instance. Once you can say that sentence you have already designed half a dozen other systems: Product versus the SKU versus the unit sitting in the warehouse; Show versus Seat versus Booking; FlightRoute versus a dated Flight. Interviewers ask this problem because the split generalises. More on the vocabulary in Domain modeling and Identifying entities, attributes & behaviors.
The tell that you have it right: search returns Books, borrowing returns a BookItem. Somebody searching wants titles. Somebody at the desk wants the object with a barcode on it. If your search() returns the same type your issue() returns, one of them is lying.
Step 2 · Clarify — 5 minutes
- Multiple copies of the same title? — the only question that really matters. They will say yes. If they say no, ask “would you ever want to?”, because the answer shapes every class you are about to draw.
- Can a member reserve a title when every copy is out? — say yes and keep it to a FIFO queue per title. It is four lines and it makes
returninteresting. - Fines? — yes, per day late, and ask whether the policy might change. That is your invitation to a strategy seam.
- How many books can one member hold? — a number, plus the rules that go with it: expired membership, unpaid fines. These belong on
Member. - Search by what? — title, author, subject, publication date. That is a
Catalogwith index maps, not a linear scan over every book. - Payments, branches, e-books, RFID gates, recommendations? — out of scope. Say it in one sentence and move.
Person → Staff → Librarian → SeniorLibrarian hierarchy has eaten twenty minutes of more than one candidate's round and added nothing the interviewer wanted.Step 3 · A loan is its own object, not a field
You have split Book from BookItem. Now a member borrows a copy. The obvious move is two fields on the item: borrowedBy and dueDate. Set them on issue, null them on return. Done.
Then the interviewer asks “how many times has this member returned a book late?” and the design has no answer, because a field can only hold the present. The moment the copy comes back, everything you knew about that loan is gone.
The general form is worth memorising
When the relationship itself has attributes, it is a class, not a foreign key. A loan has an issue date, a due date, a return date and a fine — four attributes that belong to neither the member nor the copy, but to the pairing of them. That is the definition of an association class. See Association, Aggregation, Composition for the notation.
// ✗ fields on the item — one loan at a time, no past, no fines to compute from
class BookItem {
String borrowedBy; // null when on the shelf
int dueDay; // meaningless when borrowedBy is null
}
// ✓ the act of borrowing, as an entity with a lifecycle
class BookLending {
final String barcode, memberId;
final int issuedOn, dueOn;
Integer returnedOn; // null while it is open
boolean isOpen() { return returnedOn == null; }
long daysLate(int on) { return Math.max(0, on - dueOn); }
}
// the CLOSED lendings are the history: late count, member standing, fines earnedNote what this buys you for free. member.openLoans() is a filter over the list. “Is this member a repeat offender?” is a filter over the same list. And the fine is computed from dueOn versus the day the copy actually came back — two fields on one object, rather than a subtraction spread across three classes.
Step 4 · Rules live on the object that owns the data
Here is the second place this round is lost. You have good classes, and then you write a LibraryService.issueBook() that reaches into all of them and decides. Two hundred lines, every rule in one method, and nothing on Member but getters.
The sentence that scores the point
“The five-book limit is a rule about a member, so it lives on Member. The service asks member.canBorrow(today) and does what it is told.” Say that out loud while you write the method. It is the difference between a class diagram and a script with objects in it.
The same test applies everywhere else. Can this item be loaned? is a question about the item — item.isAvailable(). How late is this? is a question about the lending — lending.daysLate(on). How much is that worth? is the only question the service genuinely owns, and it delegates that to a policy.
The class diagram
Book 1 ─ * BookItem (title vs copy) and Member 1 ─ * BookLending * ─ 1 BookItem (the loan as its own object, sitting between them). Notation: Class diagrams.The item's five states
isAvailable: boolean cannot answer “where is B-003?” when the answer is “a member reported it lost” or “the spine is being repaired”. Somebody always asks about a lost book. Five states, an enum, and the legal transitions written down.
The flows, end to end
Search goes through the Catalog. Keep index maps — Map<String, List<Book>> for title words, author and subject — instead of a linear scan over every book. It is one line to say and it is the difference between “I would loop over all books” and a design. Search returns Books; availability is catalog.availableCount(isbn), a count over their items.
issue() hands back a copy, with a barcode on it. Search hands back titles. If both return the same type, the Book / BookItem split has not really happened. Notation: Sequence diagrams.Return is the flow worth drawing, because it branches. Close the lending, price the lateness — and then ask the reservation queue what should happen to the copy.
Reserve and renew, in four lines
Reserve is only offered when availableCount(isbn) == 0 — otherwise just borrow it. Keep a Deque<String> of member ids per ISBN; FIFO, no priorities, no expiry unless asked. Renew is allowed only when nobody is queued on that title: “you can keep it, because nobody is waiting.” The notification when a copy frees up is one Observer — say the word, do not build an event bus.
if at the end of returnItem().Money and time — the two things people get casually wrong
- Time is an argument.
returnItem(barcode, on)andmember.canBorrow(today)— neverLocalDate.now()read inside the method. A ten-day-late fine is then a one-line test. A method that reads the clock can only be tested by waiting or by injecting a fake clock, and in 60 minutes you will do neither. Same rule Parking Lot used for billing. - Money is an integer in minor units.
long fineMinorin paise or cents, neverdouble.0.1 + 0.2is not0.3, and a fine that is off by a hundredth is a bug report. Format only at the edge, when you print. - A fine policy is a Strategy.
fineFor(daysLate, itemPriceMinor)behind an interface, with a flat per-day version, a slab version and a capped version. Swapping it must not change one character ofreturnItem(). Exactly the same seam as the pricing strategy in Parking Lot — say so, it shows you recognise the shape rather than memorised the problem. - Statuses are an enum with legal transitions, not free-text strings and not a boolean.
AVAILABLE → LOANED → AVAILABLEis the happy path;RESERVED,LOSTandIN_REPAIRare the ones that prove you thought about the real world.
interface FinePolicy {
/** daysLate is already clamped at 0. Money in minor units — paise, never double. */
long fineFor(long daysLate, long itemPriceMinor);
String label();
}
class FlatPerDayFine implements FinePolicy { // 500 paise = 5.00 a day
private final long perDayMinor;
public long fineFor(long daysLate, long price) { return daysLate * perDayMinor; }
}
class SlabFine implements FinePolicy { // gentle first week, then not
public long fineFor(long daysLate, long price) {
long cheap = Math.min(daysLate, 7);
return cheap * 200 + Math.max(0, daysLate - 7) * 1000;
}
}
class CappedFine implements FinePolicy { // never fine more than the cap
private final FinePolicy inner; private final long capMinor;
public long fineFor(long daysLate, long price) {
return Math.min(inner.fineFor(daysLate, price), capMinor);
}
}
// returnItem() calls policy.fineFor(...) and does not change when the policy doesWhat the design costs to extend
The 60 minutes
Book from BookItem by minute 17, the next 43 minutes are spent writing code you will have to change.The follow-ups they will ask
- “Now there are five branches.” → a
branchIdonBookItem(the copy lives somewhere; the title does not), andCatalog.availableCount(isbn, branchId). Then the real question behind it: can you return a copy to a different branch? Yes — the lending closes normally and the item'sbranchIdchanges. Say that; it proves you know which class owns location. - “What about e-books?” → the honest answer, not a fudge. An e-book has no barcode, no rack and no condition, so
BookItemstops meaning anything. Model it as a licence with a concurrent-loan count:Bookstays,BookItemis replaced by aDigitalLicencewithmaxConcurrent. This is a genuinely good question about where the abstraction ends. - “Two librarians issue the last copy at the same time.” →
firstAvailable()thensetStatus(LOANED)is check-then-act, exactly the bug in Coffee Machine. The fix is the same shape: an atomic take on the item — one lock around find-and-claim, or a compare-and-set on the status. One paragraph. Do not turn this into the whole answer; the round is about modelling. - “Notify members when a reserved copy arrives, and before a book is due.” → the library publishes events, and an email or SMS notifier subscribes. Observer again, and it keeps notification code out of
returnItem(). - “Search is slow with two million books.” → the
HashMapindexes stop being enough once you want prefix matching, typo tolerance and ranking. That is a search engine — an inverted index, Lucene or Elasticsearch — and theCataloginterface is the seam you would put it behind. Name the seam, do not design the engine. - “How do you know a member is a repeat offender?” → count the closed
BookLendings withdaysLate > 0. This is the payoff for making the loan an object, and it is worth pointing at when you answer.
How this round is lost
- One
Bookclass withisAvailable. The fatal one. Every method downstream is then wrong, and there is no time at minute 45 to redo it. - A
LibraryServicethat contains every rule. Getters onMember, decisions in the service, and an anaemic model the interviewer will name out loud. - Loans as fields on the item. No history, no late count, no way to compute a fine after the copy is back on the shelf.
doublefor fines. It will be raised, and it is a free point you handed away.- A
Personhierarchy four levels deep. Twenty minutes spent onAbstractLibraryUserand no workingissue()at the end. - Forgetting the reservation branch on return. The copy goes back to the shelf and the member who waited three weeks watches a walk-in take it.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
Three titles, eight physical copies, three members. Pick a member chip, then press 📕 Borrow on a title — the system picks a specific copy and that barcode turns 🟠 and moves onto her card. Borrow again and a different barcode goes out while the header counts down 2 of 3 available. When the last copy is gone the button becomes 🔖 Reserve. Now press 📗 Return on a loaned chip: because someone is queued, the copy does not go back to green — it turns 🔵 and is held for the head of the queue. ⏩ +7 days makes loans overdue and fines appear; the ⏱ Flat / 🪜 Slabs / 🧢 Capped chips re-price every fine without touching returnItem().
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Borrow a copy — and notice which one
Leave M-1 Asha selected and press 📕 Borrow on Clean Code. Watch three things at once: one specific barcode chip turns 🟠, that same barcode appears on Asha's card, and the title header drops to 2 of 3 available. The .callline says library.issue("978-0-13", "M-1", day=1) → BookItem#B-001. She has copy B-001 — not “the book”. That is the entire Book versus BookItem lesson, in one click.
Borrow the same title again
Select M-2 Ravi and press 📕 Borrow on the same title. A different barcode goes out — B-002 — and the header counts down again. Do it once more with M-3 Meera and the count hits 0 of 3 and the button changes to 🔖 Reserve. (📚 Empty the shelf does all three at once.) Ask yourself where any of this would live if Book had a single isAvailable boolean.
Reserve, then return into the queue
With every copy out, press 🔖 Reserve — the selected member joins a visible FIFO queue on the title. Now press 📗 Return on one of the 🟠 chips. The copy does not go green: it turns 🔵 and moves to the head of the queue, and the explain line says exactly that. Return another copy when the queue is empty and that one goes 🟢. One if at the end of returnItem() is the whole difference.
Make something overdue and price it three ways
Press ⏩ +7 days until the day counter passes a due date — overdue chips turn red and a fine appears on the member card. Now click 🪜 Slabs and then 🧢 Capped. Every open fine re-prices instantly and the explain line points out that returnItem() was never touched. That is Strategy doing its only job.
Hit the limit and read where the refusal came from
Press 🚫 Fill to limit with a member selected, then try to borrow again. The refusal names member.canBorrow(day) — the rule is on Member, not in the service. Change it in your head to the ✗ version from the figure: if (service.getLoans(m).size() >= 5). How many places would you have to edit to raise the limit to eight?
Build it from memory
Blank file, in this order: ItemStatus enum → Book (isbn, title, author, subject) → BookItem (barcode, isbn, rack, price in minor units, status) → BookLending (barcode, memberId, issuedOn, dueOn, returnedOn) → Member with canBorrow(today) → Catalog with index maps and firstAvailable(isbn) → Library.issue / returnItem / reserve / renew → a main() that issues three copies, reserves, returns late and prints the fine. If your search() and your issue() return the same type, start again.
In practice
When to use it — and what trips people up
The shape you just learned
Strip the books away and this is a catalogue of descriptions, a pool of instances of those descriptions, and an object recording who has which one and when. That triple shows up everywhere, and getting the first split right is what makes the other two possible.
- E-commerce —
Productdescribes it, aSKUnarrows it, and the unit in the warehouse bin is the instance. “3 in stock” is a count over instances, exactly like “2 of 3 available”. - Ticketing — a
Showis the description, aSeatfor a given show is the instance, and aBookingis the association with dates and money on it. - Airlines —
FlightRoute(AI-302, daily, BLR to DEL) versus a datedFlightyou actually board. Merge them and you cannot cancel Tuesday's. - Car rental — a
CarModelin the brochure versus the registered vehicle with a number plate and a service history. - Hotels — a
RoomTypeversus room 412, and aStaybetween a guest and a room across two dates. - Asset tracking of any kind — the laptop model your company buys versus the laptop with asset tag L-0912 sitting on someone's desk.
The two-sentence version to say out loud
“A Book is a catalogue record and a BookItem is a physical copy, so availability is a count over items and a loan always points at a specific barcode. And a loan is its own object rather than a field, because the finished ones are what fines and member standing are computed from.” Twenty seconds, and it is most of the round.
Where this design stops working
- When the thing has no physical instance. E-books, streaming, software licences — there is no copy to give a barcode to. The model becomes a licence with a concurrent-use count, and forcing
BookItemonto it is worse than admitting the boundary. - When one process is no longer the whole library. Two desks in two buildings claiming the last copy need the store to do the atomic take, not an in-process check-then-set. The modelling is unchanged; the claim step is not.
- When search stops being lookup. Index maps answer “author equals Martin”. They do not answer “books a bit like this one, ranked”. That is a different system behind the same
Cataloginterface. - When the rules stop being a member's business. A national inter-library policy is not an invariant of one
Memberobject, and pushing it there would be Tell, Don't Ask applied past the point where it helps.
If you only remember one thing
The catalogue entry is a description; the copy is an instance. Split them in minute 8, and every later question — how many are available, which one did she take, where is it shelved, who had it last year — has an obvious place to live. Merge them, and none of those questions has an answer at all.
What it gives you
- Splitting Book from BookItem makes availability a count rather than a boolean, so “2 of 3 available”, per-copy racks, conditions and barcodes all have somewhere natural to live.
- Modelling the loan as a BookLending with a nullable returnedOn keeps the full history, so fines, late counts and member standing are queries over data you already have.
- Putting canBorrow() on Member means the limit, the fine ceiling and the expiry check exist in exactly one place, and every screen that needs the answer asks the same question.
- A FinePolicy interface lets flat, slab and capped pricing be swapped without returnItem() changing at all — the same seam as a pricing strategy anywhere else.
- Passing the date into issue(), returnItem() and canBorrow() makes every money calculation a one-line unit test instead of something you can only observe by waiting.
Common mistakes
- Two classes where beginners expect one means more objects to create and keep consistent, and a bulk import of a thousand titles now has to create items too.
- Keeping every closed BookLending forever grows without bound; a real system needs archiving, and the design says nothing about when history stops being worth keeping.
- A single FIFO reservation queue per title has no expiry and no priority, so a reserver who never collects can hold a copy out of circulation indefinitely.
- The in-process find-then-claim on an item is check-then-act, and it is only safe because this design assumes one process — two desks need an atomic take at the store.
- The BookItem abstraction is genuinely physical, so digital formats do not fit it at all and require a parallel model rather than a subclass.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
import java.util.*;
import java.util.function.BiConsumer;
/*
* Dates are plain day numbers so the demo is deterministic. Real code uses
* LocalDate — what matters in the round is that the date is PASSED IN,
* never read from a clock inside the method.
*/
enum ItemStatus { AVAILABLE, RESERVED, LOANED, LOST, IN_REPAIR }
/** The CATALOGUE RECORD. One per title, however many copies the library owns. */
record Book(String isbn, String title, String author, String subject, int publishedYear) {}
/** ONE PHYSICAL COPY — the thing a member actually carries home. */
final class BookItem {
final String barcode, isbn, rack;
final int acquiredOn;
final long priceMinor; // paise — integer minor units, never double
private ItemStatus status = ItemStatus.AVAILABLE;
private String heldFor; // member id, only while RESERVED
BookItem(String barcode, String isbn, String rack, int acquiredOn, long priceMinor) {
this.barcode = barcode; this.isbn = isbn; this.rack = rack;
this.acquiredOn = acquiredOn; this.priceMinor = priceMinor;
}
ItemStatus status() { return status; }
String heldFor() { return heldFor; }
boolean isAvailable() { return status == ItemStatus.AVAILABLE; }
// the small state machine — a boolean cannot express five states
void loanOut() {
if (status != ItemStatus.AVAILABLE && status != ItemStatus.RESERVED)
throw new IllegalStateException(barcode + " is " + status);
status = ItemStatus.LOANED; heldFor = null;
}
void shelve() { status = ItemStatus.AVAILABLE; heldFor = null; }
void holdFor(String memberId) { status = ItemStatus.RESERVED; heldFor = memberId; }
void markLost() { status = ItemStatus.LOST; heldFor = null; }
void sendForRepair() { status = ItemStatus.IN_REPAIR; heldFor = null; }
}
/** The relationship IS an object. The closed ones are the library's memory. */
final class BookLending {
static final int LOAN_DAYS = 14;
final String barcode, memberId;
final int issuedOn, dueOn;
private Integer returnedOn;
private long fineMinor;
BookLending(String barcode, String memberId, int issuedOn) {
this.barcode = barcode; this.memberId = memberId;
this.issuedOn = issuedOn; this.dueOn = issuedOn + LOAN_DAYS;
}
boolean isOpen() { return returnedOn == null; }
long daysLate(int on) { return Math.max(0, on - dueOn); }
long fineMinor() { return fineMinor; }
void close(int on, long fine) { returnedOn = on; fineMinor = fine; }
}
final class Money {
static String of(long minor) { return "Rs." + (minor / 100) + "." + String.format("%02d", minor % 100); }
}
/* ---------------- the strategy seam: pricing lateness ---------------- */
interface FinePolicy {
long fineFor(long daysLate, long itemPriceMinor); // daysLate is already clamped at 0
String label();
}
final class FlatPerDayFine implements FinePolicy {
private final long perDayMinor;
FlatPerDayFine(long perDayMinor) { this.perDayMinor = perDayMinor; }
public long fineFor(long daysLate, long price) { return daysLate * perDayMinor; }
public String label() { return "flat " + Money.of(perDayMinor) + "/day"; }
}
final class SlabFine implements FinePolicy {
public long fineFor(long daysLate, long price) {
long gentle = Math.min(daysLate, 7);
return gentle * 200 + Math.max(0, daysLate - 7) * 1000;
}
public String label() { return "slabs 2.00 then 10.00"; }
}
final class CappedFine implements FinePolicy {
private final FinePolicy inner; private final long capMinor;
CappedFine(FinePolicy inner, long capMinor) { this.inner = inner; this.capMinor = capMinor; }
public long fineFor(long daysLate, long price) {
return Math.min(inner.fineFor(daysLate, price), Math.min(capMinor, price));
}
public String label() { return inner.label() + ", capped at " + Money.of(capMinor); }
}
/* ---------------- accounts: two roles, ONE level deep ---------------- */
abstract class Account {
final String id, name;
Account(String id, String name) { this.id = id; this.name = name; }
abstract String role();
}
final class BorrowRefused extends RuntimeException {
BorrowRefused(String why) { super(why); }
}
final class Member extends Account {
static final int MAX_LOANS = 5;
static final long MAX_FINE_MINOR = 10_000; // Rs.100.00
private final List<BookLending> lendings = new ArrayList<>();
private final int membershipExpiresOn;
private long fineOwedMinor;
Member(String id, String name, int membershipExpiresOn) {
super(id, name); this.membershipExpiresOn = membershipExpiresOn;
}
String role() { return "MEMBER"; }
long openLoans() { return lendings.stream().filter(BookLending::isOpen).count(); }
long lateReturns() { return lendings.stream().filter(l -> !l.isOpen() && l.fineMinor() > 0).count(); }
long fineOwedMinor() { return fineOwedMinor; }
List<BookLending> lendings() { return List.copyOf(lendings); }
/** THE RULE LIVES HERE. The service asks; it does not reach in and decide. */
void assertCanBorrow(int today) {
if (today > membershipExpiresOn)
throw new BorrowRefused(name + ": membership expired on day " + membershipExpiresOn);
if (openLoans() >= MAX_LOANS)
throw new BorrowRefused(name + ": loan limit reached (" + MAX_LOANS + " of " + MAX_LOANS + ")");
if (fineOwedMinor > MAX_FINE_MINOR)
throw new BorrowRefused(name + ": outstanding fine " + Money.of(fineOwedMinor));
}
boolean canBorrow(int today) {
try { assertCanBorrow(today); return true; } catch (BorrowRefused e) { return false; }
}
void addLending(BookLending l) { lendings.add(l); }
void addFine(long minor) { fineOwedMinor += minor; }
void payFine(long minor) { fineOwedMinor = Math.max(0, fineOwedMinor - minor); }
}
final class Librarian extends Account {
Librarian(String id, String name) { super(id, name); }
String role() { return "LIBRARIAN"; }
void addItem(Library library, BookItem item) { library.catalog().addItem(item); }
BookItem issueFor(Library library, String isbn, String memberId, int today) {
return library.issue(isbn, memberId, today);
}
}
/* ---------------- search: index maps, not a linear scan ---------------- */
final class Catalog {
private final Map<String, Book> byIsbn = new LinkedHashMap<>();
private final Map<String, List<Book>> byTitleWord = new HashMap<>();
private final Map<String, List<Book>> byAuthor = new HashMap<>();
private final Map<String, List<Book>> bySubject = new HashMap<>();
private final Map<String, List<BookItem>> itemsByIsbn = new LinkedHashMap<>();
private final Map<String, BookItem> byBarcode = new HashMap<>();
void addBook(Book b) {
byIsbn.put(b.isbn(), b);
for (String w : b.title().toLowerCase().split(" "))
byTitleWord.computeIfAbsent(w, k -> new ArrayList<>()).add(b);
byAuthor.computeIfAbsent(b.author().toLowerCase(), k -> new ArrayList<>()).add(b);
bySubject.computeIfAbsent(b.subject().toLowerCase(), k -> new ArrayList<>()).add(b);
itemsByIsbn.computeIfAbsent(b.isbn(), k -> new ArrayList<>());
}
void addItem(BookItem item) {
itemsByIsbn.computeIfAbsent(item.isbn, k -> new ArrayList<>()).add(item);
byBarcode.put(item.barcode, item);
}
Book book(String isbn) { return byIsbn.get(isbn); }
Collection<Book> allBooks() { return byIsbn.values(); }
List<BookItem> items(String isbn) { return itemsByIsbn.getOrDefault(isbn, List.of()); }
BookItem item(String barcode) {
BookItem i = byBarcode.get(barcode);
if (i == null) throw new IllegalArgumentException("unknown barcode " + barcode);
return i;
}
// SEARCH RETURNS BOOKS — titles. Availability is a COUNT over their items.
List<Book> searchByTitle(String word) { return byTitleWord.getOrDefault(word.toLowerCase(), List.of()); }
List<Book> searchByAuthor(String a) { return byAuthor.getOrDefault(a.toLowerCase(), List.of()); }
List<Book> searchBySubject(String s) { return bySubject.getOrDefault(s.toLowerCase(), List.of()); }
List<Book> searchByYear(int year) {
List<Book> out = new ArrayList<>();
for (Book b : byIsbn.values()) if (b.publishedYear() == year) out.add(b);
return out;
}
long availableCount(String isbn) { return items(isbn).stream().filter(BookItem::isAvailable).count(); }
// ISSUE RETURNS AN ITEM — a specific copy with a barcode on it.
Optional<BookItem> firstAvailable(String isbn) {
return items(isbn).stream().filter(BookItem::isAvailable).findFirst();
}
Optional<BookItem> heldFor(String isbn, String memberId) {
return items(isbn).stream()
.filter(i -> i.status() == ItemStatus.RESERVED && memberId.equals(i.heldFor()))
.findFirst();
}
}
/* ---------------- the facade that orchestrates, and decides nothing ---------------- */
final class Library {
private final Catalog catalog = new Catalog();
private final Map<String, Member> members = new LinkedHashMap<>();
private final Map<String, Deque<String>> reservations = new HashMap<>();
private final List<BookLending> lendings = new ArrayList<>();
private final List<BiConsumer<String, String>> listeners = new ArrayList<>();
private FinePolicy finePolicy;
Library(FinePolicy finePolicy) { this.finePolicy = finePolicy; }
Catalog catalog() { return catalog; }
FinePolicy finePolicy() { return finePolicy; }
void setFinePolicy(FinePolicy p) { finePolicy = p; } // returnItem() unchanged
void addMember(Member m) { members.put(m.id, m); }
void onNotify(BiConsumer<String, String> l) { listeners.add(l); } // observer, one line
BookItem issue(String isbn, String memberId, int today) {
Member m = member(memberId);
BookItem item = catalog.heldFor(isbn, memberId) // your held copy comes first
.or(() -> catalog.firstAvailable(isbn))
.orElseThrow(() -> new IllegalStateException("no copy of " + isbn + " is available"));
m.assertCanBorrow(today); // ASK — do not decide for it
if (item.status() == ItemStatus.RESERVED) dequeue(isbn, memberId);
item.loanOut();
BookLending lending = new BookLending(item.barcode, memberId, today);
lendings.add(lending);
m.addLending(lending);
return item;
}
/** Returns the fine charged, in minor units. */
long returnItem(String barcode, int on) {
BookItem item = catalog.item(barcode);
BookLending lending = openLendingFor(barcode);
long fine = finePolicy.fineFor(lending.daysLate(on), item.priceMinor);
lending.close(on, fine);
member(lending.memberId).addFine(fine);
Deque<String> queue = reservations.get(item.isbn); // THE BRANCH
if (queue != null && !queue.isEmpty()) {
String next = queue.peekFirst();
item.holdFor(next); // NOT back on the shelf
notifyMember(next, "copy " + barcode + " of " + item.isbn + " is waiting for you");
} else {
item.shelve();
}
return fine;
}
int reserve(String isbn, String memberId) {
if (catalog.availableCount(isbn) > 0)
throw new IllegalStateException("a copy is on the shelf — borrow it instead of reserving");
Deque<String> q = reservations.computeIfAbsent(isbn, k -> new ArrayDeque<>());
if (!q.contains(memberId)) q.addLast(memberId);
return new ArrayList<>(q).indexOf(memberId) + 1;
}
/** Allowed only when nobody is waiting for the title. */
BookLending renew(String barcode, int on) {
BookItem item = catalog.item(barcode);
Deque<String> q = reservations.get(item.isbn);
if (q != null && !q.isEmpty())
throw new IllegalStateException("cannot renew: " + q.size() + " reservation(s) waiting on " + item.isbn);
BookLending current = openLendingFor(barcode);
long fine = finePolicy.fineFor(current.daysLate(on), item.priceMinor);
current.close(on, fine);
Member m = member(current.memberId);
m.addFine(fine);
BookLending fresh = new BookLending(barcode, current.memberId, on);
lendings.add(fresh);
m.addLending(fresh);
return fresh;
}
int queueDepth(String isbn) { return reservations.getOrDefault(isbn, new ArrayDeque<>()).size(); }
Member member(String id) {
Member m = members.get(id);
if (m == null) throw new IllegalArgumentException("unknown member " + id);
return m;
}
Collection<Member> allMembers() { return members.values(); }
private void dequeue(String isbn, String memberId) {
Deque<String> q = reservations.get(isbn);
if (q != null) { q.remove(memberId); if (q.isEmpty()) reservations.remove(isbn); }
}
private BookLending openLendingFor(String barcode) {
for (BookLending l : lendings) if (l.isOpen() && l.barcode.equals(barcode)) return l;
throw new IllegalStateException(barcode + " is not on loan");
}
private void notifyMember(String memberId, String message) {
for (BiConsumer<String, String> l : listeners) l.accept(memberId, message);
}
}
public class Main {
static Library library;
public static void main(String[] args) {
library = new Library(new FlatPerDayFine(500)); // Rs.5.00 a day
library.onNotify((memberId, msg) -> System.out.println(" notify " + memberId + ": " + msg));
Catalog catalog = library.catalog();
catalog.addBook(new Book("978-0-13", "Clean Code", "Robert C. Martin", "software", 2008));
catalog.addBook(new Book("978-0-20", "The Pragmatic Programmer", "Andrew Hunt", "software", 1999));
catalog.addBook(new Book("978-0-21", "Design Patterns", "Erich Gamma", "software", 1994));
Librarian raj = new Librarian("L-1", "Raj");
raj.addItem(library, new BookItem("B-001", "978-0-13", "R-12", 0, 70000));
raj.addItem(library, new BookItem("B-002", "978-0-13", "R-12", 0, 70000));
raj.addItem(library, new BookItem("B-003", "978-0-13", "R-12", 0, 70000));
raj.addItem(library, new BookItem("P-001", "978-0-20", "R-07", 0, 60000));
raj.addItem(library, new BookItem("P-002", "978-0-20", "R-07", 0, 60000));
raj.addItem(library, new BookItem("D-001", "978-0-21", "R-03", 0, 90000));
raj.addItem(library, new BookItem("D-002", "978-0-21", "R-03", 0, 90000));
raj.addItem(library, new BookItem("D-003", "978-0-21", "R-03", 0, 90000));
library.addMember(new Member("M-1", "Asha", 400));
library.addMember(new Member("M-2", "Ravi", 400));
library.addMember(new Member("M-3", "Meera", 400));
library.addMember(new Member("M-4", "Dev", 400));
System.out.println("=== search: title word \"clean\" — search returns BOOKS ===");
for (Book b : catalog.searchByTitle("clean")) show(b);
System.out.println("\n=== day 1: three members borrow the same title ===");
borrow("978-0-13", "M-1", 1);
borrow("978-0-13", "M-2", 1);
borrow("978-0-13", "M-3", 1);
show(catalog.book("978-0-13"));
System.out.println("\n=== day 1: Dev wants it too ===");
borrow("978-0-13", "M-4", 1);
System.out.println(" reserve -> position " + library.reserve("978-0-13", "M-4") + " in the queue");
System.out.println("\n=== day 1: Ravi fills his card ===");
borrow("978-0-20", "M-2", 1);
borrow("978-0-20", "M-2", 1);
borrow("978-0-21", "M-2", 1);
borrow("978-0-21", "M-2", 1);
borrow("978-0-21", "M-2", 1); // the 6th — refused by Member, not by the service
System.out.println("\n=== day 10: Meera tries to renew B-003 ===");
try { library.renew("B-003", 10); }
catch (RuntimeException e) { System.out.println(" renew refused: " + e.getMessage()); }
System.out.println("\n=== day 25: Asha returns B-001, 10 days late ===");
long fine = library.returnItem("B-001", 25);
BookItem b001 = catalog.item("B-001");
System.out.println(" fine " + Money.of(fine) + " (" + library.finePolicy().label() + ")");
System.out.println(" B-001 -> " + b001.status() + ", held for " + b001.heldFor() + " <- NOT back on the shelf");
show(catalog.book("978-0-13"));
System.out.println("\n=== the same 10 days, priced by three policies ===");
for (FinePolicy p : List.of(new FlatPerDayFine(500), new SlabFine(),
new CappedFine(new FlatPerDayFine(500), 2000)))
System.out.printf(" %-34s %s%n", p.label(), Money.of(p.fineFor(10, 70000)));
System.out.println(" returnItem() was never touched — that is the strategy seam");
System.out.println("\n=== day 25: Dev collects the copy held for him ===");
borrow("978-0-13", "M-4", 25);
System.out.println(" queue depth for 978-0-13 is now " + library.queueDepth("978-0-13"));
System.out.println("\n=== members ===");
for (Member m : library.allMembers())
System.out.printf(" %-6s %-6s loans %d/%d fine %-10s late returns %d%n",
m.id, m.name, m.openLoans(), Member.MAX_LOANS, Money.of(m.fineOwedMinor()), m.lateReturns());
}
static void borrow(String isbn, String memberId, int day) {
try {
BookItem item = library.issue(isbn, memberId, day);
System.out.println(" " + memberId + " -> " + item.barcode + " (due day " + (day + BookLending.LOAN_DAYS) + ")");
} catch (RuntimeException e) {
System.out.println(" refused: " + e.getMessage());
}
}
static void show(Book b) {
Catalog c = library.catalog();
System.out.printf(" %-26s %-18s %d of %d available%n",
b.title(), b.author(), c.availableCount(b.isbn()), c.items(b.isbn()).size());
}
}
/* ------------------------- expected output -------------------------
=== search: title word "clean" — search returns BOOKS ===
Clean Code Robert C. Martin 3 of 3 available
=== day 1: three members borrow the same title ===
M-1 -> B-001 (due day 15)
M-2 -> B-002 (due day 15)
M-3 -> B-003 (due day 15)
Clean Code Robert C. Martin 0 of 3 available
=== day 1: Dev wants it too ===
refused: no copy of 978-0-13 is available
reserve -> position 1 in the queue
=== day 1: Ravi fills his card ===
M-2 -> P-001 (due day 15)
M-2 -> P-002 (due day 15)
M-2 -> D-001 (due day 15)
M-2 -> D-002 (due day 15)
refused: Ravi: loan limit reached (5 of 5)
=== day 10: Meera tries to renew B-003 ===
renew refused: cannot renew: 1 reservation(s) waiting on 978-0-13
=== day 25: Asha returns B-001, 10 days late ===
notify M-4: copy B-001 of 978-0-13 is waiting for you
fine Rs.50.00 (flat Rs.5.00/day)
B-001 -> RESERVED, held for M-4 <- NOT back on the shelf
Clean Code Robert C. Martin 0 of 3 available
=== the same 10 days, priced by three policies ===
flat Rs.5.00/day Rs.50.00
slabs 2.00 then 10.00 Rs.44.00
flat Rs.5.00/day, capped at Rs.20.00 Rs.20.00
returnItem() was never touched — that is the strategy seam
=== day 25: Dev collects the copy held for him ===
M-4 -> B-001 (due day 39)
queue depth for 978-0-13 is now 0
=== members ===
M-1 Asha loans 0/5 fine Rs.50.00 late returns 1
M-2 Ravi loans 5/5 fine Rs.0.00 late returns 0
M-3 Meera loans 1/5 fine Rs.0.00 late returns 0
M-4 Dev loans 1/5 fine Rs.0.00 late returns 0
------------------------------------------------------------------- */References & further reading
7 sources- Articlegithub.com
awesome-low-level-design — Library Management System
The problem written up as interviewers usually pose it. Compare its class list with yours — especially where it puts the borrow rules.
- Specen.wikipedia.org
FRBR — Functional Requirements for Bibliographic Records
Library science solved this exact split long ago: work, expression, manifestation, item. Your Book / BookItem pair is the last two, and knowing the real vocabulary is a nice thing to drop into the round.
- Articlemartinfowler.com
Martin Fowler — AnemicDomainModel
The precise name for the LibraryService-decides-everything failure, and why a model of pure getters is not really object-oriented.
- Articlemartinfowler.com
Martin Fowler — TellDontAsk
Two pages that justify member.canBorrow(today) better than any argument you can make on the spot.
- Articlemartinfowler.com
Martin Fowler — Money pattern
Why fines are integer minor units with a currency, and never a double. One paragraph, and it saves a point every time money appears in a round.
- Book
Domain-Driven Design — Eric Evans
Chapter 5 is entities versus value objects, which is exactly the question of whether a copy has an identity separate from its description. The rest of the chapter is the association-class argument.
- Docsrefactoring.guru
Refactoring Guru — Strategy
The pattern behind FinePolicy, with the swap-at-runtime version you want for the “what if fines change?” follow-up.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 08
A library owns five copies of the same title. Why is a single Book class with an isAvailable boolean wrong?
question 02 / 08
Why model borrowing as a BookLending object rather than borrowedBy and dueDate fields on BookItem?
question 03 / 08
Where should the “a member may hold at most five books” rule live?
question 04 / 08
A copy is returned and three members are queued on that title. What happens to the copy?
question 05 / 08
Why should returnItem(barcode, on) take the date as an argument instead of reading the clock inside?
question 06 / 08
Fines might be flat per day, slab-based, or capped. What is the right structure?
question 07 / 08
Why is ItemStatus an enum with five values rather than an isAvailable boolean?
question 08 / 08
The interviewer asks how you would add e-books. What is the strongest answer?
0/8 answered