Skip to content

Low Level DesignWorked problems

Design a library management system

Design a library management system step by step for a low level design interview. Grow from books and copies to lending rules in a policy, fines settled at return, a hold queue that hands each returned copy to the next reader, and a desk that kiosks can share, with code in Java, Python, C#, Go and TypeScript.

Foundation30 min core · 45 min full depth

“Design a library management system.” Members borrow books, return them late, and wait for the popular ones.

A library looks like a simple records exercise: books, members, loans, and a screen for each. Most answers draw a Library class with twenty methods and stop there.

The real design questions hide in three places. A book in the catalog isn’t the thing on the shelf. The rules (how many books, for how long, what lateness costs) change more often than anything else. And a returned book has somewhere to go besides the shelf, when someone is waiting for it.

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. A book is a title; a copy is the physical item. People search for and wait for a book. The desk lends a copy, which has a barcode. Whether a copy is out is simply whether a loan exists for it.
  2. The rules live in a policy the desk asks. “Students borrow 5 books for 14 days” is a question to a LendingPolicy, so changing a rule changes one class.
  3. A fine is worked out once, at return. It comes from two dates: when the book was due and when it came back. Fines are kept as records, never as a running total that can drift.
  4. Holds are a queue per book. When a copy comes back, it’s set aside for the first person waiting. Every way a copy can become free goes through one method, route, so no returned copy slips onto the open shelf while someone waits.
  5. One lock around the desk. Kiosks and the website use the desk at once, and a library is quiet enough that one lock fixes every race without anyone noticing a wait.

We’ll build this in four stages, then deal with several kiosks at once.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design a library management system.

What they are testing

Whether you pin down the lending rules and holds in about three minutes, and ask whether there are several copies of a book. Listing screens (search page, member page, admin page) is designing a user interface, not the objects.

Here are the questions that matter:

  • Several copies of one book? Yes, each with its own barcode. With one copy per title, book and copy could be one class.
  • Do the rules differ by member? Yes. Students borrow 5 books for 14 days, faculty 20 for 90. Even one rule for everyone belongs in a policy; it just has one case.
  • Fines? A fixed amount per day late, up to a cap. Owing too much blocks new loans.
  • Holds? Yes. A member can wait for a title, and has 3 days to collect it once it’s ready. Without holds, a return just goes back to the shelf.
  • One branch or many? One. Several branches add moving books between them, an extension.

These answers give us one promise that matters more than the others: a returned copy never sits on the open shelf while someone is waiting for its title. It’s easy to break by adding a second way for copies to become free. Three smaller promises come with it. A copy set aside for one member can’t be borrowed by anyone else. Changing a rule changes one class. And several kiosks can work at once without breaking any rule.

We leave out buying new books, the screens, and multiple branches.

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
Renewals? Twice, unless someone is waiting Unlimited renewals let one reader keep a popular book for a year
Lost books? An extension: charge a replacement price A daily job declares long-overdue copies lost
E-books? Out of scope Licenses with a copy count behave like copies with no barcode

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).

Interviewerminute 4

Start simple. A member borrows a book and brings it back. What are your classes?

What they are testing

Whether you split the book from the copy in the first two minutes. A Book class with an isAvailable flag is the answer they hear most, and it can’t describe a library with three copies of Dune.

The first decision is: what exactly is lent? A reader searches for “Dune” and doesn’t care which copy they get. But the desk scans a barcode, and must know exactly which item left the building.

So there are two classes. Book is a title: ISBN, title, author. Copy is one physical item with a barcode, pointing at its book. A Loan records which copy, which member, and when it’s due.

Is a copy out? That’s not a flag on the copy. It’s whether an active loan exists for that barcode. So there’s one source of truth, and no flag to forget to reset.

Stage 1: books, copies, and loans

Library

-copies: Map<barcode,Copy>

-loans: Map<barcode,Loan>

+addCopy(Copy)

+search(text) : List<Book>

+checkout(Member, barcode, today) : bool

+returnCopy(barcode)

«value»

Book

+isbn

+title

+author

Copy

+barcode

+book: Book

Loan

+copy: Copy

+member: Member

+borrowedOn

+dueOn

Member

+id

+name

Library

-copies: Map<barcode,Copy>

-loans: Map<barcode,Loan>

+addCopy(Copy)

+search(text) : List<Book>

+checkout(Member, barcode, today) : bool

+returnCopy(barcode)

«value»

Book

+isbn

+title

+author

Copy

+barcode

+book: Book

Loan

+copy: Copy

+member: Member

+borrowedOn

+dueOn

Member

+id

+name

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Library.javalibrary.pyLibrary.cslibrary.golibrary.ts
// A title: what a reader searches for. The library may own many copies of one book.
record Book(String isbn, String title, String author) {}
// One physical copy, known by the barcode inside its cover. Loans are of copies; searching
// is over books. Keeping the two apart is the decision the rest of the design leans on.
record Copy(String barcode, Book book) {}
record Member(String id, String name) {}
// One borrowing: which copy, who has it, and until when. A class rather than a record
// because renewing changes the due date later on.
class Loan {
final Copy copy;
final Member member;
final LocalDate borrowedOn;
LocalDate dueOn;
Loan(Copy copy, Member member, LocalDate borrowedOn, LocalDate dueOn) {
this.copy = copy;
this.member = member;
this.borrowedOn = borrowedOn;
this.dueOn = dueOn;
}
}
class Library {
private final Map<String, Copy> copies = new HashMap<>(); // by barcode
private final Map<String, Loan> loans = new HashMap<>(); // active loans, by barcode
void addCopy(Copy c) { copies.put(c.barcode(), c); }
// Search returns books, so three copies of one title show up once.
List<Book> search(String text) {
String q = text.toLowerCase();
return copies.values().stream().map(Copy::book).distinct()
.filter(b -> b.title().toLowerCase().contains(q) || b.author().toLowerCase().contains(q))
.toList();
}
// flaw 1: no rules. Anyone can borrow any number of books for a fixed 14 days, with a pile
// of overdue ones at home. Stage 2 moves the rules into a policy.
boolean checkout(Member m, String barcode, LocalDate today) {
Copy c = copies.get(barcode);
if (c == null || loans.containsKey(barcode)) return false;
loans.put(barcode, new Loan(c, m, today, today.plusDays(14)));
return true;
}
// flaw 2: returning late costs nothing, and the copy goes back on the shelf even when
// someone has been waiting for it. Stages 3 and 4 add fines and holds.
// flaw 3: each of those would land in this class too, until it does everything. Stage 2
// starts splitting it by what each part owns.
void returnCopy(String barcode) { loans.remove(barcode); }
}

It works, but it has three problems, and we leave them in on purpose. There are no rules: anyone can take fifty books for a fixed 14 days. Lateness costs nothing, and a returned copy goes back on the shelf even when someone is waiting for it. And every one of those features would land in Library, which is already doing search and lending.

Interviewerminute 8

Students can borrow 5 books for two weeks, faculty 20 for three months. Nobody with an overdue book can borrow more. Where do those rules go?

What they are testing

Whether the numbers go behind an interface the desk asks, rather than an if on the member type inside checkout, and whether you split Library now, before it grows. StudentMember and FacultyMember subclasses overriding maxBooks() is the answer they expect to push on.

Two changes. First, Library splits by what each part owns. Catalog owns what the library has: books, copies and search. LoanDesk owns who has what: the active loans, and a list of loans per member, so “what does Ana have out?” is a quick lookup, not a search through every loan.

Second, the rules move into a LendingPolicy that the desk asks: maxLoans(member) and loanDays(member). A member carries a type (student or faculty), and the policy turns the type into numbers. A new member type, or a library that lends for 21 days, is a change to the policy and nowhere else. This is the Strategy pattern: the desk depends on an interface, and a library picks the rules behind it.

A refusal comes back as a named answer, like LIMIT_REACHED, not an error. A member at their limit is normal at a library desk, and the kiosk needs to show a reason.

Stage 2: catalog, desk, and policy

returns

LoanDesk

-loans: Map<barcode,Loan>

-byMember: Map<id,Set>

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+loansOf(Member) : List<Loan>

Catalog

-copies: Map<barcode,Copy>

+add(Copy)

+copy(barcode) : Copy

+copiesOf(isbn) : List<Copy>

+search(text) : List<Book>

«interface»

LendingPolicy

+maxLoans(Member) : int

+loanDays(Member) : int

StandardPolicy

Member

+id

+name

+type: MemberType

«enum»

MemberType

STUDENT

FACULTY

«enum»

Result

OK

NO_SUCH_COPY

NOT_AVAILABLE

LIMIT_REACHED

HAS_OVERDUE

returns

LoanDesk

-loans: Map<barcode,Loan>

-byMember: Map<id,Set>

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+loansOf(Member) : List<Loan>

Catalog

-copies: Map<barcode,Copy>

+add(Copy)

+copy(barcode) : Copy

+copiesOf(isbn) : List<Copy>

+search(text) : List<Book>

«interface»

LendingPolicy

+maxLoans(Member) : int

+loanDays(Member) : int

StandardPolicy

Member

+id

+name

+type: MemberType

«enum»

MemberType

STUDENT

FACULTY

«enum»

Result

OK

NO_SUCH_COPY

NOT_AVAILABLE

LIMIT_REACHED

HAS_OVERDUE

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
LoanDesk.javaloan_desk.pyLoanDesk.csloan_desk.goloan-desk.ts
// Different members get different limits. The policy reads this; nothing else does.
enum MemberType { STUDENT, FACULTY }
// A library card holder.
record Member(String id, String name, MemberType type) {}
// What the library owns, and nothing about who has it. Searches never touch loans.
class Catalog {
private final Map<String, Copy> copies = new LinkedHashMap<>(); // by barcode
void add(Copy c) { copies.put(c.barcode(), c); }
Copy copy(String barcode) { return copies.get(barcode); }
List<Copy> copiesOf(String isbn) {
return copies.values().stream().filter(c -> c.book().isbn().equals(isbn)).toList();
}
List<Book> search(String text) {
String q = text.toLowerCase();
return copies.values().stream().map(Copy::book).distinct()
.filter(b -> b.title().toLowerCase().contains(q) || b.author().toLowerCase().contains(q))
.toList();
}
}
// The numbers a library sets and changes: how many books, for how long. The desk asks the
// policy and never hard-codes one, so a new member type is a change here and nowhere else.
interface LendingPolicy {
int maxLoans(Member m);
int loanDays(Member m);
}
class StandardPolicy implements LendingPolicy {
public int maxLoans(Member m) { return m.type() == MemberType.FACULTY ? 20 : 5; }
public int loanDays(Member m) { return m.type() == MemberType.FACULTY ? 90 : 14; }
}
// Why a request was refused. Refusals are normal at a library desk, so they are values the
// kiosk shows, not exceptions.
enum Result { OK, NO_SUCH_COPY, NOT_AVAILABLE, NOT_ON_LOAN, LIMIT_REACHED, HAS_OVERDUE }
// Who has what. The desk owns loans and applies the policy; the catalog owns the books.
class LoanDesk {
private final Catalog catalog;
private final LendingPolicy policy;
private final Supplier<LocalDate> clock; // injected, so tests can move time forward
private final Map<String, Loan> loans = new HashMap<>(); // active, by barcode
private final Map<String, Set<String>> byMember = new HashMap<>(); // member id -> barcodes
LoanDesk(Catalog catalog, LendingPolicy policy, Supplier<LocalDate> clock) {
this.catalog = catalog;
this.policy = policy;
this.clock = clock;
}
Result checkout(Member m, String barcode) {
Copy c = catalog.copy(barcode);
if (c == null) return Result.NO_SUCH_COPY;
if (loans.containsKey(barcode)) return Result.NOT_AVAILABLE;
LocalDate today = clock.get();
List<Loan> mine = loansOf(m);
if (mine.size() >= policy.maxLoans(m)) return Result.LIMIT_REACHED;
// An overdue book at home blocks new loans: the usual nudge to bring it back.
if (mine.stream().anyMatch(l -> l.dueOn.isBefore(today))) return Result.HAS_OVERDUE;
loans.put(barcode, new Loan(c, m, today, today.plusDays(policy.loanDays(m))));
byMember.computeIfAbsent(m.id(), k -> new HashSet<>()).add(barcode);
return Result.OK;
}
Result returnCopy(String barcode) {
Loan l = loans.remove(barcode);
if (l == null) return Result.NOT_ON_LOAN;
byMember.get(l.member.id()).remove(barcode);
return Result.OK;
}
// The member index makes "what do I have out?" a lookup instead of a scan of every loan.
List<Loan> loansOf(Member m) {
return byMember.getOrDefault(m.id(), Set.of()).stream().map(loans::get).toList();
}
}

The desk is given a clock (a function returning today’s date), instead of reading the computer’s time. That’s what lets a test borrow a book, move forward fifteen days, and check that it’s overdue.

Go deeperWhen a policy class stops being enough1 min

One more interface, and a method per rule. If rules start depending on many things at once (term dates, the book’s collection, the member’s history), a table of rules works better than a class of methods.

A rule table looks like this: each row says “for members of this type, borrowing books from this collection, during this part of the year: this many books, for this many days, at this fine”. The policy finds the first matching row. Librarians can then change rules without a code release, which is usually the real reason libraries ask for it.

Go deeperWhy not StudentMember and FacultyMember subclasses?1 min

The member type is data that can change, and the rules belong to the library, not the member. A student who becomes faculty would have to be replaced by a different object, and every loan pointing at the old one would need updating. With a policy, the member carries a type, and the library decides what the type means.

Interviewerminute 13

Late returns cost 25 cents a day, up to 20 dollars. Anyone owing more than 5 dollars can’t borrow. How do fines work?

What they are testing

Whether you work out the fine once, at return, from the due date and the return date, and keep fines as records. A nightly job that adds 25 cents to every late member’s balance is the answer they hear most. Ask yourself what happens the night it doesn’t run.

A fine depends on just two dates: when the copy was due, and when it came back. So the desk works it out once, at return, by asking the policy fineFor(loan, daysLate), and writes a Fine record. Nothing runs every night, nothing adds up over time, and there’s no total that can drift away from the loans it came from.

Fines live in a FineLedger, separate from the loans, because money has its own rules. A member can see each fine and what it was for. A librarian can cancel one. Payments come in at the counter. And the ledger never accepts more than is owed, so an overpayment isn’t silently kept.

What about a book that’s still out and very late? It has no fine yet. But it already blocks new loans, because the member has an overdue book, and that’s the pressure that matters.

Stage 3: fines in their own ledger

LoanDesk

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+owed(Member) : long

+pay(Member, cents) : long

FineLedger

-fines: Map<id,List>

-paid: Map<id,long>

+add(Fine)

+owed(memberId) : long

+pay(memberId, cents) : long

«value»

Fine

+memberId

+barcode

+cents

+assessedOn

«interface»

LendingPolicy

+maxLoans(Member) : int

+loanDays(Member) : int

+fineFor(Loan, daysLate) : long

+maxOwed() : long

Catalog

LoanDesk

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+owed(Member) : long

+pay(Member, cents) : long

FineLedger

-fines: Map<id,List>

-paid: Map<id,long>

+add(Fine)

+owed(memberId) : long

+pay(memberId, cents) : long

«value»

Fine

+memberId

+barcode

+cents

+assessedOn

«interface»

LendingPolicy

+maxLoans(Member) : int

+loanDays(Member) : int

+fineFor(Loan, daysLate) : long

+maxOwed() : long

Catalog

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
LoanDesk.javaloan_desk.pyLoanDesk.csloan_desk.goloan-desk.ts
// A charge, kept as its own record instead of folded into a balance, so a member can see what
// each one was for and a librarian can waive one.
record Fine(String memberId, String barcode, long cents, LocalDate assessedOn) {}
// Who owes what. The desk adds fines on return; payments arrive at the counter.
class FineLedger {
private final Map<String, List<Fine>> fines = new HashMap<>();
private final Map<String, Long> paid = new HashMap<>();
void add(Fine f) { fines.computeIfAbsent(f.memberId(), k -> new ArrayList<>()).add(f); }
long owed(String memberId) {
long charged = fines.getOrDefault(memberId, List.of()).stream().mapToLong(Fine::cents).sum();
return charged - paid.getOrDefault(memberId, 0L);
}
// Takes at most what is owed, and returns what it took, so an overpayment is never kept.
long pay(String memberId, long cents) {
long taken = Math.min(cents, owed(memberId));
if (taken > 0) paid.merge(memberId, taken, Long::sum);
return taken;
}
}
interface LendingPolicy {
int maxLoans(Member m);
int loanDays(Member m);
long fineFor(Loan loan, long daysLate); // in cents
long maxOwed(); // owing more than this blocks new loans
}
class StandardPolicy implements LendingPolicy {
public int maxLoans(Member m) { return m.type() == MemberType.FACULTY ? 20 : 5; }
public int loanDays(Member m) { return m.type() == MemberType.FACULTY ? 90 : 14; }
// 25 cents a day, capped at 20.00 so a forgotten book never costs more than a new one.
public long fineFor(Loan loan, long daysLate) { return Math.min(daysLate * 25, 2000); }
public long maxOwed() { return 500; }
}
enum Result { OK, NO_SUCH_COPY, NOT_AVAILABLE, NOT_ON_LOAN, LIMIT_REACHED, HAS_OVERDUE, FINES_OWED }
class LoanDesk {
private final Catalog catalog;
private final LendingPolicy policy;
private final Supplier<LocalDate> clock;
private final FineLedger fines = new FineLedger();
private final Map<String, Loan> loans = new HashMap<>();
private final Map<String, Set<String>> byMember = new HashMap<>();
LoanDesk(Catalog catalog, LendingPolicy policy, Supplier<LocalDate> clock) {
this.catalog = catalog;
this.policy = policy;
this.clock = clock;
}
Result checkout(Member m, String barcode) {
Copy c = catalog.copy(barcode);
if (c == null) return Result.NO_SUCH_COPY;
if (loans.containsKey(barcode)) return Result.NOT_AVAILABLE;
LocalDate today = clock.get();
List<Loan> mine = loansOf(m);
if (mine.size() >= policy.maxLoans(m)) return Result.LIMIT_REACHED;
if (mine.stream().anyMatch(l -> l.dueOn.isBefore(today))) return Result.HAS_OVERDUE;
if (fines.owed(m.id()) > policy.maxOwed()) return Result.FINES_OWED;
loans.put(barcode, new Loan(c, m, today, today.plusDays(policy.loanDays(m))));
byMember.computeIfAbsent(m.id(), k -> new HashSet<>()).add(barcode);
return Result.OK;
}
Result returnCopy(String barcode) {
Loan l = loans.remove(barcode);
if (l == null) return Result.NOT_ON_LOAN;
byMember.get(l.member.id()).remove(barcode);
LocalDate today = clock.get();
// The fine is settled once, at return, from two dates. No nightly job adds a charge
// per day, so there's nothing to get wrong if the job misses a night.
long daysLate = ChronoUnit.DAYS.between(l.dueOn, today);
if (daysLate > 0) {
fines.add(new Fine(l.member.id(), barcode, policy.fineFor(l, daysLate), today));
}
return Result.OK;
}
long owed(Member m) { return fines.owed(m.id()); }
long pay(Member m, long cents) { return fines.pay(m.id(), cents); }
List<Loan> loansOf(Member m) {
return byMember.getOrDefault(m.id(), Set.of()).stream().map(loans::get).toList();
}
}

Days late are counted by calendar date, not by 24-hour periods. So a book due today and returned tonight is on time. Money is stored in whole cents, never as decimals.

Interviewerminute 18

Both copies of Dune are out and Cy wants it. What happens when one comes back?

What they are testing

Whether the hold is on the book and the pickup is on a copy, whether every way a copy becomes free checks the queue, and whether a set-aside copy expires. A reservedBy field on Book is the answer they expect to correct: it allows only one waiting reader, and can’t say which copy is set aside.

A hold is on a book: Cy wants whichever copy of Dune comes back first. So there’s a queue of holds for each book, first come, first served.

When a copy comes back, it’s set aside on the hold shelf for the first member in the queue. From that moment, it’s a specific copy, so the hold shelf is organized by barcode. A Pickup says who the copy is for, and until when.

Three rules follow:

  • Only the holder can borrow a set-aside copy. Checkout looks at the hold shelf first.
  • Every copy that becomes free goes through one method, route. A return calls it. So does the daily job that expires pickups nobody collected. route gives the copy to the next member waiting, or puts it on the open shelf if nobody is. Having exactly one such method is what keeps the main promise. A new way for copies to become free (back from repair, a new copy bought) calls route too.
  • A hold is refused when a copy is on the open shelf. The reader should just go and take it. Otherwise the hold would sit in the queue while the copy sits on the shelf.

Renewing extends a loan from today, at most twice, and never while someone is waiting for the title. Someone in the queue comes before another two weeks for the current reader.

The desk tells the member through a Notifier interface. It says what happened (“your copy of Dune is ready until Thursday”), and leaves email, text or app message to whatever is behind it. This is a light version of the Observer pattern.

Stage 4: holds, pickups, and the notifier

LoanDesk

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+placeHold(Member, isbn) : Result

+renew(Member, barcode) : Result

+expireHolds() : int

-route(Copy, today)

Holds

-waiting: Map<isbn,Queue>

-shelf: Map<barcode,Pickup>

+add(Hold)

+next(isbn) : Hold

+setAside(barcode, Pickup)

+pickupFor(barcode) : Pickup

+expired(today) : List

«value»

Hold

+member

+isbn

+placedOn

«value»

Pickup

+member

+until

«interface»

Notifier

+holdReady(Member, Book, until)

«interface»

LendingPolicy

+maxRenewals() : int

+holdDays() : int

Loan

+dueOn

+renewals

LoanDesk

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+placeHold(Member, isbn) : Result

+renew(Member, barcode) : Result

+expireHolds() : int

-route(Copy, today)

Holds

-waiting: Map<isbn,Queue>

-shelf: Map<barcode,Pickup>

+add(Hold)

+next(isbn) : Hold

+setAside(barcode, Pickup)

+pickupFor(barcode) : Pickup

+expired(today) : List

«value»

Hold

+member

+isbn

+placedOn

«value»

Pickup

+member

+until

«interface»

Notifier

+holdReady(Member, Book, until)

«interface»

LendingPolicy

+maxRenewals() : int

+holdDays() : int

Loan

+dueOn

+renewals

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
LoanDesk.javaloan_desk.pyLoanDesk.csloan_desk.goloan-desk.ts
// One borrowing: which copy, who has it, and until when. A class rather than a record because
// renewing changes the due date later on.
class Loan {
final Copy copy;
final Member member;
final LocalDate borrowedOn;
LocalDate dueOn;
int renewals; // how many times the due date has moved; the policy caps it
Loan(Copy copy, Member member, LocalDate borrowedOn, LocalDate dueOn) {
this.copy = copy;
this.member = member;
this.borrowedOn = borrowedOn;
this.dueOn = dueOn;
}
}
// A member waiting for a title. Holds are on books, not copies: the reader wants whichever
// copy comes back first.
record Hold(Member member, String isbn, LocalDate placedOn) {}
// A copy set aside on the hold shelf for one member until a date. Once set aside it's this
// copy, so the shelf is keyed by barcode while the queue is keyed by ISBN.
record Pickup(Member member, LocalDate until) {}
// The waiting lists and the hold shelf, kept apart from loans so each stays simple.
class Holds {
private final Map<String, Deque<Hold>> waiting = new HashMap<>(); // isbn -> first come, first served
private final Map<String, Pickup> shelf = new HashMap<>(); // barcode -> who it's for
void add(Hold h) { waiting.computeIfAbsent(h.isbn(), k -> new ArrayDeque<>()).addLast(h); }
boolean anyWaiting(String isbn) { return !waiting.getOrDefault(isbn, new ArrayDeque<>()).isEmpty(); }
boolean isWaiting(Member m, String isbn) {
return waiting.getOrDefault(isbn, new ArrayDeque<>()).stream()
.anyMatch(h -> h.member().id().equals(m.id()));
}
// Removes and returns the first member waiting for this title, or null.
Hold next(String isbn) {
Deque<Hold> q = waiting.get(isbn);
return q == null ? null : q.pollFirst();
}
void setAside(String barcode, Pickup p) { shelf.put(barcode, p); }
Pickup pickupFor(String barcode) { return shelf.get(barcode); }
void clear(String barcode) { shelf.remove(barcode); }
// A pickup is good through its last day, so it expires once today is after it.
List<String> expired(LocalDate today) {
return shelf.entrySet().stream().filter(e -> e.getValue().until().isBefore(today))
.map(Map.Entry::getKey).sorted().toList();
}
}
// Email, text or app push. The desk says what happened; how the member hears is not its job.
interface Notifier {
void holdReady(Member m, Book b, LocalDate until);
}
// The numbers a library sets and changes: how many books, for how long, what lateness costs.
// The desk asks the policy and never hard-codes one, so a new member type or a new fine rule is
// a change here and nowhere else.
interface LendingPolicy {
int maxLoans(Member m);
int loanDays(Member m);
long fineFor(Loan loan, long daysLate);
long maxOwed();
int maxRenewals();
int holdDays(); // how long a set-aside copy waits to be collected
}
// One library's numbers. Faculty borrow more for longer. A fine is 25 cents a day, capped at
// $20 a book, and more than $5 owed stops new loans. Money is in cents.
class StandardPolicy implements LendingPolicy {
public int maxLoans(Member m) { return m.type() == MemberType.FACULTY ? 20 : 5; }
public int loanDays(Member m) { return m.type() == MemberType.FACULTY ? 90 : 14; }
public long fineFor(Loan loan, long daysLate) { return Math.min(daysLate * 25, 2000); }
public long maxOwed() { return 500; }
public int maxRenewals() { return 2; }
public int holdDays() { return 3; }
}
// What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader
// what to do next.
enum Result {
OK, NO_SUCH_COPY, NOT_AVAILABLE, NOT_ON_LOAN, LIMIT_REACHED, HAS_OVERDUE, FINES_OWED,
HELD_FOR_SOMEONE_ELSE, COPY_AVAILABLE, ALREADY_WAITING, NOT_YOUR_LOAN, RENEWAL_LIMIT, HOLD_WAITING
}
// Where every loan, return, hold and renewal happens. It owns who has what; the catalog owns
// what exists, and the policy owns the numbers.
class LoanDesk {
private final Catalog catalog;
private final LendingPolicy policy;
private final Notifier notifier;
private final Supplier<LocalDate> clock;
private final FineLedger fines = new FineLedger();
private final Holds holds = new Holds();
private final Map<String, Loan> loans = new HashMap<>(); // barcode -> its loan
private final Map<String, Set<String>> byMember = new HashMap<>(); // member id -> barcodes, for a fast "what do I have?"
LoanDesk(Catalog catalog, LendingPolicy policy, Notifier notifier, Supplier<LocalDate> clock) {
this.catalog = catalog;
this.policy = policy;
this.notifier = notifier;
this.clock = clock;
}
// Every reason to refuse is checked before anything changes, so a refused checkout leaves
// no trace.
Result checkout(Member m, String barcode) {
Copy c = catalog.copy(barcode);
if (c == null) return Result.NO_SUCH_COPY;
if (loans.containsKey(barcode)) return Result.NOT_AVAILABLE;
// A copy on the hold shelf can only go to the member it was set aside for.
Pickup p = holds.pickupFor(barcode);
if (p != null && !p.member().id().equals(m.id())) return Result.HELD_FOR_SOMEONE_ELSE;
LocalDate today = clock.get();
List<Loan> mine = loansOf(m);
if (mine.size() >= policy.maxLoans(m)) return Result.LIMIT_REACHED;
if (mine.stream().anyMatch(l -> l.dueOn.isBefore(today))) return Result.HAS_OVERDUE;
if (fines.owed(m.id()) > policy.maxOwed()) return Result.FINES_OWED;
if (p != null) holds.clear(barcode);
loans.put(barcode, new Loan(c, m, today, today.plusDays(policy.loanDays(m))));
byMember.computeIfAbsent(m.id(), k -> new HashSet<>()).add(barcode);
return Result.OK;
}
// A late return adds a fine. Then the copy goes to the next reader waiting, if there is one.
Result returnCopy(String barcode) {
Loan l = loans.remove(barcode);
if (l == null) return Result.NOT_ON_LOAN;
byMember.get(l.member.id()).remove(barcode);
LocalDate today = clock.get();
long daysLate = ChronoUnit.DAYS.between(l.dueOn, today);
if (daysLate > 0) {
fines.add(new Fine(l.member.id(), barcode, policy.fineFor(l, daysLate), today));
}
route(l.copy, today);
return Result.OK;
}
Result placeHold(Member m, String isbn) {
if (holds.isWaiting(m, isbn)) return Result.ALREADY_WAITING;
// With a copy on the open shelf the reader should take it. A hold now would sit in
// the queue while that copy sits on the shelf.
boolean onShelf = catalog.copiesOf(isbn).stream()
.anyMatch(c -> !loans.containsKey(c.barcode()) && holds.pickupFor(c.barcode()) == null);
if (onShelf) return Result.COPY_AVAILABLE;
holds.add(new Hold(m, isbn, clock.get()));
return Result.OK;
}
Result renew(Member m, String barcode) {
Loan l = loans.get(barcode);
if (l == null || !l.member.id().equals(m.id())) return Result.NOT_YOUR_LOAN;
LocalDate today = clock.get();
if (l.dueOn.isBefore(today)) return Result.HAS_OVERDUE;
if (l.renewals >= policy.maxRenewals()) return Result.RENEWAL_LIMIT;
// Someone waiting for this title outranks a second turn for the current reader.
if (holds.anyWaiting(l.copy.book().isbn())) return Result.HOLD_WAITING;
l.dueOn = today.plusDays(policy.loanDays(m));
l.renewals++;
return Result.OK;
}
// Run once a day. A copy nobody collected moves on to the next person waiting, or back to
// the open shelf. Returns how many pickups expired.
int expireHolds() {
LocalDate today = clock.get();
List<String> gone = holds.expired(today);
for (String barcode : gone) {
holds.clear(barcode);
route(catalog.copy(barcode), today);
}
return gone.size();
}
// Every copy that comes free goes through here: to the first member waiting for its
// title, or back to the open shelf when nobody is.
private void route(Copy c, LocalDate today) {
Hold next = holds.next(c.book().isbn());
if (next == null) return;
LocalDate until = today.plusDays(policy.holdDays());
holds.setAside(c.barcode(), new Pickup(next.member(), until));
notifier.holdReady(next.member(), c.book(), until);
}
long owed(Member m) { return fines.owed(m.id()); }
long pay(Member m, long cents) { return fines.pay(m.id(), cents); }
List<Loan> loansOf(Member m) {
return byMember.getOrDefault(m.id(), Set.of()).stream().map(loans::get).toList();
}
}

What does one route method cost? It must stay private to the desk, and every new path that frees a copy has to remember to call it. A code reviewer has to catch the one that forgets. The test of the expiry path is the example to copy.

Go deeperA copy's whole life, in one picture1 min

A copy is always in exactly one of three places: the open shelf, out on loan, or the hold shelf. Each arrow is one desk operation. Notice that every arrow leaving “on loan” goes through route, and so does the arrow for an expired pickup. That’s the main promise, drawn.

A copy's life: open shelf, loan, hold shelf

checkout

return, nobody waiting

return, someone waiting

checkout by the holder

expired, next member waiting

expired, nobody waiting

OnShelf

OnLoan

HoldShelf

checkout

return, nobody waiting

return, someone waiting

checkout by the holder

expired, next member waiting

expired, nobody waiting

OnShelf

OnLoan

HoldShelf

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
Go deeperA return that fills a hold, step by step1 min

Ana returns dune-1. The desk ends her loan and works out any fine. Then it calls route(dune-1). route finds Cy first in Dune’s queue, puts dune-1 on the hold shelf for Cy until three days from now, removes Cy from the queue, and tells the notifier. Nobody else can borrow dune-1 now.

Sequence: a return that fills a hold
NotifierHoldsFineLedgerLoanDeskKioskNotifierHoldsFineLedgerLoanDeskKioskreturnCopy(dune-1)remove the loanadd(fine) if latenext(isbn-dune)Cy's holdsetAside(dune-1, Cy until Mar 5)holdReady(Cy, Dune, Mar 5)checkout(Ana, dune-1)HELD_FOR_SOMEONE_ELSENotifierHoldsFineLedgerLoanDeskKioskNotifierHoldsFineLedgerLoanDeskKioskreturnCopy(dune-1)remove the loanadd(fine) if latenext(isbn-dune)Cy's holdsetAside(dune-1, Cy until Mar 5)holdReady(Cy, Dune, Mar 5)checkout(Ana, dune-1)HELD_FOR_SOMEONE_ELSE
Scroll or pinch to zoom, drag to pan
  • Color key
  • Interface
  • Entity
  • Facade
Go deeperCy owes 30 dollars in fines when their hold comes in1 min

The copy is set aside for Cy as usual, and Cy is told. At the desk, checkout refuses with FINES_OWED until Cy pays. If Cy doesn’t pay within three days, the pickup expires and the copy goes to the next person. Some libraries skip blocked members when routing instead. That’s one extra check in route, and a policy decision rather than a design one.

This is enough for a mid-level answer. Book and copy are separate, the rules are in a policy, fines are worked out at return, and there’s a hold queue per title. The rest of the page is what makes it a senior answer.

Here is everything together. Each class is here because a requirement needed it. SharedDesk comes from the next section.

Library management: full class diagram

returns

SharedDesk

-lock

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+placeHold(Member, isbn) : Result

LoanDesk

-loans: Map<barcode,Loan>

-byMember: Map<id,Set>

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+placeHold(Member, isbn) : Result

+renew(Member, barcode) : Result

+expireHolds() : int

+pay(Member, cents) : long

-route(Copy, today)

Catalog

+add(Copy)

+copy(barcode) : Copy

+copiesOf(isbn) : List<Copy>

+search(text) : List<Book>

FineLedger

+add(Fine)

+owed(memberId) : long

+pay(memberId, cents) : long

Holds

+add(Hold)

+next(isbn) : Hold

+setAside(barcode, Pickup)

+pickupFor(barcode) : Pickup

+expired(today) : List

«interface»

LendingPolicy

+maxLoans(Member) : int

+loanDays(Member) : int

+fineFor(Loan, daysLate) : long

+maxOwed() : long

+maxRenewals() : int

+holdDays() : int

StandardPolicy

«interface»

Notifier

+holdReady(Member, Book, until)

Copy

+barcode

+book

Loan

+copy

+member

+dueOn

+renewals

Member

+id

+name

+type

«value»

Book

+isbn

+title

+author

«value»

Fine

«value»

Hold

«value»

Pickup

«enum»

MemberType

STUDENT

FACULTY

«enum»

Result

returns

SharedDesk

-lock

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+placeHold(Member, isbn) : Result

LoanDesk

-loans: Map<barcode,Loan>

-byMember: Map<id,Set>

+checkout(Member, barcode) : Result

+returnCopy(barcode) : Result

+placeHold(Member, isbn) : Result

+renew(Member, barcode) : Result

+expireHolds() : int

+pay(Member, cents) : long

-route(Copy, today)

Catalog

+add(Copy)

+copy(barcode) : Copy

+copiesOf(isbn) : List<Copy>

+search(text) : List<Book>

FineLedger

+add(Fine)

+owed(memberId) : long

+pay(memberId, cents) : long

Holds

+add(Hold)

+next(isbn) : Hold

+setAside(barcode, Pickup)

+pickupFor(barcode) : Pickup

+expired(today) : List

«interface»

LendingPolicy

+maxLoans(Member) : int

+loanDays(Member) : int

+fineFor(Loan, daysLate) : long

+maxOwed() : long

+maxRenewals() : int

+holdDays() : int

StandardPolicy

«interface»

Notifier

+holdReady(Member, Book, until)

Copy

+barcode

+book

Loan

+copy

+member

+dueOn

+renewals

Member

+id

+name

+type

«value»

Book

+isbn

+title

+author

«value»

Fine

«value»

Hold

«value»

Pickup

«enum»

MemberType

STUDENT

FACULTY

«enum»

Result

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
library-management· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.concurrent.locks.*;
import java.util.function.*;
import java.time.*;
import java.time.temporal.*;
// A title: what a reader searches for. The library may own many copies of one book.
record Book(String isbn, String title, String author) {}
// One physical copy, known by the barcode inside its cover. Loans are of copies; searching
// is over books. Keeping the two apart is the decision the rest of the design leans on.
record Copy(String barcode, Book book) {}
// A library card holder.
record Member(String id, String name, MemberType type) {}
// One borrowing: which copy, who has it, and until when. A class rather than a record because
// renewing changes the due date later on.
class Loan {
final Copy copy;
final Member member;
final LocalDate borrowedOn;
LocalDate dueOn;
int renewals; // how many times the due date has moved; the policy caps it
Loan(Copy copy, Member member, LocalDate borrowedOn, LocalDate dueOn) {
this.copy = copy;
this.member = member;
this.borrowedOn = borrowedOn;
this.dueOn = dueOn;
}
}
// Different members get different limits. The policy reads this; nothing else does.
enum MemberType { STUDENT, FACULTY }
// What the library owns, and nothing about who has it. Searches never touch loans.
class Catalog {
private final Map<String, Copy> copies = new LinkedHashMap<>(); // by barcode
void add(Copy c) { copies.put(c.barcode(), c); }
Copy copy(String barcode) { return copies.get(barcode); }
List<Copy> copiesOf(String isbn) {
return copies.values().stream().filter(c -> c.book().isbn().equals(isbn)).toList();
}
List<Book> search(String text) {
String q = text.toLowerCase();
return copies.values().stream().map(Copy::book).distinct()
.filter(b -> b.title().toLowerCase().contains(q) || b.author().toLowerCase().contains(q))
.toList();
}
}
// The numbers a library sets and changes: how many books, for how long, what lateness costs.
// The desk asks the policy and never hard-codes one, so a new member type or a new fine rule is
// a change here and nowhere else.
interface LendingPolicy {
int maxLoans(Member m);
int loanDays(Member m);
long fineFor(Loan loan, long daysLate);
long maxOwed();
int maxRenewals();
int holdDays(); // how long a set-aside copy waits to be collected
}
// One library's numbers. Faculty borrow more for longer. A fine is 25 cents a day, capped at
// $20 a book, and more than $5 owed stops new loans. Money is in cents.
class StandardPolicy implements LendingPolicy {
public int maxLoans(Member m) { return m.type() == MemberType.FACULTY ? 20 : 5; }
public int loanDays(Member m) { return m.type() == MemberType.FACULTY ? 90 : 14; }
public long fineFor(Loan loan, long daysLate) { return Math.min(daysLate * 25, 2000); }
public long maxOwed() { return 500; }
public int maxRenewals() { return 2; }
public int holdDays() { return 3; }
}
// What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader
// what to do next.
enum Result {
OK, NO_SUCH_COPY, NOT_AVAILABLE, NOT_ON_LOAN, LIMIT_REACHED, HAS_OVERDUE, FINES_OWED,
HELD_FOR_SOMEONE_ELSE, COPY_AVAILABLE, ALREADY_WAITING, NOT_YOUR_LOAN, RENEWAL_LIMIT, HOLD_WAITING
}
// Where every loan, return, hold and renewal happens. It owns who has what; the catalog owns
// what exists, and the policy owns the numbers.
class LoanDesk {
private final Catalog catalog;
private final LendingPolicy policy;
private final Notifier notifier;
private final Supplier<LocalDate> clock;
private final FineLedger fines = new FineLedger();
private final Holds holds = new Holds();
private final Map<String, Loan> loans = new HashMap<>(); // barcode -> its loan
private final Map<String, Set<String>> byMember = new HashMap<>(); // member id -> barcodes, for a fast "what do I have?"
LoanDesk(Catalog catalog, LendingPolicy policy, Notifier notifier, Supplier<LocalDate> clock) {
this.catalog = catalog;
this.policy = policy;
this.notifier = notifier;
this.clock = clock;
}
// Every reason to refuse is checked before anything changes, so a refused checkout leaves
// no trace.
Result checkout(Member m, String barcode) {
Copy c = catalog.copy(barcode);
if (c == null) return Result.NO_SUCH_COPY;
if (loans.containsKey(barcode)) return Result.NOT_AVAILABLE;
// A copy on the hold shelf can only go to the member it was set aside for.
Pickup p = holds.pickupFor(barcode);
if (p != null && !p.member().id().equals(m.id())) return Result.HELD_FOR_SOMEONE_ELSE;
LocalDate today = clock.get();
List<Loan> mine = loansOf(m);
if (mine.size() >= policy.maxLoans(m)) return Result.LIMIT_REACHED;
if (mine.stream().anyMatch(l -> l.dueOn.isBefore(today))) return Result.HAS_OVERDUE;
if (fines.owed(m.id()) > policy.maxOwed()) return Result.FINES_OWED;
if (p != null) holds.clear(barcode);
loans.put(barcode, new Loan(c, m, today, today.plusDays(policy.loanDays(m))));
byMember.computeIfAbsent(m.id(), k -> new HashSet<>()).add(barcode);
return Result.OK;
}
// A late return adds a fine. Then the copy goes to the next reader waiting, if there is one.
Result returnCopy(String barcode) {
Loan l = loans.remove(barcode);
if (l == null) return Result.NOT_ON_LOAN;
byMember.get(l.member.id()).remove(barcode);
LocalDate today = clock.get();
long daysLate = ChronoUnit.DAYS.between(l.dueOn, today);
if (daysLate > 0) {
fines.add(new Fine(l.member.id(), barcode, policy.fineFor(l, daysLate), today));
}
route(l.copy, today);
return Result.OK;
}
Result placeHold(Member m, String isbn) {
if (holds.isWaiting(m, isbn)) return Result.ALREADY_WAITING;
// With a copy on the open shelf the reader should take it. A hold now would sit in
// the queue while that copy sits on the shelf.
boolean onShelf = catalog.copiesOf(isbn).stream()
.anyMatch(c -> !loans.containsKey(c.barcode()) && holds.pickupFor(c.barcode()) == null);
if (onShelf) return Result.COPY_AVAILABLE;
holds.add(new Hold(m, isbn, clock.get()));
return Result.OK;
}
Result renew(Member m, String barcode) {
Loan l = loans.get(barcode);
if (l == null || !l.member.id().equals(m.id())) return Result.NOT_YOUR_LOAN;
LocalDate today = clock.get();
if (l.dueOn.isBefore(today)) return Result.HAS_OVERDUE;
if (l.renewals >= policy.maxRenewals()) return Result.RENEWAL_LIMIT;
// Someone waiting for this title outranks a second turn for the current reader.
if (holds.anyWaiting(l.copy.book().isbn())) return Result.HOLD_WAITING;
l.dueOn = today.plusDays(policy.loanDays(m));
l.renewals++;
return Result.OK;
}
// Run once a day. A copy nobody collected moves on to the next person waiting, or back to
// the open shelf. Returns how many pickups expired.
int expireHolds() {
LocalDate today = clock.get();
List<String> gone = holds.expired(today);
for (String barcode : gone) {
holds.clear(barcode);
route(catalog.copy(barcode), today);
}
return gone.size();
}
// Every copy that comes free goes through here: to the first member waiting for its
// title, or back to the open shelf when nobody is.
private void route(Copy c, LocalDate today) {
Hold next = holds.next(c.book().isbn());
if (next == null) return;
LocalDate until = today.plusDays(policy.holdDays());
holds.setAside(c.barcode(), new Pickup(next.member(), until));
notifier.holdReady(next.member(), c.book(), until);
}
long owed(Member m) { return fines.owed(m.id()); }
long pay(Member m, long cents) { return fines.pay(m.id(), cents); }
List<Loan> loansOf(Member m) {
return byMember.getOrDefault(m.id(), Set.of()).stream().map(loans::get).toList();
}
}
// A charge, kept as its own record instead of folded into a balance, so a member can see what
// each one was for and a librarian can waive one.
record Fine(String memberId, String barcode, long cents, LocalDate assessedOn) {}
// Who owes what. The desk adds fines on return; payments arrive at the counter.
class FineLedger {
private final Map<String, List<Fine>> fines = new HashMap<>();
private final Map<String, Long> paid = new HashMap<>();
void add(Fine f) { fines.computeIfAbsent(f.memberId(), k -> new ArrayList<>()).add(f); }
long owed(String memberId) {
long charged = fines.getOrDefault(memberId, List.of()).stream().mapToLong(Fine::cents).sum();
return charged - paid.getOrDefault(memberId, 0L);
}
// Takes at most what is owed, and returns what it took, so an overpayment is never kept.
long pay(String memberId, long cents) {
long taken = Math.min(cents, owed(memberId));
if (taken > 0) paid.merge(memberId, taken, Long::sum);
return taken;
}
}
// A member waiting for a title. Holds are on books, not copies: the reader wants whichever
// copy comes back first.
record Hold(Member member, String isbn, LocalDate placedOn) {}
// A copy set aside on the hold shelf for one member until a date. Once set aside it's this
// copy, so the shelf is keyed by barcode while the queue is keyed by ISBN.
record Pickup(Member member, LocalDate until) {}
// The waiting lists and the hold shelf, kept apart from loans so each stays simple.
class Holds {
private final Map<String, Deque<Hold>> waiting = new HashMap<>(); // isbn -> first come, first served
private final Map<String, Pickup> shelf = new HashMap<>(); // barcode -> who it's for
void add(Hold h) { waiting.computeIfAbsent(h.isbn(), k -> new ArrayDeque<>()).addLast(h); }
boolean anyWaiting(String isbn) { return !waiting.getOrDefault(isbn, new ArrayDeque<>()).isEmpty(); }
boolean isWaiting(Member m, String isbn) {
return waiting.getOrDefault(isbn, new ArrayDeque<>()).stream()
.anyMatch(h -> h.member().id().equals(m.id()));
}
// Removes and returns the first member waiting for this title, or null.
Hold next(String isbn) {
Deque<Hold> q = waiting.get(isbn);
return q == null ? null : q.pollFirst();
}
void setAside(String barcode, Pickup p) { shelf.put(barcode, p); }
Pickup pickupFor(String barcode) { return shelf.get(barcode); }
void clear(String barcode) { shelf.remove(barcode); }
// A pickup is good through its last day, so it expires once today is after it.
List<String> expired(LocalDate today) {
return shelf.entrySet().stream().filter(e -> e.getValue().until().isBefore(today))
.map(Map.Entry::getKey).sorted().toList();
}
}
// Email, text or app push. The desk says what happened; how the member hears is not its job.
interface Notifier {
void holdReady(Member m, Book b, LocalDate until);
}
// Kiosks, the front desk and the website all call the desk on their own threads, and two races
// matter. A hold placed while the last copy is being returned can join the queue just after
// the return found it empty: the copy goes to the open shelf and the reader waits for a return
// that already happened. And a member one book under the limit, at two kiosks at once, can end
// up one over it. Each is a check and an act on shared state, split by another thread.
//
// One lock around the whole desk closes both. A branch makes a few loans a minute, each a few
// microseconds of work, so nobody waits on it in practice.
class SharedDesk {
private final LoanDesk desk;
private final ReentrantLock lock = new ReentrantLock();
SharedDesk(LoanDesk desk) { this.desk = desk; }
Result checkout(Member m, String barcode) { return locked(() -> desk.checkout(m, barcode)); }
Result returnCopy(String barcode) { return locked(() -> desk.returnCopy(barcode)); }
Result placeHold(Member m, String isbn) { return locked(() -> desk.placeHold(m, isbn)); }
Result renew(Member m, String barcode) { return locked(() -> desk.renew(m, barcode)); }
int expireHolds() { return locked(desk::expireHolds); }
long pay(Member m, long cents) { return locked(() -> desk.pay(m, cents)); }
long owed(Member m) { return locked(() -> desk.owed(m)); }
int loanCount(Member m) { return locked(() -> desk.loansOf(m).size()); }
// The notifier runs inside the lock, so it must only queue the message (an outbox), never
// send an email while every kiosk waits.
private <T> T locked(Supplier<T> op) {
lock.lock();
try {
return op.get();
} finally {
lock.unlock();
}
}
}
Read onlyJava 21Every file builds in CI18 files300 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI18 files371 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI18 files283 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI18 files468 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI20 files362 lines

Three ideas hold it together:

  • Each part owns one thing. The catalog owns books, the desk owns loans, the ledger owns money, the holds own the queue and the hold shelf, and the policy owns the numbers (Strategy). A change to fine rules touches the policy; a change to search touches the catalog. That’s the answer to “without a god class”. To kiosks, LoanDesk is a simple front door with a handful of methods (a facade).
  • State is worked out, not stored as a flag. A copy is out because a loan exists, and set aside because a pickup exists. There’s no status field to fall out of step.
  • Every freed copy is routed. One private method checks the queue, so the main promise holds however the copy became free.

So far, one person uses the desk at a time. But the front desk, three kiosks and the website all use it at once.

Interviewerminute 26

The front desk, three kiosks and the website all use this at once. What can go wrong?

What they are testing

Whether you find a real race rather than saying “add synchronized”. The good answer names the hold that joins the queue just after a return found it empty, and the member who goes over the limit at two kiosks. Then they want a fix sized to a library’s traffic.

Two races matter. Both are a check and an action, split apart by another thread.

The lost hold. Cy places a hold on Dune on the website, at the same moment Ana returns the last copy at a kiosk. The return checks the queue: empty, so the copy goes to the open shelf. But Cy’s hold had already checked that no copy was free, and now it joins the queue. Cy waits for a return that already happened, and the copy sits on the shelf until someone else takes it.

Sequence: the lost hold, without a lock
Kiosk (Ana)LoanDeskWebsite (Cy)Kiosk (Ana)LoanDeskWebsite (Cy)Cy waits, and dune-2 sits on the shelfplaceHold(Cy, Dune)any copy free? noreturnCopy(dune-2)anyone waiting? nodune-2 back on the open shelfqueue Cy's holdKiosk (Ana)LoanDeskWebsite (Cy)Kiosk (Ana)LoanDeskWebsite (Cy)Cy waits, and dune-2 sits on the shelfplaceHold(Cy, Dune)any copy free? noreturnCopy(dune-2)anyone waiting? nodune-2 back on the open shelfqueue Cy's hold
Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade

The sixth book. Ana has four books, and the limit is five. She and a friend with her card scan two books at two kiosks at once. Both checkouts count four, both lend, and Ana has six.

The clearly correct fix, and the one we choose, is one lock around the desk. Every operation runs alone. Both races close, because a check and its action can no longer be split. Is that too slow? A branch makes a few loans a minute, and each operation is a few lookups that take microseconds. Nobody ever waits on this lock in practice.

SharedDesk adds the lock as a wrapper around LoanDesk. So the desk itself stays simple, single-threaded code, and its tests don’t change. This is the Decorator pattern.

Option How Correct? Cost
1. One lock around the desk (our choice) Every operation runs alone Yes Operations wait for each other, for microseconds
2. A lock per member and a lock per title Take the member’s lock, then the title’s, always in that order Yes Two locks per call, an ordering rule, and extra checks
3. Database transactions Lock the member row, then the title row, in one transaction Yes The real answer once the data is in a database
4. Thread-safe maps, no lock ConcurrentHashMap for loans and queues No Each map is safe; a check across two of them is not
SharedDesk.javashared_desk.pySharedDesk.csshared_desk.godb.ts
// Kiosks, the front desk and the website all call the desk on their own threads, and two races
// matter. A hold placed while the last copy is being returned can join the queue just after
// the return found it empty: the copy goes to the open shelf and the reader waits for a return
// that already happened. And a member one book under the limit, at two kiosks at once, can end
// up one over it. Each is a check and an act on shared state, split by another thread.
//
// One lock around the whole desk closes both. A branch makes a few loans a minute, each a few
// microseconds of work, so nobody waits on it in practice.
class SharedDesk {
private final LoanDesk desk;
private final ReentrantLock lock = new ReentrantLock();
SharedDesk(LoanDesk desk) { this.desk = desk; }
Result checkout(Member m, String barcode) { return locked(() -> desk.checkout(m, barcode)); }
Result returnCopy(String barcode) { return locked(() -> desk.returnCopy(barcode)); }
Result placeHold(Member m, String isbn) { return locked(() -> desk.placeHold(m, isbn)); }
Result renew(Member m, String barcode) { return locked(() -> desk.renew(m, barcode)); }
int expireHolds() { return locked(desk::expireHolds); }
long pay(Member m, long cents) { return locked(() -> desk.pay(m, cents)); }
long owed(Member m) { return locked(() -> desk.owed(m)); }
int loanCount(Member m) { return locked(() -> desk.loansOf(m).size()); }
// The notifier runs inside the lock, so it must only queue the message (an outbox), never
// send an email while every kiosk waits.
private <T> T locked(Supplier<T> op) {
lock.lock();
try {
return op.get();
} finally {
lock.unlock();
}
}
}

Why is this correct? Every operation holds the same lock from its first check to its last change. So “is anyone waiting?” and “put it on the shelf” happen together, and so do “how many books?” and “lend it”. No other kiosk can act in between.

What does it cost? A national library system with thousands of kiosks would want finer locks. And the notifier must never do slow work inside the lock. It only queues the message, and a separate sender delivers it.

Go deeperTypeScript, and the database version1 min

In TypeScript, the in-memory desk needs no lock: each method runs to the end before another request starts. The TypeScript tab shows where the races come back: when the data moves to a database and every step is an await. Between two awaits, another request can run.

The fix there is option 3: one transaction that locks the member’s row, then the title’s row, always in that order. Why does the order matter? If one request locked the title then the member, while another locked the member then the title, each could hold one lock and wait for the other forever. That’s called a deadlock. A fixed order makes it impossible.

The clock is a function the test controls, so “fifteen days later” is one line. The tests that matter:

  • Search finds a book once, and each of its copies lends separately.
  • A student stops at five books; an overdue book blocks the next loan.
  • A late return is fined per day, up to the cap.
  • Fines over the limit block loans until paid, and paying too much takes only what’s owed.
  • A returned copy is set aside for the first member waiting, who is told, and nobody else can borrow it.
  • A hold is refused while a copy is on the shelf.
  • Renewals stop at the limit, and while someone is waiting.
  • An uncollected pickup passes to the next member, then to the open shelf.
  • Sixteen kiosks racing for one member’s last slot lend exactly one book.
Go deeperThe tests in five languages2 min

The expiry test protects the main promise. It walks a copy from Ben’s pickup, to Cy’s, to the open shelf. If anyone adds a new path that frees a copy, this is the test to copy for it.

FakeNotifier.javaworld.pyFakeNotifier.csworld.gofake-notifier.ts
// Records each "your hold is ready" message instead of sending it.
static class FakeNotifier implements Notifier {
final List<String> sent = new ArrayList<>();
public void holdReady(Member m, Book b, LocalDate until) { sent.add(m.id() + ":" + b.isbn() + ":" + until); }
}
static final Book DUNE = new Book("isbn-dune", "Dune", "Frank Herbert");
static final Member ANA = new Member("ana", "Ana", MemberType.STUDENT);
static final Member BEN = new Member("ben", "Ben", MemberType.STUDENT);
static final Member CY = new Member("cy", "Cy", MemberType.STUDENT);
LocalDate today = LocalDate.of(2026, 3, 2);
FakeNotifier notifier = new FakeNotifier();
Catalog catalog = new Catalog();
LoanDesk desk;
// Two copies of Dune, and twenty other books with one copy each.
@BeforeEach
void setUp() {
catalog.add(new Copy("dune-1", DUNE));
catalog.add(new Copy("dune-2", DUNE));
for (int i = 0; i < 20; i++) catalog.add(new Copy("b" + i, new Book("isbn-" + i, "Book " + i, "Author")));
desk = new LoanDesk(catalog, new StandardPolicy(), notifier, () -> today);
}
@Test
void searchFindsTheBookOnceAndEachCopyLendsSeparately() {
assertEquals(List.of(DUNE), catalog.search("herbert"));
assertEquals(Result.OK, desk.checkout(ANA, "dune-1"));
assertEquals(Result.NOT_AVAILABLE, desk.checkout(BEN, "dune-1"));
assertEquals(Result.OK, desk.checkout(BEN, "dune-2"));
}
@Test
void aStudentStopsAtFiveBooks() {
for (int i = 0; i < 5; i++) assertEquals(Result.OK, desk.checkout(ANA, "b" + i));
assertEquals(Result.LIMIT_REACHED, desk.checkout(ANA, "b5"));
}
@Test
void anOverdueBookBlocksNewLoans() {
desk.checkout(ANA, "b0");
today = today.plusDays(15);
assertEquals(Result.HAS_OVERDUE, desk.checkout(ANA, "b1"));
}
@Test
void aLateReturnIsFinedPerDayUpToTheCap() {
desk.checkout(ANA, "b0");
desk.checkout(BEN, "b1");
today = today.plusDays(14 + 3);
desk.returnCopy("b0");
assertEquals(75, desk.owed(ANA));
today = today.plusDays(200);
desk.returnCopy("b1");
assertEquals(2000, desk.owed(BEN));
}
@Test
void finesOverTheLimitBlockLoansUntilPaid() {
desk.checkout(ANA, "b0");
today = today.plusDays(14 + 30);
desk.returnCopy("b0");
assertEquals(Result.FINES_OWED, desk.checkout(ANA, "b1"));
assertEquals(750, desk.pay(ANA, 1000)); // never takes more than is owed
assertEquals(Result.OK, desk.checkout(ANA, "b1"));
}
@Test
void aReturnedCopyIsSetAsideForTheFirstMemberWaiting() {
desk.checkout(ANA, "dune-1");
desk.checkout(BEN, "dune-2");
assertEquals(Result.OK, desk.placeHold(CY, DUNE.isbn()));
desk.returnCopy("dune-1");
assertEquals(List.of("cy:isbn-dune:2026-03-05"), notifier.sent);
assertEquals(Result.HELD_FOR_SOMEONE_ELSE, desk.checkout(ANA, "dune-1"));
assertEquals(Result.OK, desk.checkout(CY, "dune-1"));
}
@Test
void aHoldIsRefusedWhileACopyIsOnTheShelf() {
desk.checkout(ANA, "dune-1");
assertEquals(Result.COPY_AVAILABLE, desk.placeHold(BEN, DUNE.isbn()));
}
@Test
void renewalsStopWhenSomeoneIsWaitingOrAtTheLimit() {
desk.checkout(ANA, "b0");
assertEquals(Result.OK, desk.renew(ANA, "b0"));
assertEquals(Result.OK, desk.renew(ANA, "b0"));
assertEquals(Result.RENEWAL_LIMIT, desk.renew(ANA, "b0"));
desk.checkout(ANA, "dune-1");
desk.checkout(BEN, "dune-2");
desk.placeHold(CY, DUNE.isbn());
assertEquals(Result.HOLD_WAITING, desk.renew(ANA, "dune-1"));
}
@Test
void anUncollectedHoldPassesToTheNextMemberThenToTheShelf() {
desk.checkout(ANA, "dune-1");
desk.checkout(ANA, "dune-2");
desk.placeHold(BEN, DUNE.isbn());
desk.placeHold(CY, DUNE.isbn());
desk.returnCopy("dune-1"); // set aside for Ben until 2026-03-05
today = today.plusDays(4);
assertEquals(1, desk.expireHolds()); // Ben didn't come; Cy is next
assertEquals(Result.HELD_FOR_SOMEONE_ELSE, desk.checkout(BEN, "dune-1"));
today = today.plusDays(4);
assertEquals(1, desk.expireHolds()); // nobody else waiting: back on the shelf
assertEquals(Result.OK, desk.checkout(BEN, "dune-1"));
}
@Test
void racingKiosksNeverPushAMemberOverTheLimit() throws Exception {
SharedDesk shared = new SharedDesk(desk);
for (int i = 0; i < 4; i++) shared.checkout(ANA, "b" + i);
ExecutorService pool = Executors.newFixedThreadPool(16);
CountDownLatch start = new CountDownLatch(1);
List<Future<Result>> results = new ArrayList<>();
for (int i = 4; i < 20; i++) {
String barcode = "b" + i;
results.add(pool.submit(() -> { start.await(); return shared.checkout(ANA, barcode); }));
}
start.countDown();
int ok = 0;
for (Future<Result> f : results) if (f.get() == Result.OK) ok++;
pool.shutdown();
assertEquals(1, ok);
assertEquals(5, shared.loanCount(ANA));
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
Unknown barcode NO_SUCH_COPY LoanDesk.checkout
Copy already out NOT_AVAILABLE LoanDesk.checkout
Sixth book for a student LIMIT_REACHED LendingPolicy.maxLoans
Overdue book at home HAS_OVERDUE on checkout and on renew LoanDesk
Returned late Fine per day, capped LoanDesk.returnCopy
Owing more than the limit FINES_OWED until paid LoanDesk.checkout
Paying more than owed Only what’s owed is taken FineLedger.pay
Returned twice NOT_ON_LOAN the second time; no second fine LoanDesk.returnCopy
Hold while a copy is on the shelf COPY_AVAILABLE LoanDesk.placeHold
Second hold on the same title ALREADY_WAITING Holds.isWaiting
Someone else takes a set-aside copy HELD_FOR_SOMEONE_ELSE LoanDesk.checkout
Pickup not collected Next member, or back to the shelf LoanDesk.expireHolds
Renew while someone waits HOLD_WAITING LoanDesk.renew
Renew someone else’s loan NOT_YOUR_LOAN LoanDesk.renew
Hold placed during the last return One runs after the other, thanks to the desk lock SharedDesk
Decision We chose We gave up
What is lent A copy with a barcode, separate from the book Nothing; one class can’t describe three copies
Is it out? Worked out from active loans and pickups A status field that’s quick to read
Rules A policy the desk asks One more interface
Splitting the work Catalog, desk, ledger, holds, each owning one thing More classes than a single Library
Fines Worked out once at return, kept as records “Owed so far” for books still out needs working out on the spot
Holds Queue per book, pickup per copy, every freed copy routed Every new path must call route
Concurrency One lock around the desk; a transaction in the database The finer locks a huge system would need
Go deeperGenuinely different designs, and when to pick them2 min

A status field on the copy. Many designs give Copy a status (available, on loan, on hold shelf, lost) and update it in each operation. It makes “show me every copy’s status” a simple read, and it’s how a database table would look. The cost is that the status and the loan can disagree, so every operation must update both. Working the status out from loans and pickups trades a little lookup work for one source of truth.

Holds that skip blocked members. Instead of setting a copy aside for someone who can’t borrow it (fines, overdue books), route can skip them and leave their hold in place. That serves the next reader faster, and it’s a few lines in route. It also means a blocked member’s hold never fills, which some libraries see as a feature and others as a complaint.

An event log. Record every checkout, return, hold and payment as an event, and build loans, fines and queues from the events. It gives a perfect history and easy audits, but reading the current state gets more complex. A library rarely needs it. A system that lends expensive equipment might.

  • 0 to 3. Ask about copies, rules per member type, fines and holds.
  • 3 to 5. The promises. Name “a returned copy never sits on the shelf while someone waits” as the hard one.
  • 5 to 25. Book and copy, then the policy and the split, then fines at return, then holds with route. Code checkout, returnCopy and route, and describe the rest.
  • 25 to 27. Walk through the full diagram: each part owns one thing.
  • 27 to 33. The lost hold and the sixth book; one lock, and the database version.
  • 33 to 45. Branches or lost books, then your own questions.

If time is short, describe fines in one sentence and spend the time on holds. Routing returned copies is where this design is really tested.

Interviewerminute 32

Now there are five branches. A member can borrow from one and return to another.

What they are testing

Whether you see that a copy now has a home branch and a current location, and that a hold can be filled by a copy at another branch, which then has to travel.

Most extensions fit into one place:

Extension What changes Touches the desk?
Multiple branches Copies get a home and a location; route may send a copy in transit route only
Lost books A daily job declares loans lost after N days late; the policy prices them One new method
Cancel a fine The ledger records a cancellation against a fine No
Due-date reminders A daily job asks the desk for loans due tomorrow; the notifier sends them No
New member type One more case in the policy No
Reading history Keep returned loans instead of deleting them, with a returned date One line
E-books A license count per title behaves like copies without barcodes A new kind of copy
Go deeperAdding branches, step by step2 min

With five branches, a copy can be in a different place from where it belongs. That’s the one new idea, and most of the change follows from it.

  1. A copy gets a home and a location. home is the branch that owns it. location is where it is right now: a branch, or “in transit” between two.
  2. Returning anywhere still calls route. Ana can return a book at any branch. The desk ends the loan exactly as before, then routes the copy.
  3. route looks at the whole queue. The first member waiting may have chosen to collect at another branch. If they’re at the branch where the copy is, the copy goes on that hold shelf, as before. If not, the copy goes in transit to their branch, and the pickup’s three days start when it arrives, not now.
  4. Nobody waiting? The copy travels back to its home branch, or stays where it is, depending on the library’s policy. That’s one more question for LendingPolicy.

What about holds? A member now picks a pickup branch when placing a hold. The queue stays per book, across all branches, so the first person waiting is served first, wherever they are. A library that prefers “nearest copy first” would order the queue differently, but route would still be the one place that decides.

Notice what stayed the same: the policy, the ledger, the notifier, and the rule that every freed copy goes through route.

Go deeperFour more follow-up questions2 min
Interviewer asksHow would search scale to a million books?

The catalog’s search looks at every book, which is fine for a branch and wrong for a national catalog. The catalog interface stays the same. Behind it goes a search index (a full-text search engine, or a database with text indexes), organized by book, not copy. The desk never searches, so it doesn’t change.

Interviewer asksWhere do the dates come from in production?

From a clock the desk is given. In production, it returns the library’s local date, which matters: a library in Sydney and a server in Virginia disagree about what day it is for most of every day. Fines and due dates are calendar dates in the library’s time zone, never times in the server’s.

Interviewer asksWhy keep fines as records instead of a total per member?

Because people dispute fines. With records, the member sees which book, how late, and how much, and a librarian can cancel one fine without guessing how much of a total it made up. The total is a sum over the records, worked out when needed.

Interviewer asksHow does the daily expiry job avoid running twice?

It doesn’t need to. expireHolds only acts on pickups whose date has passed, and each one it handles is removed or replaced. So a second run on the same day finds nothing to do. Jobs that are safe to repeat are much easier to schedule than jobs that must run exactly once.