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.
- Read first
- Object modelingStrategy
“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.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- 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.
- 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. - 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.
- 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. - 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.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a library management system.
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 |
Build it
Section titled “Build it”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).
Stage 1: Books, copies and loans
Section titled “Stage 1: Books, copies and loans”Start simple. A member borrows a book and brings it back. What are your classes?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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); }}from dataclasses import dataclassfrom datetime import date, timedelta
# A title: what a reader searches for. The library may own many copies of one book.@dataclass(frozen=True)class Book: isbn: str title: str author: str
# 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.@dataclass(frozen=True)class Copy: barcode: str book: Book
@dataclass(frozen=True)class Member: id: str name: str
# One borrowing: which copy, who has it, and until when. Not frozen, because renewing# changes the due date later on.@dataclassclass Loan: copy: Copy member: Member borrowed_on: date due_on: date
class Library: def __init__(self): self._copies: dict[str, Copy] = {} # by barcode self._loans: dict[str, Loan] = {} # active loans, by barcode
def add_copy(self, c: Copy) -> None: self._copies[c.barcode] = c
# Search returns books, so three copies of one title show up once. def search(self, text: str) -> list[Book]: q = text.lower() books = dict.fromkeys(c.book for c in self._copies.values()) # distinct, in order return [b for b in books if q in b.title.lower() or q in b.author.lower()]
# 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. def checkout(self, m: Member, barcode: str, today: date) -> bool: c = self._copies.get(barcode) if c is None or barcode in self._loans: return False self._loans[barcode] = Loan(c, m, today, today + timedelta(days=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. def return_copy(self, barcode: str) -> None: self._loans.pop(barcode, None)// A title: what a reader searches for. The library may own many copies of one book.public sealed 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.public sealed record Copy(string Barcode, Book Book);
public sealed 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.public sealed class Loan(Copy copy, Member member, DateOnly borrowedOn, DateOnly dueOn){ public Copy Copy { get; } = copy; public Member Member { get; } = member; public DateOnly BorrowedOn { get; } = borrowedOn; public DateOnly DueOn { get; set; } = dueOn;}
public sealed class Library{ private readonly Dictionary<string, Copy> _copies = []; // by barcode private readonly Dictionary<string, Loan> _loans = []; // active loans, by barcode
public void AddCopy(Copy c) => _copies[c.Barcode] = c;
// Search returns books, so three copies of one title show up once. public List<Book> Search(string text) => _copies.Values.Select(c => c.Book).Distinct() .Where(b => b.Title.Contains(text, StringComparison.OrdinalIgnoreCase) || b.Author.Contains(text, StringComparison.OrdinalIgnoreCase)) .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. public bool Checkout(Member m, string barcode, DateOnly today) { if (!_copies.TryGetValue(barcode, out var c) || _loans.ContainsKey(barcode)) return false; _loans[barcode] = new Loan(c, m, today, today.AddDays(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. public void ReturnCopy(string barcode) => _loans.Remove(barcode);}// Book is a title: what a reader searches for. The library may own many copies of one book.type Book struct { ISBN, Title, Author string}
// Copy is 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 leans on.type Copy struct { Barcode string Book Book}
type Member struct { ID, Name string}
// Loan is one borrowing: which copy, who has it, and until when. Dates are midnight UTC// values, so subtracting two of them gives whole days.type Loan struct { Copy Copy Member Member BorrowedOn time.Time DueOn time.Time}
type Library struct { copies map[string]Copy // by barcode loans map[string]*Loan // active loans, by barcode}
func NewLibrary() *Library { return &Library{copies: map[string]Copy{}, loans: map[string]*Loan{}}}
func (l *Library) AddCopy(c Copy) { l.copies[c.Barcode] = c }
// Search returns books, so three copies of one title show up once.func (l *Library) Search(text string) []Book { q := strings.ToLower(text) seen := map[string]bool{} var out []Book for _, c := range l.copies { b := c.Book if seen[b.ISBN] { continue } seen[b.ISBN] = true if strings.Contains(strings.ToLower(b.Title), q) || strings.Contains(strings.ToLower(b.Author), q) { out = append(out, b) } } return out}
// Checkout has no rules (flaw 1): 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.func (l *Library) Checkout(m Member, barcode string, today time.Time) bool { c, ok := l.copies[barcode] if !ok || l.loans[barcode] != nil { return false } l.loans[barcode] = &Loan{Copy: c, Member: m, BorrowedOn: today, DueOn: today.AddDate(0, 0, 14)} return true}
// ReturnCopy charges nothing for lateness, and the copy goes back on the shelf even when// someone is waiting for it (flaw 2); stages 3 and 4 add fines and holds. flaw 3: each of// those would land in Library too, until it does everything. Stage 2 starts splitting it.func (l *Library) ReturnCopy(barcode string) { delete(l.loans, barcode) }// A calendar day as a whole number (days since an epoch). JavaScript has no date-only type,// and a Date carries a time and a timezone that make "how many days late?" error-prone.type Day = number;
// A title: what a reader searches for. The library may own many copies of one book.interface Book { readonly isbn: string; readonly title: string; readonly author: string;}
// 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.interface Copy { readonly barcode: string; readonly book: Book;}
interface Member { readonly id: string; readonly name: string;}
// One borrowing: which copy, who has it, and until when. dueOn is mutable because renewing// changes it later on.interface Loan { readonly copy: Copy; readonly member: Member; readonly borrowedOn: Day; dueOn: Day;}
class Library { private readonly copies = new Map<string, Copy>(); // by barcode private readonly loans = new Map<string, Loan>(); // active loans, by barcode
addCopy(c: Copy): void { this.copies.set(c.barcode, c); }
// Search returns books, so three copies of one title show up once. search(text: string): Book[] { const q = text.toLowerCase(); const books = new Set([...this.copies.values()].map((c) => c.book)); return [...books].filter((b) => b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)); }
// 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. checkout(m: Member, barcode: string, today: Day): boolean { const c = this.copies.get(barcode); if (!c || this.loans.has(barcode)) return false; this.loans.set(barcode, { copy: c, member: m, borrowedOn: today, dueOn: today + 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. returnCopy(barcode: string): void { this.loans.delete(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.
Stage 2: The rules in a policy
Section titled “Stage 2: The rules in a policy”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?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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(); }}from enum import Enum, autofrom typing import Callable, Protocol
# Different members get different limits. The policy reads this; nothing else does.class MemberType(Enum): STUDENT = auto() FACULTY = auto()
# A library card holder.@dataclass(frozen=True)class Member: id: str name: str type: MemberType
# What the library owns, and nothing about who has it. Searches never touch loans.class Catalog: def __init__(self): self._copies: dict[str, Copy] = {} # by barcode, in the order added
def add(self, c: Copy) -> None: self._copies[c.barcode] = c
def copy(self, barcode: str) -> Copy | None: return self._copies.get(barcode)
def copies_of(self, isbn: str) -> list[Copy]: return [c for c in self._copies.values() if c.book.isbn == isbn]
def search(self, text: str) -> list[Book]: q = text.lower() books = dict.fromkeys(c.book for c in self._copies.values()) return [b for b in books if q in b.title.lower() or q in b.author.lower()]
# 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.class LendingPolicy(Protocol): def max_loans(self, m: Member) -> int: ... def loan_days(self, m: Member) -> int: ...
class StandardPolicy: def max_loans(self, m: Member) -> int: return 20 if m.type is MemberType.FACULTY else 5
def loan_days(self, m: Member) -> int: return 90 if m.type is MemberType.FACULTY else 14
# Why a request was refused. Refusals are normal at a library desk, so they are values the# kiosk shows, not exceptions.class Result(Enum): OK = auto() NO_SUCH_COPY = auto() NOT_AVAILABLE = auto() NOT_ON_LOAN = auto() LIMIT_REACHED = auto() HAS_OVERDUE = auto()
# Who has what. The desk owns loans and applies the policy; the catalog owns the books.class LoanDesk: def __init__(self, catalog: Catalog, policy: LendingPolicy, clock: Callable[[], date]): self._catalog = catalog self._policy = policy self._clock = clock # injected, so tests can move time forward self._loans: dict[str, Loan] = {} # active, by barcode self._by_member: dict[str, set[str]] = {} # member id -> barcodes
def checkout(self, m: Member, barcode: str) -> Result: c = self._catalog.copy(barcode) if c is None: return Result.NO_SUCH_COPY if barcode in self._loans: return Result.NOT_AVAILABLE today = self._clock() mine = self.loans_of(m) if len(mine) >= self._policy.max_loans(m): return Result.LIMIT_REACHED # An overdue book at home blocks new loans: the usual nudge to bring it back. if any(l.due_on < today for l in mine): return Result.HAS_OVERDUE self._loans[barcode] = Loan(c, m, today, today + timedelta(days=self._policy.loan_days(m))) self._by_member.setdefault(m.id, set()).add(barcode) return Result.OK
def return_copy(self, barcode: str) -> Result: l = self._loans.pop(barcode, None) if l is None: return Result.NOT_ON_LOAN self._by_member[l.member.id].discard(barcode) return Result.OK
# The member index makes "what do I have out?" a lookup instead of a scan of every loan. def loans_of(self, m: Member) -> list[Loan]: return [self._loans[b] for b in self._by_member.get(m.id, ())]// Different members get different limits. The policy reads this; nothing else does.public enum MemberType { Student, Faculty }
// A library card holder.public sealed record Member(string Id, string Name, MemberType Type);
// What the library owns, and nothing about who has it. Searches never touch loans.public sealed class Catalog{ private readonly Dictionary<string, Copy> _copies = []; // by barcode private readonly List<Copy> _inOrder = []; // for searches in the order added
public void Add(Copy c) { _copies[c.Barcode] = c; _inOrder.Add(c); }
public Copy? Copy(string barcode) => _copies.GetValueOrDefault(barcode);
public List<Copy> CopiesOf(string isbn) => _inOrder.Where(c => c.Book.Isbn == isbn).ToList();
public List<Book> Search(string text) => _inOrder.Select(c => c.Book).Distinct() .Where(b => b.Title.Contains(text, StringComparison.OrdinalIgnoreCase) || b.Author.Contains(text, StringComparison.OrdinalIgnoreCase)) .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.public interface ILendingPolicy{ int MaxLoans(Member m); int LoanDays(Member m);}
public sealed class StandardPolicy : ILendingPolicy{ public int MaxLoans(Member m) => m.Type == MemberType.Faculty ? 20 : 5; public int LoanDays(Member m) => 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.public enum Result { Ok, NoSuchCopy, NotAvailable, NotOnLoan, LimitReached, HasOverdue }
// Who has what. The desk owns loans and applies the policy; the catalog owns the books.public sealed class LoanDesk(Catalog catalog, ILendingPolicy policy, Func<DateOnly> clock){ private readonly Dictionary<string, Loan> _loans = []; // active, by barcode private readonly Dictionary<string, HashSet<string>> _byMember = []; // member id -> barcodes
// clock is injected, so tests can move time forward. public Result Checkout(Member m, string barcode) { var c = catalog.Copy(barcode); if (c is null) return Result.NoSuchCopy; if (_loans.ContainsKey(barcode)) return Result.NotAvailable; var today = clock(); var mine = LoansOf(m); if (mine.Count >= policy.MaxLoans(m)) return Result.LimitReached; // An overdue book at home blocks new loans: the usual nudge to bring it back. if (mine.Any(l => l.DueOn < today)) return Result.HasOverdue; Lend(c, m, today); return Result.Ok; }
public Result ReturnCopy(string barcode) { if (!_loans.Remove(barcode, out var l)) return Result.NotOnLoan; _byMember[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. public List<Loan> LoansOf(Member m) => _byMember.TryGetValue(m.Id, out var mine) ? mine.Select(b => _loans[b]).ToList() : [];
private void Lend(Copy c, Member m, DateOnly today) { _loans[c.Barcode] = new Loan(c, m, today, today.AddDays(policy.LoanDays(m))); if (!_byMember.TryGetValue(m.Id, out var mine)) _byMember[m.Id] = mine = []; mine.Add(c.Barcode); }}// MemberType gives different members different limits. The policy reads it; nothing else does.type MemberType int
const ( Student MemberType = iota Faculty)
// Member is a library card holder.type Member struct { ID, Name string Type MemberType}
// Catalog is what the library owns, and nothing about who has it. Searches never touch loans.type Catalog struct { copies []Copy // in the order added index map[string]int // barcode -> position in copies}
func NewCatalog() *Catalog { return &Catalog{index: map[string]int{}} }
func (c *Catalog) Add(cp Copy) { c.index[cp.Barcode] = len(c.copies) c.copies = append(c.copies, cp)}
func (c *Catalog) Copy(barcode string) (Copy, bool) { i, ok := c.index[barcode] if !ok { return Copy{}, false } return c.copies[i], true}
func (c *Catalog) CopiesOf(isbn string) []Copy { var out []Copy for _, cp := range c.copies { if cp.Book.ISBN == isbn { out = append(out, cp) } } return out}
func (c *Catalog) Search(text string) []Book { q := strings.ToLower(text) seen := map[string]bool{} var out []Book for _, cp := range c.copies { b := cp.Book if seen[b.ISBN] { continue } seen[b.ISBN] = true if strings.Contains(strings.ToLower(b.Title), q) || strings.Contains(strings.ToLower(b.Author), q) { out = append(out, b) } } return out}
// LendingPolicy holds 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 changes only this.type LendingPolicy interface { MaxLoans(m Member) int LoanDays(m Member) int}
// StandardPolicy is one library's numbers. Faculty borrow more books, for longer.type StandardPolicy struct{}
func (StandardPolicy) MaxLoans(m Member) int { if m.Type == Faculty { return 20 } return 5}
func (StandardPolicy) LoanDays(m Member) int { if m.Type == Faculty { return 90 } return 14}
// Result says why a request was refused. Refusals are normal at a library desk, so they are// values the kiosk shows, not errors.type Result int
const ( OK Result = iota NoSuchCopy NotAvailable NotOnLoan LimitReached HasOverdue)
// LoanDesk is who has what. It owns loans and applies the policy; the catalog owns the books.type LoanDesk struct { catalog *Catalog policy LendingPolicy clock func() time.Time // injected, so tests can move time forward loans map[string]*Loan // active, by barcode byMember map[string]map[string]bool // member id -> barcodes}
func NewLoanDesk(c *Catalog, p LendingPolicy, clock func() time.Time) *LoanDesk { return &LoanDesk{catalog: c, policy: p, clock: clock, loans: map[string]*Loan{}, byMember: map[string]map[string]bool{}}}
func (d *LoanDesk) Checkout(m Member, barcode string) Result { c, ok := d.catalog.Copy(barcode) if !ok { return NoSuchCopy } if d.loans[barcode] != nil { return NotAvailable } today := d.clock() mine := d.LoansOf(m) if len(mine) >= d.policy.MaxLoans(m) { return LimitReached } // An overdue book at home blocks new loans: the usual nudge to bring it back. for _, l := range mine { if l.DueOn.Before(today) { return HasOverdue } } d.lend(c, m, today) return OK}
func (d *LoanDesk) lend(c Copy, m Member, today time.Time) { d.loans[c.Barcode] = &Loan{Copy: c, Member: m, BorrowedOn: today, DueOn: today.AddDate(0, 0, d.policy.LoanDays(m))} if d.byMember[m.ID] == nil { d.byMember[m.ID] = map[string]bool{} } d.byMember[m.ID][c.Barcode] = true}
func (d *LoanDesk) ReturnCopy(barcode string) Result { l := d.loans[barcode] if l == nil { return NotOnLoan } delete(d.loans, barcode) delete(d.byMember[l.Member.ID], barcode) return OK}
// LoansOf uses the member index, so "what do I have out?" is a lookup, not a scan of every loan.func (d *LoanDesk) LoansOf(m Member) []*Loan { var out []*Loan for b := range d.byMember[m.ID] { out = append(out, d.loans[b]) } return out}// Different members get different limits. The policy reads this; nothing else does.type MemberType = 'student' | 'faculty';
// A library card holder.interface Member { readonly id: string; readonly name: string; readonly type: MemberType;}
// What the library owns, and nothing about who has it. Searches never touch loans.class Catalog { private readonly copies = new Map<string, Copy>(); // by barcode, in the order added
add(c: Copy): void { this.copies.set(c.barcode, c); }
copy(barcode: string): Copy | undefined { return this.copies.get(barcode); }
copiesOf(isbn: string): Copy[] { return [...this.copies.values()].filter((c) => c.book.isbn === isbn); }
search(text: string): Book[] { const q = text.toLowerCase(); const books = new Set([...this.copies.values()].map((c) => c.book)); return [...books].filter((b) => b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)); }}
// 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 { maxLoans(m: Member): number; loanDays(m: Member): number;}
class StandardPolicy implements LendingPolicy { maxLoans(m: Member): number { return m.type === 'faculty' ? 20 : 5; } loanDays(m: Member): number { return m.type === 'faculty' ? 90 : 14; }}
// Why a request was refused. Refusals are normal at a library desk, so they are values the// kiosk shows, not exceptions.type 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 readonly loans = new Map<string, Loan>(); // active, by barcode private readonly byMember = new Map<string, Set<string>>(); // member id -> barcodes
constructor( private readonly catalog: Catalog, private readonly policy: LendingPolicy, private readonly clock: () => Day, // injected, so tests can move time forward ) {}
checkout(m: Member, barcode: string): Result { const c = this.catalog.copy(barcode); if (!c) return 'no-such-copy'; if (this.loans.has(barcode)) return 'not-available'; const today = this.clock(); const mine = this.loansOf(m); if (mine.length >= this.policy.maxLoans(m)) return 'limit-reached'; // An overdue book at home blocks new loans: the usual nudge to bring it back. if (mine.some((l) => l.dueOn < today)) return 'has-overdue'; this.lend(c, m, today); return 'ok'; }
returnCopy(barcode: string): Result { const l = this.loans.get(barcode); if (!l) return 'not-on-loan'; this.loans.delete(barcode); this.byMember.get(l.member.id)!.delete(barcode); return 'ok'; }
// The member index makes "what do I have out?" a lookup instead of a scan of every loan. loansOf(m: Member): Loan[] { return [...(this.byMember.get(m.id) ?? [])].map((b) => this.loans.get(b)!); }
private lend(c: Copy, m: Member, today: Day): void { this.loans.set(c.barcode, { copy: c, member: m, borrowedOn: today, dueOn: today + this.policy.loanDays(m) }); if (!this.byMember.has(m.id)) this.byMember.set(m.id, new Set()); this.byMember.get(m.id)!.add(c.barcode); }}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.
Stage 3: Fines, settled at return
Section titled “Stage 3: Fines, settled at return”Late returns cost 25 cents a day, up to 20 dollars. Anyone owing more than 5 dollars can’t borrow. How do fines work?
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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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(); }}# 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.@dataclass(frozen=True)class Fine: member_id: str barcode: str cents: int assessed_on: date
# Who owes what. The desk adds fines on return; payments arrive at the counter.class FineLedger: def __init__(self): self._fines: dict[str, list[Fine]] = {} self._paid: dict[str, int] = {}
def add(self, f: Fine) -> None: self._fines.setdefault(f.member_id, []).append(f)
def owed(self, member_id: str) -> int: charged = sum(f.cents for f in self._fines.get(member_id, [])) return charged - self._paid.get(member_id, 0)
# Takes at most what is owed, and returns what it took, so an overpayment is never kept. def pay(self, member_id: str, cents: int) -> int: taken = min(cents, self.owed(member_id)) if taken > 0: self._paid[member_id] = self._paid.get(member_id, 0) + taken return taken
class LendingPolicy(Protocol): def max_loans(self, m: Member) -> int: ... def loan_days(self, m: Member) -> int: ... def fine_for(self, loan: Loan, days_late: int) -> int: ... # in cents def max_owed(self) -> int: ... # owing more than this blocks new loans
class StandardPolicy: def max_loans(self, m: Member) -> int: return 20 if m.type is MemberType.FACULTY else 5
def loan_days(self, m: Member) -> int: return 90 if m.type is MemberType.FACULTY else 14
# 25 cents a day, capped at 20.00 so a forgotten book never costs more than a new one. def fine_for(self, loan: Loan, days_late: int) -> int: return min(days_late * 25, 2000)
def max_owed(self) -> int: return 500
class Result(Enum): OK = auto() NO_SUCH_COPY = auto() NOT_AVAILABLE = auto() NOT_ON_LOAN = auto() LIMIT_REACHED = auto() HAS_OVERDUE = auto() FINES_OWED = auto()
class LoanDesk: def __init__(self, catalog: Catalog, policy: LendingPolicy, clock: Callable[[], date]): self._catalog = catalog self._policy = policy self._clock = clock self._fines = FineLedger() self._loans: dict[str, Loan] = {} self._by_member: dict[str, set[str]] = {}
def checkout(self, m: Member, barcode: str) -> Result: c = self._catalog.copy(barcode) if c is None: return Result.NO_SUCH_COPY if barcode in self._loans: return Result.NOT_AVAILABLE today = self._clock() mine = self.loans_of(m) if len(mine) >= self._policy.max_loans(m): return Result.LIMIT_REACHED if any(l.due_on < today for l in mine): return Result.HAS_OVERDUE if self._fines.owed(m.id) > self._policy.max_owed(): return Result.FINES_OWED self._loans[barcode] = Loan(c, m, today, today + timedelta(days=self._policy.loan_days(m))) self._by_member.setdefault(m.id, set()).add(barcode) return Result.OK
def return_copy(self, barcode: str) -> Result: l = self._loans.pop(barcode, None) if l is None: return Result.NOT_ON_LOAN self._by_member[l.member.id].discard(barcode) today = self._clock() # 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. days_late = (today - l.due_on).days if days_late > 0: self._fines.add(Fine(l.member.id, barcode, self._policy.fine_for(l, days_late), today)) return Result.OK
def owed(self, m: Member) -> int: return self._fines.owed(m.id)
def pay(self, m: Member, cents: int) -> int: return self._fines.pay(m.id, cents)
def loans_of(self, m: Member) -> list[Loan]: return [self._loans[b] for b in self._by_member.get(m.id, ())]// 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.public sealed record Fine(string MemberId, string Barcode, long Cents, DateOnly AssessedOn);
// Who owes what. The desk adds fines on return; payments arrive at the counter.public sealed class FineLedger{ private readonly Dictionary<string, List<Fine>> _fines = []; private readonly Dictionary<string, long> _paid = [];
public void Add(Fine f) { if (!_fines.TryGetValue(f.MemberId, out var list)) _fines[f.MemberId] = list = []; list.Add(f); }
public long Owed(string memberId) => _fines.GetValueOrDefault(memberId, []).Sum(f => f.Cents) - _paid.GetValueOrDefault(memberId);
// Takes at most what is owed, and returns what it took, so an overpayment is never kept. public long Pay(string memberId, long cents) { var taken = Math.Min(cents, Owed(memberId)); if (taken > 0) _paid[memberId] = _paid.GetValueOrDefault(memberId) + taken; return taken; }}
public interface ILendingPolicy{ int MaxLoans(Member m); int LoanDays(Member m); long FineFor(Loan loan, int daysLate); // in cents long MaxOwed(); // owing more than this blocks new loans}
public sealed class StandardPolicy : ILendingPolicy{ public int MaxLoans(Member m) => m.Type == MemberType.Faculty ? 20 : 5; public int LoanDays(Member m) => 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, int daysLate) => Math.Min(daysLate * 25L, 2000); public long MaxOwed() => 500;}
public enum Result { Ok, NoSuchCopy, NotAvailable, NotOnLoan, LimitReached, HasOverdue, FinesOwed }
public sealed class LoanDesk(Catalog catalog, ILendingPolicy policy, Func<DateOnly> clock){ private readonly FineLedger _fines = new(); private readonly Dictionary<string, Loan> _loans = []; private readonly Dictionary<string, HashSet<string>> _byMember = [];
public Result Checkout(Member m, string barcode) { var c = catalog.Copy(barcode); if (c is null) return Result.NoSuchCopy; if (_loans.ContainsKey(barcode)) return Result.NotAvailable; var today = clock(); var mine = LoansOf(m); if (mine.Count >= policy.MaxLoans(m)) return Result.LimitReached; if (mine.Any(l => l.DueOn < today)) return Result.HasOverdue; if (_fines.Owed(m.Id) > policy.MaxOwed()) return Result.FinesOwed; Lend(c, m, today); return Result.Ok; }
public Result ReturnCopy(string barcode) { if (!_loans.Remove(barcode, out var l)) return Result.NotOnLoan; _byMember[l.Member.Id].Remove(barcode); var today = clock(); // 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. var daysLate = today.DayNumber - l.DueOn.DayNumber; if (daysLate > 0) _fines.Add(new Fine(l.Member.Id, barcode, policy.FineFor(l, daysLate), today)); return Result.Ok; }
public long Owed(Member m) => _fines.Owed(m.Id);
public long Pay(Member m, long cents) => _fines.Pay(m.Id, cents);
public List<Loan> LoansOf(Member m) => _byMember.TryGetValue(m.Id, out var mine) ? mine.Select(b => _loans[b]).ToList() : [];
private void Lend(Copy c, Member m, DateOnly today) { _loans[c.Barcode] = new Loan(c, m, today, today.AddDays(policy.LoanDays(m))); if (!_byMember.TryGetValue(m.Id, out var mine)) _byMember[m.Id] = mine = []; mine.Add(c.Barcode); }}// Fine is 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.type Fine struct { MemberID, Barcode string Cents int64 AssessedOn time.Time}
// FineLedger is who owes what. The desk adds fines on return; payments arrive at the counter.type FineLedger struct { fines map[string][]Fine paid map[string]int64}
func NewFineLedger() *FineLedger { return &FineLedger{fines: map[string][]Fine{}, paid: map[string]int64{}}}
func (f *FineLedger) Add(fine Fine) { f.fines[fine.MemberID] = append(f.fines[fine.MemberID], fine) }
func (f *FineLedger) Owed(memberID string) int64 { var charged int64 for _, x := range f.fines[memberID] { charged += x.Cents } return charged - f.paid[memberID]}
// Pay takes at most what is owed and returns what it took, so an overpayment is never kept.func (f *FineLedger) Pay(memberID string, cents int64) int64 { taken := min(cents, f.Owed(memberID)) if taken > 0 { f.paid[memberID] += taken } return taken}
type LendingPolicy interface { MaxLoans(m Member) int LoanDays(m Member) int FineFor(l *Loan, daysLate int) int64 // in cents MaxOwed() int64 // owing more than this blocks new loans}
// FineFor charges 25 cents a day, capped at 20.00 so a forgotten book never costs more than a// new one.func (StandardPolicy) FineFor(l *Loan, daysLate int) int64 { return min(int64(daysLate)*25, 2000) }
func (StandardPolicy) MaxOwed() int64 { return 500 }
const ( OK Result = iota NoSuchCopy NotAvailable NotOnLoan LimitReached HasOverdue FinesOwed)
type LoanDesk struct { catalog *Catalog policy LendingPolicy clock func() time.Time fines *FineLedger loans map[string]*Loan byMember map[string]map[string]bool}
func NewLoanDesk(c *Catalog, p LendingPolicy, clock func() time.Time) *LoanDesk { return &LoanDesk{catalog: c, policy: p, clock: clock, fines: NewFineLedger(), loans: map[string]*Loan{}, byMember: map[string]map[string]bool{}}}
func (d *LoanDesk) Checkout(m Member, barcode string) Result { c, ok := d.catalog.Copy(barcode) if !ok { return NoSuchCopy } if d.loans[barcode] != nil { return NotAvailable } today := d.clock() mine := d.LoansOf(m) if len(mine) >= d.policy.MaxLoans(m) { return LimitReached } for _, l := range mine { if l.DueOn.Before(today) { return HasOverdue } } if d.fines.Owed(m.ID) > d.policy.MaxOwed() { return FinesOwed } d.lend(c, m, today) return OK}
func (d *LoanDesk) ReturnCopy(barcode string) Result { l := d.loans[barcode] if l == nil { return NotOnLoan } delete(d.loans, barcode) delete(d.byMember[l.Member.ID], barcode) today := d.clock() // 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. if daysLate := int(today.Sub(l.DueOn).Hours() / 24); daysLate > 0 { d.fines.Add(Fine{l.Member.ID, barcode, d.policy.FineFor(l, daysLate), today}) } return OK}
func (d *LoanDesk) lend(c Copy, m Member, today time.Time) { d.loans[c.Barcode] = &Loan{Copy: c, Member: m, BorrowedOn: today, DueOn: today.AddDate(0, 0, d.policy.LoanDays(m))} if d.byMember[m.ID] == nil { d.byMember[m.ID] = map[string]bool{} } d.byMember[m.ID][c.Barcode] = true}
func (d *LoanDesk) LoansOf(m Member) []*Loan { var out []*Loan for b := range d.byMember[m.ID] { out = append(out, d.loans[b]) } return out}
func (d *LoanDesk) Owed(m Member) int64 { return d.fines.Owed(m.ID) }
func (d *LoanDesk) Pay(m Member, cents int64) int64 { return d.fines.Pay(m.ID, cents) }// 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.interface Fine { readonly memberId: string; readonly barcode: string; readonly cents: number; readonly assessedOn: Day;}
// Who owes what. The desk adds fines on return; payments arrive at the counter.class FineLedger { private readonly fines = new Map<string, Fine[]>(); private readonly paid = new Map<string, number>();
add(f: Fine): void { this.fines.set(f.memberId, [...(this.fines.get(f.memberId) ?? []), f]); }
owed(memberId: string): number { const charged = (this.fines.get(memberId) ?? []).reduce((sum, f) => sum + f.cents, 0); return charged - (this.paid.get(memberId) ?? 0); }
// Takes at most what is owed, and returns what it took, so an overpayment is never kept. pay(memberId: string, cents: number): number { const taken = Math.min(cents, this.owed(memberId)); if (taken > 0) this.paid.set(memberId, (this.paid.get(memberId) ?? 0) + taken); return taken; }}
interface LendingPolicy { maxLoans(m: Member): number; loanDays(m: Member): number; fineFor(loan: Loan, daysLate: number): number; // in cents maxOwed(): number; // owing more than this blocks new loans}
class StandardPolicy implements LendingPolicy { maxLoans(m: Member): number { return m.type === 'faculty' ? 20 : 5; } loanDays(m: Member): number { return m.type === 'faculty' ? 90 : 14; } // 25 cents a day, capped at 20.00 so a forgotten book never costs more than a new one. fineFor(_loan: Loan, daysLate: number): number { return Math.min(daysLate * 25, 2000); } maxOwed(): number { return 500; }}
type Result = 'ok' | 'no-such-copy' | 'not-available' | 'not-on-loan' | 'limit-reached' | 'has-overdue' | 'fines-owed';
class LoanDesk { private readonly fines = new FineLedger(); private readonly loans = new Map<string, Loan>(); private readonly byMember = new Map<string, Set<string>>();
constructor( private readonly catalog: Catalog, private readonly policy: LendingPolicy, private readonly clock: () => Day, ) {}
checkout(m: Member, barcode: string): Result { const c = this.catalog.copy(barcode); if (!c) return 'no-such-copy'; if (this.loans.has(barcode)) return 'not-available'; const today = this.clock(); const mine = this.loansOf(m); if (mine.length >= this.policy.maxLoans(m)) return 'limit-reached'; if (mine.some((l) => l.dueOn < today)) return 'has-overdue'; if (this.fines.owed(m.id) > this.policy.maxOwed()) return 'fines-owed'; this.lend(c, m, today); return 'ok'; }
returnCopy(barcode: string): Result { const l = this.loans.get(barcode); if (!l) return 'not-on-loan'; this.loans.delete(barcode); this.byMember.get(l.member.id)!.delete(barcode); const today = this.clock(); // 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. const daysLate = today - l.dueOn; if (daysLate > 0) { this.fines.add({ memberId: l.member.id, barcode, cents: this.policy.fineFor(l, daysLate), assessedOn: today }); } return 'ok'; }
owed(m: Member): number { return this.fines.owed(m.id); }
pay(m: Member, cents: number): number { return this.fines.pay(m.id, cents); }
loansOf(m: Member): Loan[] { return [...(this.byMember.get(m.id) ?? [])].map((b) => this.loans.get(b)!); }
private lend(c: Copy, m: Member, today: Day): void { this.loans.set(c.barcode, { copy: c, member: m, borrowedOn: today, dueOn: today + this.policy.loanDays(m) }); if (!this.byMember.has(m.id)) this.byMember.set(m.id, new Set()); this.byMember.get(m.id)!.add(c.barcode); }}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.
Stage 4: Holds and renewals
Section titled “Stage 4: Holds and renewals”Both copies of Dune are out and Cy wants it. What happens when one comes back?
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.routegives 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) callsroutetoo. - 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.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// 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(); }}from collections import deque
# One borrowing: which copy, who has it, and until when. Not frozen, because renewing changes# the due date later on.@dataclassclass Loan: copy: Copy member: Member borrowed_on: date due_on: date renewals: int = 0 # how many times the due date has moved; the policy caps it
# A member waiting for a title. Holds are on books, not copies: the reader wants whichever# copy comes back first.@dataclass(frozen=True)class Hold: member: Member isbn: str placed_on: date
# 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.@dataclass(frozen=True)class Pickup: member: Member until: date
# The waiting lists and the hold shelf, kept apart from loans so each stays simple.class Holds: def __init__(self): self._waiting: dict[str, deque[Hold]] = {} # isbn -> first come, first served self._shelf: dict[str, Pickup] = {} # barcode -> who it's for
def add(self, h: Hold) -> None: self._waiting.setdefault(h.isbn, deque()).append(h)
def any_waiting(self, isbn: str) -> bool: return bool(self._waiting.get(isbn))
def is_waiting(self, m: Member, isbn: str) -> bool: return any(h.member.id == m.id for h in self._waiting.get(isbn, ()))
# Removes and returns the first member waiting for this title, or None. def next(self, isbn: str) -> Hold | None: q = self._waiting.get(isbn) return q.popleft() if q else None
def set_aside(self, barcode: str, p: Pickup) -> None: self._shelf[barcode] = p
def pickup_for(self, barcode: str) -> Pickup | None: return self._shelf.get(barcode)
def clear(self, barcode: str) -> None: self._shelf.pop(barcode, None)
# A pickup is good through its last day, so it expires once today is after it. def expired(self, today: date) -> list[str]: return sorted(b for b, p in self._shelf.items() if p.until < today)
# Email, text or app push. The desk says what happened; how the member hears is not its job.class Notifier(Protocol): def hold_ready(self, m: Member, b: Book, until: date) -> None: ...
# 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.class LendingPolicy(Protocol): def max_loans(self, m: Member) -> int: ... def loan_days(self, m: Member) -> int: ... def fine_for(self, loan: Loan, days_late: int) -> int: ... def max_owed(self) -> int: ... def max_renewals(self) -> int: ... def hold_days(self) -> int: ... # 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: def max_loans(self, m: Member) -> int: return 20 if m.type is MemberType.FACULTY else 5
def loan_days(self, m: Member) -> int: return 90 if m.type is MemberType.FACULTY else 14
def fine_for(self, loan: Loan, days_late: int) -> int: return min(days_late * 25, 2000)
def max_owed(self) -> int: return 500
def max_renewals(self) -> int: return 2
def hold_days(self) -> int: return 3
# What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader# what to do next.class Result(Enum): OK = auto() NO_SUCH_COPY = auto() NOT_AVAILABLE = auto() NOT_ON_LOAN = auto() LIMIT_REACHED = auto() HAS_OVERDUE = auto() FINES_OWED = auto() HELD_FOR_SOMEONE_ELSE = auto() COPY_AVAILABLE = auto() ALREADY_WAITING = auto() NOT_YOUR_LOAN = auto() RENEWAL_LIMIT = auto() HOLD_WAITING = auto()
# 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: def __init__(self, catalog: Catalog, policy: LendingPolicy, notifier: Notifier, clock: Callable[[], date]): self._catalog = catalog self._policy = policy self._notifier = notifier self._clock = clock self._fines = FineLedger() self._holds = Holds() self._loans: dict[str, Loan] = {} # barcode -> its loan self._by_member: dict[str, set[str]] = {} # member id -> barcodes, for a fast "what do I have?"
# Every reason to refuse is checked before anything changes, so a refused checkout leaves no # trace. def checkout(self, m: Member, barcode: str) -> Result: c = self._catalog.copy(barcode) if c is None: return Result.NO_SUCH_COPY if barcode in self._loans: return Result.NOT_AVAILABLE # A copy on the hold shelf can only go to the member it was set aside for. p = self._holds.pickup_for(barcode) if p is not None and p.member.id != m.id: return Result.HELD_FOR_SOMEONE_ELSE today = self._clock() mine = self.loans_of(m) if len(mine) >= self._policy.max_loans(m): return Result.LIMIT_REACHED if any(l.due_on < today for l in mine): return Result.HAS_OVERDUE if self._fines.owed(m.id) > self._policy.max_owed(): return Result.FINES_OWED if p is not None: self._holds.clear(barcode) self._loans[barcode] = Loan(c, m, today, today + timedelta(days=self._policy.loan_days(m))) self._by_member.setdefault(m.id, set()).add(barcode) return Result.OK
# A late return adds a fine. Then the copy goes to the next reader waiting, if there is one. def return_copy(self, barcode: str) -> Result: l = self._loans.pop(barcode, None) if l is None: return Result.NOT_ON_LOAN self._by_member[l.member.id].discard(barcode) today = self._clock() days_late = (today - l.due_on).days if days_late > 0: self._fines.add(Fine(l.member.id, barcode, self._policy.fine_for(l, days_late), today)) self._route(l.copy, today) return Result.OK
def place_hold(self, m: Member, isbn: str) -> Result: if self._holds.is_waiting(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. on_shelf = any( c.barcode not in self._loans and self._holds.pickup_for(c.barcode) is None for c in self._catalog.copies_of(isbn) ) if on_shelf: return Result.COPY_AVAILABLE self._holds.add(Hold(m, isbn, self._clock())) return Result.OK
def renew(self, m: Member, barcode: str) -> Result: l = self._loans.get(barcode) if l is None or l.member.id != m.id: return Result.NOT_YOUR_LOAN today = self._clock() if l.due_on < today: return Result.HAS_OVERDUE if l.renewals >= self._policy.max_renewals(): return Result.RENEWAL_LIMIT # Someone waiting for this title outranks a second turn for the current reader. if self._holds.any_waiting(l.copy.book.isbn): return Result.HOLD_WAITING l.due_on = today + timedelta(days=self._policy.loan_days(m)) l.renewals += 1 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. def expire_holds(self) -> int: today = self._clock() gone = self._holds.expired(today) for barcode in gone: self._holds.clear(barcode) self._route(self._catalog.copy(barcode), today) return len(gone)
# 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. def _route(self, c: Copy, today: date) -> None: nxt = self._holds.next(c.book.isbn) if nxt is None: return until = today + timedelta(days=self._policy.hold_days()) self._holds.set_aside(c.barcode, Pickup(nxt.member, until)) self._notifier.hold_ready(nxt.member, c.book, until)
def owed(self, m: Member) -> int: return self._fines.owed(m.id)
def pay(self, m: Member, cents: int) -> int: return self._fines.pay(m.id, cents)
def loans_of(self, m: Member) -> list[Loan]: return [self._loans[b] for b in self._by_member.get(m.id, ())]// One borrowing: which copy, who has it, and until when. Renewing changes the due date later on.public sealed class Loan(Copy copy, Member member, DateOnly borrowedOn, DateOnly dueOn){ public Copy Copy { get; } = copy; public Member Member { get; } = member; public DateOnly BorrowedOn { get; } = borrowedOn; public DateOnly DueOn { get; set; } = dueOn; public int Renewals { get; set; } // how many times the due date has moved; the policy caps it}
// A member waiting for a title. Holds are on books, not copies: the reader wants whichever// copy comes back first.public sealed record Hold(Member Member, string Isbn, DateOnly 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.public sealed record Pickup(Member Member, DateOnly Until);
// The waiting lists and the hold shelf, kept apart from loans so each stays simple.public sealed class Holds{ private readonly Dictionary<string, Queue<Hold>> _waiting = []; // isbn -> first come, first served private readonly Dictionary<string, Pickup> _shelf = []; // barcode -> who it's for
public void Add(Hold h) { if (!_waiting.TryGetValue(h.Isbn, out var q)) _waiting[h.Isbn] = q = new Queue<Hold>(); q.Enqueue(h); }
public bool AnyWaiting(string isbn) => _waiting.TryGetValue(isbn, out var q) && q.Count > 0;
public bool IsWaiting(Member m, string isbn) => _waiting.TryGetValue(isbn, out var q) && q.Any(h => h.Member.Id == m.Id);
// Removes and returns the first member waiting for this title, or null. public Hold? Next(string isbn) => _waiting.TryGetValue(isbn, out var q) && q.TryDequeue(out var h) ? h : null;
public void SetAside(string barcode, Pickup p) => _shelf[barcode] = p;
public Pickup? PickupFor(string barcode) => _shelf.GetValueOrDefault(barcode);
public void Clear(string barcode) => _shelf.Remove(barcode);
// A pickup is good through its last day, so it expires once today is after it. public List<string> Expired(DateOnly today) => _shelf.Where(e => e.Value.Until < today).Select(e => e.Key).Order().ToList();}
// Email, text or app push. The desk says what happened; how the member hears is not its job.public interface INotifier{ void HoldReady(Member m, Book b, DateOnly 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.public interface ILendingPolicy{ int MaxLoans(Member m); int LoanDays(Member m); long FineFor(Loan loan, int 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.public sealed class StandardPolicy : ILendingPolicy{ public int MaxLoans(Member m) => m.Type == MemberType.Faculty ? 20 : 5; public int LoanDays(Member m) => m.Type == MemberType.Faculty ? 90 : 14; public long FineFor(Loan loan, int daysLate) => Math.Min(daysLate * 25L, 2000); public long MaxOwed() => 500; public int MaxRenewals() => 2; public int HoldDays() => 3;}
// What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader// what to do next.public enum Result{ Ok, NoSuchCopy, NotAvailable, NotOnLoan, LimitReached, HasOverdue, FinesOwed, HeldForSomeoneElse, CopyAvailable, AlreadyWaiting, NotYourLoan, RenewalLimit, HoldWaiting,}
// Where every loan, return, hold and renewal happens. It owns who has what; the catalog owns// what exists, and the policy owns the numbers.public sealed class LoanDesk(Catalog catalog, ILendingPolicy policy, INotifier notifier, Func<DateOnly> clock){ private readonly FineLedger _fines = new(); private readonly Holds _holds = new(); private readonly Dictionary<string, Loan> _loans = []; // barcode -> its loan private readonly Dictionary<string, HashSet<string>> _byMember = []; // member id -> barcodes, for a fast "what do I have?"
// Every reason to refuse is checked before anything changes, so a refused checkout leaves // no trace. public Result Checkout(Member m, string barcode) { var c = catalog.Copy(barcode); if (c is null) return Result.NoSuchCopy; if (_loans.ContainsKey(barcode)) return Result.NotAvailable; // A copy on the hold shelf can only go to the member it was set aside for. var p = _holds.PickupFor(barcode); if (p is not null && p.Member.Id != m.Id) return Result.HeldForSomeoneElse; var today = clock(); var mine = LoansOf(m); if (mine.Count >= policy.MaxLoans(m)) return Result.LimitReached; if (mine.Any(l => l.DueOn < today)) return Result.HasOverdue; if (_fines.Owed(m.Id) > policy.MaxOwed()) return Result.FinesOwed; if (p is not null) _holds.Clear(barcode); Lend(c, m, today); return Result.Ok; }
// A late return adds a fine. Then the copy goes to the next reader waiting, if there is one. public Result ReturnCopy(string barcode) { if (!_loans.Remove(barcode, out var l)) return Result.NotOnLoan; _byMember[l.Member.Id].Remove(barcode); var today = clock(); var daysLate = today.DayNumber - l.DueOn.DayNumber; if (daysLate > 0) _fines.Add(new Fine(l.Member.Id, barcode, policy.FineFor(l, daysLate), today)); Route(l.Copy, today); return Result.Ok; }
public Result PlaceHold(Member m, string isbn) { if (_holds.IsWaiting(m, isbn)) return Result.AlreadyWaiting; // 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. var onShelf = catalog.CopiesOf(isbn) .Any(c => !_loans.ContainsKey(c.Barcode) && _holds.PickupFor(c.Barcode) is null); if (onShelf) return Result.CopyAvailable; _holds.Add(new Hold(m, isbn, clock())); return Result.Ok; }
public Result Renew(Member m, string barcode) { if (!_loans.TryGetValue(barcode, out var l) || l.Member.Id != m.Id) return Result.NotYourLoan; var today = clock(); if (l.DueOn < today) return Result.HasOverdue; if (l.Renewals >= policy.MaxRenewals()) return Result.RenewalLimit; // Someone waiting for this title outranks a second turn for the current reader. if (_holds.AnyWaiting(l.Copy.Book.Isbn)) return Result.HoldWaiting; l.DueOn = today.AddDays(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. public int ExpireHolds() { var today = clock(); var gone = _holds.Expired(today); foreach (var barcode in gone) { _holds.Clear(barcode); Route(catalog.Copy(barcode)!, today); } return gone.Count; }
public long Owed(Member m) => _fines.Owed(m.Id);
public long Pay(Member m, long cents) => _fines.Pay(m.Id, cents);
public List<Loan> LoansOf(Member m) => _byMember.TryGetValue(m.Id, out var mine) ? mine.Select(b => _loans[b]).ToList() : [];
// 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, DateOnly today) { var next = _holds.Next(c.Book.Isbn); if (next is null) return; var until = today.AddDays(policy.HoldDays()); _holds.SetAside(c.Barcode, new Pickup(next.Member, until)); notifier.HoldReady(next.Member, c.Book, until); }
private void Lend(Copy c, Member m, DateOnly today) { _loans[c.Barcode] = new Loan(c, m, today, today.AddDays(policy.LoanDays(m))); if (!_byMember.TryGetValue(m.Id, out var mine)) _byMember[m.Id] = mine = []; mine.Add(c.Barcode); }}// Loan is one borrowing: which copy, who has it, and until when. Renewing moves DueOn.type Loan struct { Copy Copy Member Member BorrowedOn time.Time DueOn time.Time Renewals int // how many times DueOn has moved; the policy caps it}
// Hold is a member waiting for a title. Holds are on books, not copies: the reader wants// whichever copy comes back first.type Hold struct { Member Member ISBN string PlacedOn time.Time}
// Pickup is 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.type Pickup struct { Member Member Until time.Time}
// Holds keeps the waiting lists and the hold shelf, apart from loans so each stays simple.type Holds struct { waiting map[string][]Hold // isbn -> first come, first served shelf map[string]Pickup // barcode -> who it's for}
func NewHolds() *Holds { return &Holds{waiting: map[string][]Hold{}, shelf: map[string]Pickup{}} }
func (h *Holds) Add(x Hold) { h.waiting[x.ISBN] = append(h.waiting[x.ISBN], x) }
func (h *Holds) AnyWaiting(isbn string) bool { return len(h.waiting[isbn]) > 0 }
func (h *Holds) IsWaiting(m Member, isbn string) bool { return slices.ContainsFunc(h.waiting[isbn], func(x Hold) bool { return x.Member.ID == m.ID })}
// Next removes and returns the first member waiting for this title.func (h *Holds) Next(isbn string) (Hold, bool) { q := h.waiting[isbn] if len(q) == 0 { return Hold{}, false } h.waiting[isbn] = q[1:] return q[0], true}
func (h *Holds) SetAside(barcode string, p Pickup) { h.shelf[barcode] = p }
func (h *Holds) PickupFor(barcode string) (Pickup, bool) { p, ok := h.shelf[barcode] return p, ok}
func (h *Holds) Clear(barcode string) { delete(h.shelf, barcode) }
// Expired lists pickups past their last day. A pickup is good through its Until date.func (h *Holds) Expired(today time.Time) []string { var out []string for b, p := range h.shelf { if p.Until.Before(today) { out = append(out, b) } } slices.Sort(out) return out}
// Notifier is email, text or app push. The desk says what happened; how the member hears is// not its job.type Notifier interface { HoldReady(m Member, b Book, until time.Time)}
// LendingPolicy holds 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 changes only this.type LendingPolicy interface { MaxLoans(m Member) int LoanDays(m Member) int FineFor(l *Loan, daysLate int) int64 MaxOwed() int64 MaxRenewals() int HoldDays() int // how long a set-aside copy waits to be collected}
func (StandardPolicy) MaxRenewals() int { return 2 }
func (StandardPolicy) HoldDays() int { return 3 }
const ( OK Result = iota NoSuchCopy NotAvailable NotOnLoan LimitReached HasOverdue FinesOwed HeldForSomeoneElse CopyAvailable AlreadyWaiting NotYourLoan RenewalLimit HoldWaiting)
// LoanDesk is where every loan, return, hold and renewal happens. It owns who has what; the// catalog owns what exists, and the policy owns the numbers.type LoanDesk struct { catalog *Catalog policy LendingPolicy notifier Notifier clock func() time.Time fines *FineLedger holds *Holds loans map[string]*Loan // barcode -> its loan byMember map[string]map[string]bool // member id -> barcodes, for a fast "what do I have?"}
func NewLoanDesk(c *Catalog, p LendingPolicy, n Notifier, clock func() time.Time) *LoanDesk { return &LoanDesk{catalog: c, policy: p, notifier: n, clock: clock, fines: NewFineLedger(), holds: NewHolds(), loans: map[string]*Loan{}, byMember: map[string]map[string]bool{}}}
// Checkout checks every reason to refuse before anything changes, so a refused checkout leaves// no trace.func (d *LoanDesk) Checkout(m Member, barcode string) Result { c, ok := d.catalog.Copy(barcode) if !ok { return NoSuchCopy } if d.loans[barcode] != nil { return NotAvailable } // A copy on the hold shelf can only go to the member it was set aside for. p, held := d.holds.PickupFor(barcode) if held && p.Member.ID != m.ID { return HeldForSomeoneElse } today := d.clock() mine := d.LoansOf(m) if len(mine) >= d.policy.MaxLoans(m) { return LimitReached } for _, l := range mine { if l.DueOn.Before(today) { return HasOverdue } } if d.fines.Owed(m.ID) > d.policy.MaxOwed() { return FinesOwed } if held { d.holds.Clear(barcode) } d.lend(c, m, today) return OK}
// ReturnCopy adds a fine for a late return. Then the copy goes to the next reader waiting, if// there is one.func (d *LoanDesk) ReturnCopy(barcode string) Result { l := d.loans[barcode] if l == nil { return NotOnLoan } delete(d.loans, barcode) delete(d.byMember[l.Member.ID], barcode) today := d.clock() if daysLate := int(today.Sub(l.DueOn).Hours() / 24); daysLate > 0 { d.fines.Add(Fine{l.Member.ID, barcode, d.policy.FineFor(l, daysLate), today}) } d.route(l.Copy, today) return OK}
func (d *LoanDesk) PlaceHold(m Member, isbn string) Result { if d.holds.IsWaiting(m, isbn) { return AlreadyWaiting } // 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. for _, c := range d.catalog.CopiesOf(isbn) { _, held := d.holds.PickupFor(c.Barcode) if d.loans[c.Barcode] == nil && !held { return CopyAvailable } } d.holds.Add(Hold{m, isbn, d.clock()}) return OK}
func (d *LoanDesk) Renew(m Member, barcode string) Result { l := d.loans[barcode] if l == nil || l.Member.ID != m.ID { return NotYourLoan } today := d.clock() if l.DueOn.Before(today) { return HasOverdue } if l.Renewals >= d.policy.MaxRenewals() { return RenewalLimit } // Someone waiting for this title outranks a second turn for the current reader. if d.holds.AnyWaiting(l.Copy.Book.ISBN) { return HoldWaiting } l.DueOn = today.AddDate(0, 0, d.policy.LoanDays(m)) l.Renewals++ return OK}
// ExpireHolds runs once a day. A copy nobody collected moves on to the next person waiting,// or back to the open shelf. It returns how many pickups expired.func (d *LoanDesk) ExpireHolds() int { today := d.clock() gone := d.holds.Expired(today) for _, barcode := range gone { d.holds.Clear(barcode) c, _ := d.catalog.Copy(barcode) d.route(c, today) } return len(gone)}
// route is where every copy that comes free goes: to the first member waiting for its// title, or back to the open shelf when nobody is.func (d *LoanDesk) route(c Copy, today time.Time) { next, ok := d.holds.Next(c.Book.ISBN) if !ok { return } until := today.AddDate(0, 0, d.policy.HoldDays()) d.holds.SetAside(c.Barcode, Pickup{next.Member, until}) d.notifier.HoldReady(next.Member, c.Book, until)}
func (d *LoanDesk) lend(c Copy, m Member, today time.Time) { d.loans[c.Barcode] = &Loan{Copy: c, Member: m, BorrowedOn: today, DueOn: today.AddDate(0, 0, d.policy.LoanDays(m))} if d.byMember[m.ID] == nil { d.byMember[m.ID] = map[string]bool{} } d.byMember[m.ID][c.Barcode] = true}
func (d *LoanDesk) LoansOf(m Member) []*Loan { var out []*Loan for b := range d.byMember[m.ID] { out = append(out, d.loans[b]) } return out}
func (d *LoanDesk) Owed(m Member) int64 { return d.fines.Owed(m.ID) }
func (d *LoanDesk) Pay(m Member, cents int64) int64 { return d.fines.Pay(m.ID, cents) }// One borrowing: which copy, who has it, and until when. Renewing changes the due date later on.interface Loan { readonly copy: Copy; readonly member: Member; readonly borrowedOn: Day; dueOn: Day; renewals: number; // how many times the due date has moved; the policy caps it}
// A member waiting for a title. Holds are on books, not copies: the reader wants whichever// copy comes back first.interface Hold { readonly member: Member; readonly isbn: string; readonly placedOn: Day;}
// A copy set aside on the hold shelf for one member until a day. Once set aside it's this// copy, so the shelf is keyed by barcode while the queue is keyed by ISBN.interface Pickup { readonly member: Member; readonly until: Day;}
// The waiting lists and the hold shelf, kept apart from loans so each stays simple.class Holds { private readonly waiting = new Map<string, Hold[]>(); // isbn -> first come, first served private readonly shelf = new Map<string, Pickup>(); // barcode -> who it's for
add(h: Hold): void { this.waiting.set(h.isbn, [...(this.waiting.get(h.isbn) ?? []), h]); }
anyWaiting(isbn: string): boolean { return (this.waiting.get(isbn) ?? []).length > 0; }
isWaiting(m: Member, isbn: string): boolean { return (this.waiting.get(isbn) ?? []).some((h) => h.member.id === m.id); }
// Removes and returns the first member waiting for this title, or undefined. next(isbn: string): Hold | undefined { return this.waiting.get(isbn)?.shift(); }
setAside(barcode: string, p: Pickup): void { this.shelf.set(barcode, p); }
pickupFor(barcode: string): Pickup | undefined { return this.shelf.get(barcode); }
clear(barcode: string): void { this.shelf.delete(barcode); }
// A pickup is good through its last day, so it expires once today is after it. expired(today: Day): string[] { return [...this.shelf].filter(([, p]) => p.until < today).map(([b]) => b).sort(); }}
// Email, text or app push. The desk says what happened; how the member hears is not its job.interface Notifier { holdReady(m: Member, b: Book, until: Day): void;}
// 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 { maxLoans(m: Member): number; loanDays(m: Member): number; fineFor(loan: Loan, daysLate: number): number; maxOwed(): number; maxRenewals(): number; holdDays(): number; // 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 { maxLoans(m: Member): number { return m.type === 'faculty' ? 20 : 5; } loanDays(m: Member): number { return m.type === 'faculty' ? 90 : 14; } fineFor(_loan: Loan, daysLate: number): number { return Math.min(daysLate * 25, 2000); } maxOwed(): number { return 500; } maxRenewals(): number { return 2; } holdDays(): number { return 3; }}
// What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader// what to do next.type 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 readonly fines = new FineLedger(); private readonly holds = new Holds(); private readonly loans = new Map<string, Loan>(); // barcode -> its loan private readonly byMember = new Map<string, Set<string>>(); // member id -> barcodes, for a fast "what do I have?"
constructor( private readonly catalog: Catalog, private readonly policy: LendingPolicy, private readonly notifier: Notifier, private readonly clock: () => Day, ) {}
// Every reason to refuse is checked before anything changes, so a refused checkout leaves no // trace. checkout(m: Member, barcode: string): Result { const c = this.catalog.copy(barcode); if (!c) return 'no-such-copy'; if (this.loans.has(barcode)) return 'not-available'; // A copy on the hold shelf can only go to the member it was set aside for. const p = this.holds.pickupFor(barcode); if (p && p.member.id !== m.id) return 'held-for-someone-else'; const today = this.clock(); const mine = this.loansOf(m); if (mine.length >= this.policy.maxLoans(m)) return 'limit-reached'; if (mine.some((l) => l.dueOn < today)) return 'has-overdue'; if (this.fines.owed(m.id) > this.policy.maxOwed()) return 'fines-owed'; if (p) this.holds.clear(barcode); this.lend(c, m, today); return 'ok'; }
// A late return adds a fine. Then the copy goes to the next reader waiting, if there is one. returnCopy(barcode: string): Result { const l = this.loans.get(barcode); if (!l) return 'not-on-loan'; this.loans.delete(barcode); this.byMember.get(l.member.id)!.delete(barcode); const today = this.clock(); const daysLate = today - l.dueOn; if (daysLate > 0) { this.fines.add({ memberId: l.member.id, barcode, cents: this.policy.fineFor(l, daysLate), assessedOn: today }); } this.route(l.copy, today); return 'ok'; }
placeHold(m: Member, isbn: string): Result { if (this.holds.isWaiting(m, isbn)) return '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. const onShelf = this.catalog .copiesOf(isbn) .some((c) => !this.loans.has(c.barcode) && !this.holds.pickupFor(c.barcode)); if (onShelf) return 'copy-available'; this.holds.add({ member: m, isbn, placedOn: this.clock() }); return 'ok'; }
renew(m: Member, barcode: string): Result { const l = this.loans.get(barcode); if (!l || l.member.id !== m.id) return 'not-your-loan'; const today = this.clock(); if (l.dueOn < today) return 'has-overdue'; if (l.renewals >= this.policy.maxRenewals()) return 'renewal-limit'; // Someone waiting for this title outranks a second turn for the current reader. if (this.holds.anyWaiting(l.copy.book.isbn)) return 'hold-waiting'; l.dueOn = today + this.policy.loanDays(m); l.renewals++; return '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. expireHolds(): number { const today = this.clock(); const gone = this.holds.expired(today); for (const barcode of gone) { this.holds.clear(barcode); this.route(this.catalog.copy(barcode)!, today); } return gone.length; }
owed(m: Member): number { return this.fines.owed(m.id); }
pay(m: Member, cents: number): number { return this.fines.pay(m.id, cents); }
loansOf(m: Member): Loan[] { return [...(this.byMember.get(m.id) ?? [])].map((b) => this.loans.get(b)!); }
// 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 route(c: Copy, today: Day): void { const next = this.holds.next(c.book.isbn); if (!next) return; const until = today + this.policy.holdDays(); this.holds.setAside(c.barcode, { member: next.member, until }); this.notifier.holdReady(next.member, c.book, until); }
private lend(c: Copy, m: Member, today: Day): void { this.loans.set(c.barcode, { copy: c, member: m, borrowedOn: today, dueOn: today + this.policy.loanDays(m), renewals: 0, }); if (!this.byMember.has(m.id)) this.byMember.set(m.id, new Set()); this.byMember.get(m.id)!.add(c.barcode); }}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.
- 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.
- 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.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it. SharedDesk
comes from the next section.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// 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(); } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom collections import dequefrom dataclasses import dataclassfrom datetime import date, timedeltafrom enum import Enum, autofrom typing import Callable, Protocol
# A title: what a reader searches for. The library may own many copies of one book.@dataclass(frozen=True)class Book: isbn: str title: str author: str
# 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.@dataclass(frozen=True)class Copy: barcode: str book: Book
# A library card holder.@dataclass(frozen=True)class Member: id: str name: str type: MemberType
# One borrowing: which copy, who has it, and until when. Not frozen, because renewing changes# the due date later on.@dataclassclass Loan: copy: Copy member: Member borrowed_on: date due_on: date renewals: int = 0 # how many times the due date has moved; the policy caps it
# Different members get different limits. The policy reads this; nothing else does.class MemberType(Enum): STUDENT = auto() FACULTY = auto()
# What the library owns, and nothing about who has it. Searches never touch loans.class Catalog: def __init__(self): self._copies: dict[str, Copy] = {} # by barcode, in the order added
def add(self, c: Copy) -> None: self._copies[c.barcode] = c
def copy(self, barcode: str) -> Copy | None: return self._copies.get(barcode)
def copies_of(self, isbn: str) -> list[Copy]: return [c for c in self._copies.values() if c.book.isbn == isbn]
def search(self, text: str) -> list[Book]: q = text.lower() books = dict.fromkeys(c.book for c in self._copies.values()) return [b for b in books if q in b.title.lower() or q in b.author.lower()]
# 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.class LendingPolicy(Protocol): def max_loans(self, m: Member) -> int: ... def loan_days(self, m: Member) -> int: ... def fine_for(self, loan: Loan, days_late: int) -> int: ... def max_owed(self) -> int: ... def max_renewals(self) -> int: ... def hold_days(self) -> int: ... # 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: def max_loans(self, m: Member) -> int: return 20 if m.type is MemberType.FACULTY else 5
def loan_days(self, m: Member) -> int: return 90 if m.type is MemberType.FACULTY else 14
def fine_for(self, loan: Loan, days_late: int) -> int: return min(days_late * 25, 2000)
def max_owed(self) -> int: return 500
def max_renewals(self) -> int: return 2
def hold_days(self) -> int: return 3
# What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader# what to do next.class Result(Enum): OK = auto() NO_SUCH_COPY = auto() NOT_AVAILABLE = auto() NOT_ON_LOAN = auto() LIMIT_REACHED = auto() HAS_OVERDUE = auto() FINES_OWED = auto() HELD_FOR_SOMEONE_ELSE = auto() COPY_AVAILABLE = auto() ALREADY_WAITING = auto() NOT_YOUR_LOAN = auto() RENEWAL_LIMIT = auto() HOLD_WAITING = auto()
# 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: def __init__(self, catalog: Catalog, policy: LendingPolicy, notifier: Notifier, clock: Callable[[], date]): self._catalog = catalog self._policy = policy self._notifier = notifier self._clock = clock self._fines = FineLedger() self._holds = Holds() self._loans: dict[str, Loan] = {} # barcode -> its loan self._by_member: dict[str, set[str]] = {} # member id -> barcodes, for a fast "what do I have?"
# Every reason to refuse is checked before anything changes, so a refused checkout leaves no # trace. def checkout(self, m: Member, barcode: str) -> Result: c = self._catalog.copy(barcode) if c is None: return Result.NO_SUCH_COPY if barcode in self._loans: return Result.NOT_AVAILABLE # A copy on the hold shelf can only go to the member it was set aside for. p = self._holds.pickup_for(barcode) if p is not None and p.member.id != m.id: return Result.HELD_FOR_SOMEONE_ELSE today = self._clock() mine = self.loans_of(m) if len(mine) >= self._policy.max_loans(m): return Result.LIMIT_REACHED if any(l.due_on < today for l in mine): return Result.HAS_OVERDUE if self._fines.owed(m.id) > self._policy.max_owed(): return Result.FINES_OWED if p is not None: self._holds.clear(barcode) self._loans[barcode] = Loan(c, m, today, today + timedelta(days=self._policy.loan_days(m))) self._by_member.setdefault(m.id, set()).add(barcode) return Result.OK
# A late return adds a fine. Then the copy goes to the next reader waiting, if there is one. def return_copy(self, barcode: str) -> Result: l = self._loans.pop(barcode, None) if l is None: return Result.NOT_ON_LOAN self._by_member[l.member.id].discard(barcode) today = self._clock() days_late = (today - l.due_on).days if days_late > 0: self._fines.add(Fine(l.member.id, barcode, self._policy.fine_for(l, days_late), today)) self._route(l.copy, today) return Result.OK
def place_hold(self, m: Member, isbn: str) -> Result: if self._holds.is_waiting(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. on_shelf = any( c.barcode not in self._loans and self._holds.pickup_for(c.barcode) is None for c in self._catalog.copies_of(isbn) ) if on_shelf: return Result.COPY_AVAILABLE self._holds.add(Hold(m, isbn, self._clock())) return Result.OK
def renew(self, m: Member, barcode: str) -> Result: l = self._loans.get(barcode) if l is None or l.member.id != m.id: return Result.NOT_YOUR_LOAN today = self._clock() if l.due_on < today: return Result.HAS_OVERDUE if l.renewals >= self._policy.max_renewals(): return Result.RENEWAL_LIMIT # Someone waiting for this title outranks a second turn for the current reader. if self._holds.any_waiting(l.copy.book.isbn): return Result.HOLD_WAITING l.due_on = today + timedelta(days=self._policy.loan_days(m)) l.renewals += 1 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. def expire_holds(self) -> int: today = self._clock() gone = self._holds.expired(today) for barcode in gone: self._holds.clear(barcode) self._route(self._catalog.copy(barcode), today) return len(gone)
# 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. def _route(self, c: Copy, today: date) -> None: nxt = self._holds.next(c.book.isbn) if nxt is None: return until = today + timedelta(days=self._policy.hold_days()) self._holds.set_aside(c.barcode, Pickup(nxt.member, until)) self._notifier.hold_ready(nxt.member, c.book, until)
def owed(self, m: Member) -> int: return self._fines.owed(m.id)
def pay(self, m: Member, cents: int) -> int: return self._fines.pay(m.id, cents)
def loans_of(self, m: Member) -> list[Loan]: return [self._loans[b] for b in self._by_member.get(m.id, ())]
# 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.@dataclass(frozen=True)class Fine: member_id: str barcode: str cents: int assessed_on: date
# Who owes what. The desk adds fines on return; payments arrive at the counter.class FineLedger: def __init__(self): self._fines: dict[str, list[Fine]] = {} self._paid: dict[str, int] = {}
def add(self, f: Fine) -> None: self._fines.setdefault(f.member_id, []).append(f)
def owed(self, member_id: str) -> int: charged = sum(f.cents for f in self._fines.get(member_id, [])) return charged - self._paid.get(member_id, 0)
# Takes at most what is owed, and returns what it took, so an overpayment is never kept. def pay(self, member_id: str, cents: int) -> int: taken = min(cents, self.owed(member_id)) if taken > 0: self._paid[member_id] = self._paid.get(member_id, 0) + taken return taken
# A member waiting for a title. Holds are on books, not copies: the reader wants whichever# copy comes back first.@dataclass(frozen=True)class Hold: member: Member isbn: str placed_on: date
# 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.@dataclass(frozen=True)class Pickup: member: Member until: date
# The waiting lists and the hold shelf, kept apart from loans so each stays simple.class Holds: def __init__(self): self._waiting: dict[str, deque[Hold]] = {} # isbn -> first come, first served self._shelf: dict[str, Pickup] = {} # barcode -> who it's for
def add(self, h: Hold) -> None: self._waiting.setdefault(h.isbn, deque()).append(h)
def any_waiting(self, isbn: str) -> bool: return bool(self._waiting.get(isbn))
def is_waiting(self, m: Member, isbn: str) -> bool: return any(h.member.id == m.id for h in self._waiting.get(isbn, ()))
# Removes and returns the first member waiting for this title, or None. def next(self, isbn: str) -> Hold | None: q = self._waiting.get(isbn) return q.popleft() if q else None
def set_aside(self, barcode: str, p: Pickup) -> None: self._shelf[barcode] = p
def pickup_for(self, barcode: str) -> Pickup | None: return self._shelf.get(barcode)
def clear(self, barcode: str) -> None: self._shelf.pop(barcode, None)
# A pickup is good through its last day, so it expires once today is after it. def expired(self, today: date) -> list[str]: return sorted(b for b, p in self._shelf.items() if p.until < today)
# Email, text or app push. The desk says what happened; how the member hears is not its job.class Notifier(Protocol): def hold_ready(self, m: Member, b: Book, until: date) -> None: ...
# 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: def __init__(self, desk: LoanDesk): self._desk = desk # The notifier runs inside this lock, so it must only queue the message (an outbox), # never send an email while every kiosk waits. self._lock = threading.Lock()
def checkout(self, m: Member, barcode: str) -> Result: with self._lock: return self._desk.checkout(m, barcode)
def return_copy(self, barcode: str) -> Result: with self._lock: return self._desk.return_copy(barcode)
def place_hold(self, m: Member, isbn: str) -> Result: with self._lock: return self._desk.place_hold(m, isbn)
def renew(self, m: Member, barcode: str) -> Result: with self._lock: return self._desk.renew(m, barcode)
def expire_holds(self) -> int: with self._lock: return self._desk.expire_holds()
def pay(self, m: Member, cents: int) -> int: with self._lock: return self._desk.pay(m, cents)
def owed(self, m: Member) -> int: with self._lock: return self._desk.owed(m)
def loan_count(self, m: Member) -> int: with self._lock: return len(self._desk.loans_of(m))// The complete design, built from every stage on this page.
// A title: what a reader searches for. The library may own many copies of one book.public sealed 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.public sealed record Copy(string Barcode, Book Book);
// A library card holder.public sealed record Member(string Id, string Name, MemberType Type);
// One borrowing: which copy, who has it, and until when. Renewing changes the due date later on.public sealed class Loan(Copy copy, Member member, DateOnly borrowedOn, DateOnly dueOn){ public Copy Copy { get; } = copy; public Member Member { get; } = member; public DateOnly BorrowedOn { get; } = borrowedOn; public DateOnly DueOn { get; set; } = dueOn; public int Renewals { get; set; } // how many times the due date has moved; the policy caps it}
// Different members get different limits. The policy reads this; nothing else does.public enum MemberType { Student, Faculty }
// What the library owns, and nothing about who has it. Searches never touch loans.public sealed class Catalog{ private readonly Dictionary<string, Copy> _copies = []; // by barcode private readonly List<Copy> _inOrder = []; // for searches in the order added
public void Add(Copy c) { _copies[c.Barcode] = c; _inOrder.Add(c); }
public Copy? Copy(string barcode) => _copies.GetValueOrDefault(barcode);
public List<Copy> CopiesOf(string isbn) => _inOrder.Where(c => c.Book.Isbn == isbn).ToList();
public List<Book> Search(string text) => _inOrder.Select(c => c.Book).Distinct() .Where(b => b.Title.Contains(text, StringComparison.OrdinalIgnoreCase) || b.Author.Contains(text, StringComparison.OrdinalIgnoreCase)) .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.public interface ILendingPolicy{ int MaxLoans(Member m); int LoanDays(Member m); long FineFor(Loan loan, int 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.public sealed class StandardPolicy : ILendingPolicy{ public int MaxLoans(Member m) => m.Type == MemberType.Faculty ? 20 : 5; public int LoanDays(Member m) => m.Type == MemberType.Faculty ? 90 : 14; public long FineFor(Loan loan, int daysLate) => Math.Min(daysLate * 25L, 2000); public long MaxOwed() => 500; public int MaxRenewals() => 2; public int HoldDays() => 3;}
// What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader// what to do next.public enum Result{ Ok, NoSuchCopy, NotAvailable, NotOnLoan, LimitReached, HasOverdue, FinesOwed, HeldForSomeoneElse, CopyAvailable, AlreadyWaiting, NotYourLoan, RenewalLimit, HoldWaiting,}
// Where every loan, return, hold and renewal happens. It owns who has what; the catalog owns// what exists, and the policy owns the numbers.public sealed class LoanDesk(Catalog catalog, ILendingPolicy policy, INotifier notifier, Func<DateOnly> clock){ private readonly FineLedger _fines = new(); private readonly Holds _holds = new(); private readonly Dictionary<string, Loan> _loans = []; // barcode -> its loan private readonly Dictionary<string, HashSet<string>> _byMember = []; // member id -> barcodes, for a fast "what do I have?"
// Every reason to refuse is checked before anything changes, so a refused checkout leaves // no trace. public Result Checkout(Member m, string barcode) { var c = catalog.Copy(barcode); if (c is null) return Result.NoSuchCopy; if (_loans.ContainsKey(barcode)) return Result.NotAvailable; // A copy on the hold shelf can only go to the member it was set aside for. var p = _holds.PickupFor(barcode); if (p is not null && p.Member.Id != m.Id) return Result.HeldForSomeoneElse; var today = clock(); var mine = LoansOf(m); if (mine.Count >= policy.MaxLoans(m)) return Result.LimitReached; if (mine.Any(l => l.DueOn < today)) return Result.HasOverdue; if (_fines.Owed(m.Id) > policy.MaxOwed()) return Result.FinesOwed; if (p is not null) _holds.Clear(barcode); Lend(c, m, today); return Result.Ok; }
// A late return adds a fine. Then the copy goes to the next reader waiting, if there is one. public Result ReturnCopy(string barcode) { if (!_loans.Remove(barcode, out var l)) return Result.NotOnLoan; _byMember[l.Member.Id].Remove(barcode); var today = clock(); var daysLate = today.DayNumber - l.DueOn.DayNumber; if (daysLate > 0) _fines.Add(new Fine(l.Member.Id, barcode, policy.FineFor(l, daysLate), today)); Route(l.Copy, today); return Result.Ok; }
public Result PlaceHold(Member m, string isbn) { if (_holds.IsWaiting(m, isbn)) return Result.AlreadyWaiting; // 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. var onShelf = catalog.CopiesOf(isbn) .Any(c => !_loans.ContainsKey(c.Barcode) && _holds.PickupFor(c.Barcode) is null); if (onShelf) return Result.CopyAvailable; _holds.Add(new Hold(m, isbn, clock())); return Result.Ok; }
public Result Renew(Member m, string barcode) { if (!_loans.TryGetValue(barcode, out var l) || l.Member.Id != m.Id) return Result.NotYourLoan; var today = clock(); if (l.DueOn < today) return Result.HasOverdue; if (l.Renewals >= policy.MaxRenewals()) return Result.RenewalLimit; // Someone waiting for this title outranks a second turn for the current reader. if (_holds.AnyWaiting(l.Copy.Book.Isbn)) return Result.HoldWaiting; l.DueOn = today.AddDays(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. public int ExpireHolds() { var today = clock(); var gone = _holds.Expired(today); foreach (var barcode in gone) { _holds.Clear(barcode); Route(catalog.Copy(barcode)!, today); } return gone.Count; }
public long Owed(Member m) => _fines.Owed(m.Id);
public long Pay(Member m, long cents) => _fines.Pay(m.Id, cents);
public List<Loan> LoansOf(Member m) => _byMember.TryGetValue(m.Id, out var mine) ? mine.Select(b => _loans[b]).ToList() : [];
// 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, DateOnly today) { var next = _holds.Next(c.Book.Isbn); if (next is null) return; var until = today.AddDays(policy.HoldDays()); _holds.SetAside(c.Barcode, new Pickup(next.Member, until)); notifier.HoldReady(next.Member, c.Book, until); }
private void Lend(Copy c, Member m, DateOnly today) { _loans[c.Barcode] = new Loan(c, m, today, today.AddDays(policy.LoanDays(m))); if (!_byMember.TryGetValue(m.Id, out var mine)) _byMember[m.Id] = mine = []; mine.Add(c.Barcode); }}
// 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.public sealed record Fine(string MemberId, string Barcode, long Cents, DateOnly AssessedOn);
// Who owes what. The desk adds fines on return; payments arrive at the counter.public sealed class FineLedger{ private readonly Dictionary<string, List<Fine>> _fines = []; private readonly Dictionary<string, long> _paid = [];
public void Add(Fine f) { if (!_fines.TryGetValue(f.MemberId, out var list)) _fines[f.MemberId] = list = []; list.Add(f); }
public long Owed(string memberId) => _fines.GetValueOrDefault(memberId, []).Sum(f => f.Cents) - _paid.GetValueOrDefault(memberId);
// Takes at most what is owed, and returns what it took, so an overpayment is never kept. public long Pay(string memberId, long cents) { var taken = Math.Min(cents, Owed(memberId)); if (taken > 0) _paid[memberId] = _paid.GetValueOrDefault(memberId) + taken; return taken; }}
// A member waiting for a title. Holds are on books, not copies: the reader wants whichever// copy comes back first.public sealed record Hold(Member Member, string Isbn, DateOnly 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.public sealed record Pickup(Member Member, DateOnly Until);
// The waiting lists and the hold shelf, kept apart from loans so each stays simple.public sealed class Holds{ private readonly Dictionary<string, Queue<Hold>> _waiting = []; // isbn -> first come, first served private readonly Dictionary<string, Pickup> _shelf = []; // barcode -> who it's for
public void Add(Hold h) { if (!_waiting.TryGetValue(h.Isbn, out var q)) _waiting[h.Isbn] = q = new Queue<Hold>(); q.Enqueue(h); }
public bool AnyWaiting(string isbn) => _waiting.TryGetValue(isbn, out var q) && q.Count > 0;
public bool IsWaiting(Member m, string isbn) => _waiting.TryGetValue(isbn, out var q) && q.Any(h => h.Member.Id == m.Id);
// Removes and returns the first member waiting for this title, or null. public Hold? Next(string isbn) => _waiting.TryGetValue(isbn, out var q) && q.TryDequeue(out var h) ? h : null;
public void SetAside(string barcode, Pickup p) => _shelf[barcode] = p;
public Pickup? PickupFor(string barcode) => _shelf.GetValueOrDefault(barcode);
public void Clear(string barcode) => _shelf.Remove(barcode);
// A pickup is good through its last day, so it expires once today is after it. public List<string> Expired(DateOnly today) => _shelf.Where(e => e.Value.Until < today).Select(e => e.Key).Order().ToList();}
// Email, text or app push. The desk says what happened; how the member hears is not its job.public interface INotifier{ void HoldReady(Member m, Book b, DateOnly 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. The notifier runs inside the lock,// so it must only queue the message (an outbox), never send an email while every kiosk waits.public sealed class SharedDesk(LoanDesk desk){ private readonly Lock _gate = new();
public Result Checkout(Member m, string barcode) { lock (_gate) return desk.Checkout(m, barcode); } public Result ReturnCopy(string barcode) { lock (_gate) return desk.ReturnCopy(barcode); } public Result PlaceHold(Member m, string isbn) { lock (_gate) return desk.PlaceHold(m, isbn); } public Result Renew(Member m, string barcode) { lock (_gate) return desk.Renew(m, barcode); } public int ExpireHolds() { lock (_gate) return desk.ExpireHolds(); } public long Pay(Member m, long cents) { lock (_gate) return desk.Pay(m, cents); } public long Owed(Member m) { lock (_gate) return desk.Owed(m); } public int LoanCount(Member m) { lock (_gate) return desk.LoansOf(m).Count; }}// The complete design, built from every stage on this page.
package librarymanagement
import ( "slices" "strings" "sync" "time")
// Book is a title: what a reader searches for. The library may own many copies of one book.type Book struct { ISBN, Title, Author string}
// Copy is 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 leans on.type Copy struct { Barcode string Book Book}
// MemberType gives different members different limits. The policy reads it; nothing else does.type MemberType int
const ( Student MemberType = iota Faculty)
// Member is a library card holder.type Member struct { ID, Name string Type MemberType}
// Catalog is what the library owns, and nothing about who has it. Searches never touch loans.type Catalog struct { copies []Copy // in the order added index map[string]int // barcode -> position in copies}
func NewCatalog() *Catalog { return &Catalog{index: map[string]int{}} }
func (c *Catalog) Add(cp Copy) { c.index[cp.Barcode] = len(c.copies) c.copies = append(c.copies, cp)}
func (c *Catalog) Copy(barcode string) (Copy, bool) { i, ok := c.index[barcode] if !ok { return Copy{}, false } return c.copies[i], true}
func (c *Catalog) CopiesOf(isbn string) []Copy { var out []Copy for _, cp := range c.copies { if cp.Book.ISBN == isbn { out = append(out, cp) } } return out}
func (c *Catalog) Search(text string) []Book { q := strings.ToLower(text) seen := map[string]bool{} var out []Book for _, cp := range c.copies { b := cp.Book if seen[b.ISBN] { continue } seen[b.ISBN] = true if strings.Contains(strings.ToLower(b.Title), q) || strings.Contains(strings.ToLower(b.Author), q) { out = append(out, b) } } return out}
// StandardPolicy is one library's numbers. Faculty borrow more books, for longer.type StandardPolicy struct{}
func (StandardPolicy) MaxLoans(m Member) int { if m.Type == Faculty { return 20 } return 5}
func (StandardPolicy) LoanDays(m Member) int { if m.Type == Faculty { return 90 } return 14}
// Result says why a request was refused. Refusals are normal at a library desk, so they are// values the kiosk shows, not errors.type Result int
// Fine is 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.type Fine struct { MemberID, Barcode string Cents int64 AssessedOn time.Time}
// FineLedger is who owes what. The desk adds fines on return; payments arrive at the counter.type FineLedger struct { fines map[string][]Fine paid map[string]int64}
func NewFineLedger() *FineLedger { return &FineLedger{fines: map[string][]Fine{}, paid: map[string]int64{}}}
func (f *FineLedger) Add(fine Fine) { f.fines[fine.MemberID] = append(f.fines[fine.MemberID], fine) }
func (f *FineLedger) Owed(memberID string) int64 { var charged int64 for _, x := range f.fines[memberID] { charged += x.Cents } return charged - f.paid[memberID]}
// Pay takes at most what is owed and returns what it took, so an overpayment is never kept.func (f *FineLedger) Pay(memberID string, cents int64) int64 { taken := min(cents, f.Owed(memberID)) if taken > 0 { f.paid[memberID] += taken } return taken}
// FineFor charges 25 cents a day, capped at 20.00 so a forgotten book never costs more than a// new one.func (StandardPolicy) FineFor(l *Loan, daysLate int) int64 { return min(int64(daysLate)*25, 2000) }
func (StandardPolicy) MaxOwed() int64 { return 500 }
// Loan is one borrowing: which copy, who has it, and until when. Renewing moves DueOn.type Loan struct { Copy Copy Member Member BorrowedOn time.Time DueOn time.Time Renewals int // how many times DueOn has moved; the policy caps it}
// Hold is a member waiting for a title. Holds are on books, not copies: the reader wants// whichever copy comes back first.type Hold struct { Member Member ISBN string PlacedOn time.Time}
// Pickup is 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.type Pickup struct { Member Member Until time.Time}
// Holds keeps the waiting lists and the hold shelf, apart from loans so each stays simple.type Holds struct { waiting map[string][]Hold // isbn -> first come, first served shelf map[string]Pickup // barcode -> who it's for}
func NewHolds() *Holds { return &Holds{waiting: map[string][]Hold{}, shelf: map[string]Pickup{}} }
func (h *Holds) Add(x Hold) { h.waiting[x.ISBN] = append(h.waiting[x.ISBN], x) }
func (h *Holds) AnyWaiting(isbn string) bool { return len(h.waiting[isbn]) > 0 }
func (h *Holds) IsWaiting(m Member, isbn string) bool { return slices.ContainsFunc(h.waiting[isbn], func(x Hold) bool { return x.Member.ID == m.ID })}
// Next removes and returns the first member waiting for this title.func (h *Holds) Next(isbn string) (Hold, bool) { q := h.waiting[isbn] if len(q) == 0 { return Hold{}, false } h.waiting[isbn] = q[1:] return q[0], true}
func (h *Holds) SetAside(barcode string, p Pickup) { h.shelf[barcode] = p }
func (h *Holds) PickupFor(barcode string) (Pickup, bool) { p, ok := h.shelf[barcode] return p, ok}
func (h *Holds) Clear(barcode string) { delete(h.shelf, barcode) }
// Expired lists pickups past their last day. A pickup is good through its Until date.func (h *Holds) Expired(today time.Time) []string { var out []string for b, p := range h.shelf { if p.Until.Before(today) { out = append(out, b) } } slices.Sort(out) return out}
// Notifier is email, text or app push. The desk says what happened; how the member hears is// not its job.type Notifier interface { HoldReady(m Member, b Book, until time.Time)}
// LendingPolicy holds 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 changes only this.type LendingPolicy interface { MaxLoans(m Member) int LoanDays(m Member) int FineFor(l *Loan, daysLate int) int64 MaxOwed() int64 MaxRenewals() int HoldDays() int // how long a set-aside copy waits to be collected}
func (StandardPolicy) MaxRenewals() int { return 2 }
func (StandardPolicy) HoldDays() int { return 3 }
const ( OK Result = iota NoSuchCopy NotAvailable NotOnLoan LimitReached HasOverdue FinesOwed HeldForSomeoneElse CopyAvailable AlreadyWaiting NotYourLoan RenewalLimit HoldWaiting)
// LoanDesk is where every loan, return, hold and renewal happens. It owns who has what; the// catalog owns what exists, and the policy owns the numbers.type LoanDesk struct { catalog *Catalog policy LendingPolicy notifier Notifier clock func() time.Time fines *FineLedger holds *Holds loans map[string]*Loan // barcode -> its loan byMember map[string]map[string]bool // member id -> barcodes, for a fast "what do I have?"}
func NewLoanDesk(c *Catalog, p LendingPolicy, n Notifier, clock func() time.Time) *LoanDesk { return &LoanDesk{catalog: c, policy: p, notifier: n, clock: clock, fines: NewFineLedger(), holds: NewHolds(), loans: map[string]*Loan{}, byMember: map[string]map[string]bool{}}}
// Checkout checks every reason to refuse before anything changes, so a refused checkout leaves// no trace.func (d *LoanDesk) Checkout(m Member, barcode string) Result { c, ok := d.catalog.Copy(barcode) if !ok { return NoSuchCopy } if d.loans[barcode] != nil { return NotAvailable } // A copy on the hold shelf can only go to the member it was set aside for. p, held := d.holds.PickupFor(barcode) if held && p.Member.ID != m.ID { return HeldForSomeoneElse } today := d.clock() mine := d.LoansOf(m) if len(mine) >= d.policy.MaxLoans(m) { return LimitReached } for _, l := range mine { if l.DueOn.Before(today) { return HasOverdue } } if d.fines.Owed(m.ID) > d.policy.MaxOwed() { return FinesOwed } if held { d.holds.Clear(barcode) } d.lend(c, m, today) return OK}
// ReturnCopy adds a fine for a late return. Then the copy goes to the next reader waiting, if// there is one.func (d *LoanDesk) ReturnCopy(barcode string) Result { l := d.loans[barcode] if l == nil { return NotOnLoan } delete(d.loans, barcode) delete(d.byMember[l.Member.ID], barcode) today := d.clock() if daysLate := int(today.Sub(l.DueOn).Hours() / 24); daysLate > 0 { d.fines.Add(Fine{l.Member.ID, barcode, d.policy.FineFor(l, daysLate), today}) } d.route(l.Copy, today) return OK}
func (d *LoanDesk) PlaceHold(m Member, isbn string) Result { if d.holds.IsWaiting(m, isbn) { return AlreadyWaiting } // 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. for _, c := range d.catalog.CopiesOf(isbn) { _, held := d.holds.PickupFor(c.Barcode) if d.loans[c.Barcode] == nil && !held { return CopyAvailable } } d.holds.Add(Hold{m, isbn, d.clock()}) return OK}
func (d *LoanDesk) Renew(m Member, barcode string) Result { l := d.loans[barcode] if l == nil || l.Member.ID != m.ID { return NotYourLoan } today := d.clock() if l.DueOn.Before(today) { return HasOverdue } if l.Renewals >= d.policy.MaxRenewals() { return RenewalLimit } // Someone waiting for this title outranks a second turn for the current reader. if d.holds.AnyWaiting(l.Copy.Book.ISBN) { return HoldWaiting } l.DueOn = today.AddDate(0, 0, d.policy.LoanDays(m)) l.Renewals++ return OK}
// ExpireHolds runs once a day. A copy nobody collected moves on to the next person waiting,// or back to the open shelf. It returns how many pickups expired.func (d *LoanDesk) ExpireHolds() int { today := d.clock() gone := d.holds.Expired(today) for _, barcode := range gone { d.holds.Clear(barcode) c, _ := d.catalog.Copy(barcode) d.route(c, today) } return len(gone)}
// route is where every copy that comes free goes: to the first member waiting for its// title, or back to the open shelf when nobody is.func (d *LoanDesk) route(c Copy, today time.Time) { next, ok := d.holds.Next(c.Book.ISBN) if !ok { return } until := today.AddDate(0, 0, d.policy.HoldDays()) d.holds.SetAside(c.Barcode, Pickup{next.Member, until}) d.notifier.HoldReady(next.Member, c.Book, until)}
func (d *LoanDesk) lend(c Copy, m Member, today time.Time) { d.loans[c.Barcode] = &Loan{Copy: c, Member: m, BorrowedOn: today, DueOn: today.AddDate(0, 0, d.policy.LoanDays(m))} if d.byMember[m.ID] == nil { d.byMember[m.ID] = map[string]bool{} } d.byMember[m.ID][c.Barcode] = true}
func (d *LoanDesk) LoansOf(m Member) []*Loan { var out []*Loan for b := range d.byMember[m.ID] { out = append(out, d.loans[b]) } return out}
func (d *LoanDesk) Owed(m Member) int64 { return d.fines.Owed(m.ID) }
func (d *LoanDesk) Pay(m Member, cents int64) int64 { return d.fines.Pay(m.ID, cents) }
// SharedDesk exists because kiosks, the front desk and the website all call the desk at once,// 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 split by another// goroutine.//// One mutex 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. The notifier runs inside the lock,// so it must only queue the message (an outbox), never send an email while every kiosk waits.type SharedDesk struct { mu sync.Mutex desk *LoanDesk}
func NewSharedDesk(d *LoanDesk) *SharedDesk { return &SharedDesk{desk: d} }
func (s *SharedDesk) Checkout(m Member, barcode string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.Checkout(m, barcode)}
func (s *SharedDesk) ReturnCopy(barcode string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.ReturnCopy(barcode)}
func (s *SharedDesk) PlaceHold(m Member, isbn string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.PlaceHold(m, isbn)}
func (s *SharedDesk) Renew(m Member, barcode string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.Renew(m, barcode)}
func (s *SharedDesk) ExpireHolds() int { s.mu.Lock() defer s.mu.Unlock() return s.desk.ExpireHolds()}
func (s *SharedDesk) Pay(m Member, cents int64) int64 { s.mu.Lock() defer s.mu.Unlock() return s.desk.Pay(m, cents)}
func (s *SharedDesk) LoanCount(m Member) int { s.mu.Lock() defer s.mu.Unlock() return len(s.desk.LoansOf(m))}// The complete design, built from every stage on this page.
// A calendar day as a whole number (days since an epoch). JavaScript has no date-only type,// and a Date carries a time and a timezone that make "how many days late?" error-prone.type Day = number;
// A title: what a reader searches for. The library may own many copies of one book.interface Book { readonly isbn: string; readonly title: string; readonly author: string;}
// 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.interface Copy { readonly barcode: string; readonly book: Book;}
// A library card holder.interface Member { readonly id: string; readonly name: string; readonly type: MemberType;}
// One borrowing: which copy, who has it, and until when. Renewing changes the due date later on.interface Loan { readonly copy: Copy; readonly member: Member; readonly borrowedOn: Day; dueOn: Day; renewals: number; // how many times the due date has moved; the policy caps it}
// Different members get different limits. The policy reads this; nothing else does.type MemberType = 'student' | 'faculty';
// What the library owns, and nothing about who has it. Searches never touch loans.class Catalog { private readonly copies = new Map<string, Copy>(); // by barcode, in the order added
add(c: Copy): void { this.copies.set(c.barcode, c); }
copy(barcode: string): Copy | undefined { return this.copies.get(barcode); }
copiesOf(isbn: string): Copy[] { return [...this.copies.values()].filter((c) => c.book.isbn === isbn); }
search(text: string): Book[] { const q = text.toLowerCase(); const books = new Set([...this.copies.values()].map((c) => c.book)); return [...books].filter((b) => b.title.toLowerCase().includes(q) || b.author.toLowerCase().includes(q)); }}
// 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 { maxLoans(m: Member): number; loanDays(m: Member): number; fineFor(loan: Loan, daysLate: number): number; maxOwed(): number; maxRenewals(): number; holdDays(): number; // 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 { maxLoans(m: Member): number { return m.type === 'faculty' ? 20 : 5; } loanDays(m: Member): number { return m.type === 'faculty' ? 90 : 14; } fineFor(_loan: Loan, daysLate: number): number { return Math.min(daysLate * 25, 2000); } maxOwed(): number { return 500; } maxRenewals(): number { return 2; } holdDays(): number { return 3; }}
// What a request gets back. Each refusal has its own reason, so the kiosk can tell the reader// what to do next.type 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 readonly fines = new FineLedger(); private readonly holds = new Holds(); private readonly loans = new Map<string, Loan>(); // barcode -> its loan private readonly byMember = new Map<string, Set<string>>(); // member id -> barcodes, for a fast "what do I have?"
constructor( private readonly catalog: Catalog, private readonly policy: LendingPolicy, private readonly notifier: Notifier, private readonly clock: () => Day, ) {}
// Every reason to refuse is checked before anything changes, so a refused checkout leaves no // trace. checkout(m: Member, barcode: string): Result { const c = this.catalog.copy(barcode); if (!c) return 'no-such-copy'; if (this.loans.has(barcode)) return 'not-available'; // A copy on the hold shelf can only go to the member it was set aside for. const p = this.holds.pickupFor(barcode); if (p && p.member.id !== m.id) return 'held-for-someone-else'; const today = this.clock(); const mine = this.loansOf(m); if (mine.length >= this.policy.maxLoans(m)) return 'limit-reached'; if (mine.some((l) => l.dueOn < today)) return 'has-overdue'; if (this.fines.owed(m.id) > this.policy.maxOwed()) return 'fines-owed'; if (p) this.holds.clear(barcode); this.lend(c, m, today); return 'ok'; }
// A late return adds a fine. Then the copy goes to the next reader waiting, if there is one. returnCopy(barcode: string): Result { const l = this.loans.get(barcode); if (!l) return 'not-on-loan'; this.loans.delete(barcode); this.byMember.get(l.member.id)!.delete(barcode); const today = this.clock(); const daysLate = today - l.dueOn; if (daysLate > 0) { this.fines.add({ memberId: l.member.id, barcode, cents: this.policy.fineFor(l, daysLate), assessedOn: today }); } this.route(l.copy, today); return 'ok'; }
placeHold(m: Member, isbn: string): Result { if (this.holds.isWaiting(m, isbn)) return '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. const onShelf = this.catalog .copiesOf(isbn) .some((c) => !this.loans.has(c.barcode) && !this.holds.pickupFor(c.barcode)); if (onShelf) return 'copy-available'; this.holds.add({ member: m, isbn, placedOn: this.clock() }); return 'ok'; }
renew(m: Member, barcode: string): Result { const l = this.loans.get(barcode); if (!l || l.member.id !== m.id) return 'not-your-loan'; const today = this.clock(); if (l.dueOn < today) return 'has-overdue'; if (l.renewals >= this.policy.maxRenewals()) return 'renewal-limit'; // Someone waiting for this title outranks a second turn for the current reader. if (this.holds.anyWaiting(l.copy.book.isbn)) return 'hold-waiting'; l.dueOn = today + this.policy.loanDays(m); l.renewals++; return '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. expireHolds(): number { const today = this.clock(); const gone = this.holds.expired(today); for (const barcode of gone) { this.holds.clear(barcode); this.route(this.catalog.copy(barcode)!, today); } return gone.length; }
owed(m: Member): number { return this.fines.owed(m.id); }
pay(m: Member, cents: number): number { return this.fines.pay(m.id, cents); }
loansOf(m: Member): Loan[] { return [...(this.byMember.get(m.id) ?? [])].map((b) => this.loans.get(b)!); }
// 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 route(c: Copy, today: Day): void { const next = this.holds.next(c.book.isbn); if (!next) return; const until = today + this.policy.holdDays(); this.holds.setAside(c.barcode, { member: next.member, until }); this.notifier.holdReady(next.member, c.book, until); }
private lend(c: Copy, m: Member, today: Day): void { this.loans.set(c.barcode, { copy: c, member: m, borrowedOn: today, dueOn: today + this.policy.loanDays(m), renewals: 0, }); if (!this.byMember.has(m.id)) this.byMember.set(m.id, new Set()); this.byMember.get(m.id)!.add(c.barcode); }}
// 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.interface Fine { readonly memberId: string; readonly barcode: string; readonly cents: number; readonly assessedOn: Day;}
// Who owes what. The desk adds fines on return; payments arrive at the counter.class FineLedger { private readonly fines = new Map<string, Fine[]>(); private readonly paid = new Map<string, number>();
add(f: Fine): void { this.fines.set(f.memberId, [...(this.fines.get(f.memberId) ?? []), f]); }
owed(memberId: string): number { const charged = (this.fines.get(memberId) ?? []).reduce((sum, f) => sum + f.cents, 0); return charged - (this.paid.get(memberId) ?? 0); }
// Takes at most what is owed, and returns what it took, so an overpayment is never kept. pay(memberId: string, cents: number): number { const taken = Math.min(cents, this.owed(memberId)); if (taken > 0) this.paid.set(memberId, (this.paid.get(memberId) ?? 0) + taken); return taken; }}
// A member waiting for a title. Holds are on books, not copies: the reader wants whichever// copy comes back first.interface Hold { readonly member: Member; readonly isbn: string; readonly placedOn: Day;}
// A copy set aside on the hold shelf for one member until a day. Once set aside it's this// copy, so the shelf is keyed by barcode while the queue is keyed by ISBN.interface Pickup { readonly member: Member; readonly until: Day;}
// The waiting lists and the hold shelf, kept apart from loans so each stays simple.class Holds { private readonly waiting = new Map<string, Hold[]>(); // isbn -> first come, first served private readonly shelf = new Map<string, Pickup>(); // barcode -> who it's for
add(h: Hold): void { this.waiting.set(h.isbn, [...(this.waiting.get(h.isbn) ?? []), h]); }
anyWaiting(isbn: string): boolean { return (this.waiting.get(isbn) ?? []).length > 0; }
isWaiting(m: Member, isbn: string): boolean { return (this.waiting.get(isbn) ?? []).some((h) => h.member.id === m.id); }
// Removes and returns the first member waiting for this title, or undefined. next(isbn: string): Hold | undefined { return this.waiting.get(isbn)?.shift(); }
setAside(barcode: string, p: Pickup): void { this.shelf.set(barcode, p); }
pickupFor(barcode: string): Pickup | undefined { return this.shelf.get(barcode); }
clear(barcode: string): void { this.shelf.delete(barcode); }
// A pickup is good through its last day, so it expires once today is after it. expired(today: Day): string[] { return [...this.shelf].filter(([, p]) => p.until < today).map(([b]) => b).sort(); }}
// Email, text or app push. The desk says what happened; how the member hears is not its job.interface Notifier { holdReady(m: Member, b: Book, until: Day): void;}
// In one Node process the desk needs no lock: each method is synchronous, so it runs to the// end before any other request starts. The races come back when the desk's state moves to a// database and every step becomes an await, because another request can run at each one.// A hold placed while the last copy is being returned can then join the queue just after the// return found it empty, and two kiosks can each see a member one book under the limit.//// The fix is a transaction that locks the rows the decision reads, always in the same order// (member, then title) so two transactions can't deadlock. Here is placeHold that way.interface Tx { one<T>(sql: string, ...args: unknown[]): Promise<T | undefined>; run(sql: string, ...args: unknown[]): Promise<number>; // rows changed}
interface Db { transaction<T>(work: (tx: Tx) => Promise<T>): Promise<T>;}
async function placeHoldInDb(db: Db, memberId: string, isbn: string): Promise<Result> { return db.transaction(async (tx) => { await tx.one('SELECT id FROM members WHERE id = $1 FOR UPDATE', memberId); // Locking the title row makes this transaction and a return of any copy of the title // take turns, so "no copy free, so queue" and "nobody queued, so shelve" can't interleave. await tx.one('SELECT isbn FROM books WHERE isbn = $1 FOR UPDATE', isbn); const waiting = await tx.one('SELECT 1 FROM holds WHERE isbn = $1 AND member_id = $2', isbn, memberId); if (waiting) return 'already-waiting'; const free = await tx.one( `SELECT 1 FROM copies c WHERE c.isbn = $1 AND NOT EXISTS (SELECT 1 FROM loans l WHERE l.barcode = c.barcode AND l.returned_on IS NULL) AND NOT EXISTS (SELECT 1 FROM pickups p WHERE p.barcode = c.barcode)`, isbn, ); if (free) return 'copy-available'; await tx.run('INSERT INTO holds (member_id, isbn, placed_on) VALUES ($1, $2, current_date)', memberId, isbn); return 'ok'; });}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,
LoanDeskis 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.
Stress it
Section titled “Stress it”So far, one person uses the desk at a time. But the front desk, three kiosks and the website all use it at once.
The lost hold, and the sixth book
Section titled “The lost hold, and the sixth book”The front desk, three kiosks and the website all use this at once. What can go wrong?
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.
- 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 |
// 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(); } }}import threading
# 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: def __init__(self, desk: LoanDesk): self._desk = desk # The notifier runs inside this lock, so it must only queue the message (an outbox), # never send an email while every kiosk waits. self._lock = threading.Lock()
def checkout(self, m: Member, barcode: str) -> Result: with self._lock: return self._desk.checkout(m, barcode)
def return_copy(self, barcode: str) -> Result: with self._lock: return self._desk.return_copy(barcode)
def place_hold(self, m: Member, isbn: str) -> Result: with self._lock: return self._desk.place_hold(m, isbn)
def renew(self, m: Member, barcode: str) -> Result: with self._lock: return self._desk.renew(m, barcode)
def expire_holds(self) -> int: with self._lock: return self._desk.expire_holds()
def pay(self, m: Member, cents: int) -> int: with self._lock: return self._desk.pay(m, cents)
def owed(self, m: Member) -> int: with self._lock: return self._desk.owed(m)
def loan_count(self, m: Member) -> int: with self._lock: return len(self._desk.loans_of(m))// 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. The notifier runs inside the lock,// so it must only queue the message (an outbox), never send an email while every kiosk waits.public sealed class SharedDesk(LoanDesk desk){ private readonly Lock _gate = new();
public Result Checkout(Member m, string barcode) { lock (_gate) return desk.Checkout(m, barcode); } public Result ReturnCopy(string barcode) { lock (_gate) return desk.ReturnCopy(barcode); } public Result PlaceHold(Member m, string isbn) { lock (_gate) return desk.PlaceHold(m, isbn); } public Result Renew(Member m, string barcode) { lock (_gate) return desk.Renew(m, barcode); } public int ExpireHolds() { lock (_gate) return desk.ExpireHolds(); } public long Pay(Member m, long cents) { lock (_gate) return desk.Pay(m, cents); } public long Owed(Member m) { lock (_gate) return desk.Owed(m); } public int LoanCount(Member m) { lock (_gate) return desk.LoansOf(m).Count; }}// SharedDesk exists because kiosks, the front desk and the website all call the desk at once,// 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 split by another// goroutine.//// One mutex 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. The notifier runs inside the lock,// so it must only queue the message (an outbox), never send an email while every kiosk waits.type SharedDesk struct { mu sync.Mutex desk *LoanDesk}
func NewSharedDesk(d *LoanDesk) *SharedDesk { return &SharedDesk{desk: d} }
func (s *SharedDesk) Checkout(m Member, barcode string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.Checkout(m, barcode)}
func (s *SharedDesk) ReturnCopy(barcode string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.ReturnCopy(barcode)}
func (s *SharedDesk) PlaceHold(m Member, isbn string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.PlaceHold(m, isbn)}
func (s *SharedDesk) Renew(m Member, barcode string) Result { s.mu.Lock() defer s.mu.Unlock() return s.desk.Renew(m, barcode)}
func (s *SharedDesk) ExpireHolds() int { s.mu.Lock() defer s.mu.Unlock() return s.desk.ExpireHolds()}
func (s *SharedDesk) Pay(m Member, cents int64) int64 { s.mu.Lock() defer s.mu.Unlock() return s.desk.Pay(m, cents)}
func (s *SharedDesk) LoanCount(m Member) int { s.mu.Lock() defer s.mu.Unlock() return len(s.desk.LoansOf(m))}// In one Node process the desk needs no lock: each method is synchronous, so it runs to the// end before any other request starts. The races come back when the desk's state moves to a// database and every step becomes an await, because another request can run at each one.// A hold placed while the last copy is being returned can then join the queue just after the// return found it empty, and two kiosks can each see a member one book under the limit.//// The fix is a transaction that locks the rows the decision reads, always in the same order// (member, then title) so two transactions can't deadlock. Here is placeHold that way.interface Tx { one<T>(sql: string, ...args: unknown[]): Promise<T | undefined>; run(sql: string, ...args: unknown[]): Promise<number>; // rows changed}
interface Db { transaction<T>(work: (tx: Tx) => Promise<T>): Promise<T>;}
async function placeHoldInDb(db: Db, memberId: string, isbn: string): Promise<Result> { return db.transaction(async (tx) => { await tx.one('SELECT id FROM members WHERE id = $1 FOR UPDATE', memberId); // Locking the title row makes this transaction and a return of any copy of the title // take turns, so "no copy free, so queue" and "nobody queued, so shelve" can't interleave. await tx.one('SELECT isbn FROM books WHERE isbn = $1 FOR UPDATE', isbn); const waiting = await tx.one('SELECT 1 FROM holds WHERE isbn = $1 AND member_id = $2', isbn, memberId); if (waiting) return 'already-waiting'; const free = await tx.one( `SELECT 1 FROM copies c WHERE c.isbn = $1 AND NOT EXISTS (SELECT 1 FROM loans l WHERE l.barcode = c.barcode AND l.returned_on IS NULL) AND NOT EXISTS (SELECT 1 FROM pickups p WHERE p.barcode = c.barcode)`, isbn, ); if (free) return 'copy-available'; await tx.run('INSERT INTO holds (member_id, isbn, placed_on) VALUES ($1, $2, current_date)', memberId, isbn); return 'ok'; });}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.
How you’d prove it works
Section titled “How you’d prove it works”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.
// 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.@BeforeEachvoid 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);}
@Testvoid 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"));}
@Testvoid aStudentStopsAtFiveBooks() { for (int i = 0; i < 5; i++) assertEquals(Result.OK, desk.checkout(ANA, "b" + i)); assertEquals(Result.LIMIT_REACHED, desk.checkout(ANA, "b5"));}
@Testvoid anOverdueBookBlocksNewLoans() { desk.checkout(ANA, "b0"); today = today.plusDays(15); assertEquals(Result.HAS_OVERDUE, desk.checkout(ANA, "b1"));}
@Testvoid 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));}
@Testvoid 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"));}
@Testvoid 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"));}
@Testvoid aHoldIsRefusedWhileACopyIsOnTheShelf() { desk.checkout(ANA, "dune-1"); assertEquals(Result.COPY_AVAILABLE, desk.placeHold(BEN, DUNE.isbn()));}
@Testvoid 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"));}
@Testvoid 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"));}
@Testvoid 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));}import threadingfrom concurrent.futures import ThreadPoolExecutorfrom datetime import date, timedelta
import pytest
# Records each "your hold is ready" message instead of sending it.class FakeNotifier: def __init__(self): self.sent: list[str] = []
def hold_ready(self, m: Member, b: Book, until: date) -> None: self.sent.append(f"{m.id}:{b.isbn}:{until.isoformat()}")
DUNE = Book("isbn-dune", "Dune", "Frank Herbert")ANA = Member("ana", "Ana", MemberType.STUDENT)BEN = Member("ben", "Ben", MemberType.STUDENT)CY = Member("cy", "Cy", MemberType.STUDENT)
class World: # Two copies of Dune, and twenty other books with one copy each. def __init__(self): self.today = date(2026, 3, 2) self.notifier = FakeNotifier() self.catalog = Catalog() self.catalog.add(Copy("dune-1", DUNE)) self.catalog.add(Copy("dune-2", DUNE)) for i in range(20): self.catalog.add(Copy(f"b{i}", Book(f"isbn-{i}", f"Book {i}", "Author"))) self.desk = LoanDesk(self.catalog, StandardPolicy(), self.notifier, lambda: self.today)
def advance(self, days: int) -> None: self.today += timedelta(days=days)
@pytest.fixturedef w(): return World()
def test_search_finds_the_book_once_and_each_copy_lends_separately(w): assert w.catalog.search("herbert") == [DUNE] assert w.desk.checkout(ANA, "dune-1") is Result.OK assert w.desk.checkout(BEN, "dune-1") is Result.NOT_AVAILABLE assert w.desk.checkout(BEN, "dune-2") is Result.OK
def test_a_student_stops_at_five_books(w): for i in range(5): assert w.desk.checkout(ANA, f"b{i}") is Result.OK assert w.desk.checkout(ANA, "b5") is Result.LIMIT_REACHED
def test_an_overdue_book_blocks_new_loans(w): w.desk.checkout(ANA, "b0") w.advance(15) assert w.desk.checkout(ANA, "b1") is Result.HAS_OVERDUE
def test_a_late_return_is_fined_per_day_up_to_the_cap(w): w.desk.checkout(ANA, "b0") w.desk.checkout(BEN, "b1") w.advance(14 + 3) w.desk.return_copy("b0") assert w.desk.owed(ANA) == 75 w.advance(200) w.desk.return_copy("b1") assert w.desk.owed(BEN) == 2000
def test_fines_over_the_limit_block_loans_until_paid(w): w.desk.checkout(ANA, "b0") w.advance(14 + 30) w.desk.return_copy("b0") assert w.desk.checkout(ANA, "b1") is Result.FINES_OWED assert w.desk.pay(ANA, 1000) == 750 # never takes more than is owed assert w.desk.checkout(ANA, "b1") is Result.OK
def test_a_returned_copy_is_set_aside_for_the_first_member_waiting(w): w.desk.checkout(ANA, "dune-1") w.desk.checkout(BEN, "dune-2") assert w.desk.place_hold(CY, DUNE.isbn) is Result.OK w.desk.return_copy("dune-1") assert w.notifier.sent == ["cy:isbn-dune:2026-03-05"] assert w.desk.checkout(ANA, "dune-1") is Result.HELD_FOR_SOMEONE_ELSE assert w.desk.checkout(CY, "dune-1") is Result.OK
def test_a_hold_is_refused_while_a_copy_is_on_the_shelf(w): w.desk.checkout(ANA, "dune-1") assert w.desk.place_hold(BEN, DUNE.isbn) is Result.COPY_AVAILABLE
def test_renewals_stop_when_someone_is_waiting_or_at_the_limit(w): w.desk.checkout(ANA, "b0") assert w.desk.renew(ANA, "b0") is Result.OK assert w.desk.renew(ANA, "b0") is Result.OK assert w.desk.renew(ANA, "b0") is Result.RENEWAL_LIMIT w.desk.checkout(ANA, "dune-1") w.desk.checkout(BEN, "dune-2") w.desk.place_hold(CY, DUNE.isbn) assert w.desk.renew(ANA, "dune-1") is Result.HOLD_WAITING
def test_an_uncollected_hold_passes_to_the_next_member_then_to_the_shelf(w): w.desk.checkout(ANA, "dune-1") w.desk.checkout(ANA, "dune-2") w.desk.place_hold(BEN, DUNE.isbn) w.desk.place_hold(CY, DUNE.isbn) w.desk.return_copy("dune-1") # set aside for Ben until 2026-03-05 w.advance(4) assert w.desk.expire_holds() == 1 # Ben didn't come; Cy is next assert w.desk.checkout(BEN, "dune-1") is Result.HELD_FOR_SOMEONE_ELSE w.advance(4) assert w.desk.expire_holds() == 1 # nobody else waiting: back on the shelf assert w.desk.checkout(BEN, "dune-1") is Result.OK
def test_racing_kiosks_never_push_a_member_over_the_limit(w): shared = SharedDesk(w.desk) for i in range(4): shared.checkout(ANA, f"b{i}") start = threading.Barrier(16)
def borrow(i: int) -> Result: start.wait() return shared.checkout(ANA, f"b{i}")
with ThreadPoolExecutor(16) as pool: results = list(pool.map(borrow, range(4, 20))) assert results.count(Result.OK) == 1 assert shared.loan_count(ANA) == 5// Records each "your hold is ready" message instead of sending it.private sealed class FakeNotifier : INotifier{ public readonly List<string> Sent = []; public void HoldReady(Member m, Book b, DateOnly until) => Sent.Add($"{m.Id}:{b.Isbn}:{until:yyyy-MM-dd}");}
private static readonly Book Dune = new("isbn-dune", "Dune", "Frank Herbert");private static readonly Member Ana = new("ana", "Ana", MemberType.Student);private static readonly Member Ben = new("ben", "Ben", MemberType.Student);private static readonly Member Cy = new("cy", "Cy", MemberType.Student);
private DateOnly _today = new(2026, 3, 2);private readonly FakeNotifier _notifier = new();private readonly Catalog _catalog = new();private readonly LoanDesk _desk;
// Two copies of Dune, and twenty other books with one copy each. xUnit builds a fresh// instance per test, so the constructor is the setup.public LibraryTests(){ _catalog.Add(new Copy("dune-1", Dune)); _catalog.Add(new Copy("dune-2", Dune)); for (var 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);}
[Fact]public void SearchFindsTheBookOnceAndEachCopyLendsSeparately(){ Assert.Equal(new[] { Dune }, _catalog.Search("herbert")); Assert.Equal(Result.Ok, _desk.Checkout(Ana, "dune-1")); Assert.Equal(Result.NotAvailable, _desk.Checkout(Ben, "dune-1")); Assert.Equal(Result.Ok, _desk.Checkout(Ben, "dune-2"));}
[Fact]public void AStudentStopsAtFiveBooks(){ for (var i = 0; i < 5; i++) Assert.Equal(Result.Ok, _desk.Checkout(Ana, $"b{i}")); Assert.Equal(Result.LimitReached, _desk.Checkout(Ana, "b5"));}
[Fact]public void AnOverdueBookBlocksNewLoans(){ _desk.Checkout(Ana, "b0"); _today = _today.AddDays(15); Assert.Equal(Result.HasOverdue, _desk.Checkout(Ana, "b1"));}
[Fact]public void ALateReturnIsFinedPerDayUpToTheCap(){ _desk.Checkout(Ana, "b0"); _desk.Checkout(Ben, "b1"); _today = _today.AddDays(14 + 3); _desk.ReturnCopy("b0"); Assert.Equal(75, _desk.Owed(Ana)); _today = _today.AddDays(200); _desk.ReturnCopy("b1"); Assert.Equal(2000, _desk.Owed(Ben));}
[Fact]public void FinesOverTheLimitBlockLoansUntilPaid(){ _desk.Checkout(Ana, "b0"); _today = _today.AddDays(14 + 30); _desk.ReturnCopy("b0"); Assert.Equal(Result.FinesOwed, _desk.Checkout(Ana, "b1")); Assert.Equal(750, _desk.Pay(Ana, 1000)); // never takes more than is owed Assert.Equal(Result.Ok, _desk.Checkout(Ana, "b1"));}
[Fact]public void AReturnedCopyIsSetAsideForTheFirstMemberWaiting(){ _desk.Checkout(Ana, "dune-1"); _desk.Checkout(Ben, "dune-2"); Assert.Equal(Result.Ok, _desk.PlaceHold(Cy, Dune.Isbn)); _desk.ReturnCopy("dune-1"); Assert.Equal(new[] { "cy:isbn-dune:2026-03-05" }, _notifier.Sent); Assert.Equal(Result.HeldForSomeoneElse, _desk.Checkout(Ana, "dune-1")); Assert.Equal(Result.Ok, _desk.Checkout(Cy, "dune-1"));}
[Fact]public void AHoldIsRefusedWhileACopyIsOnTheShelf(){ _desk.Checkout(Ana, "dune-1"); Assert.Equal(Result.CopyAvailable, _desk.PlaceHold(Ben, Dune.Isbn));}
[Fact]public void RenewalsStopWhenSomeoneIsWaitingOrAtTheLimit(){ _desk.Checkout(Ana, "b0"); Assert.Equal(Result.Ok, _desk.Renew(Ana, "b0")); Assert.Equal(Result.Ok, _desk.Renew(Ana, "b0")); Assert.Equal(Result.RenewalLimit, _desk.Renew(Ana, "b0")); _desk.Checkout(Ana, "dune-1"); _desk.Checkout(Ben, "dune-2"); _desk.PlaceHold(Cy, Dune.Isbn); Assert.Equal(Result.HoldWaiting, _desk.Renew(Ana, "dune-1"));}
[Fact]public 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.AddDays(4); Assert.Equal(1, _desk.ExpireHolds()); // Ben didn't come; Cy is next Assert.Equal(Result.HeldForSomeoneElse, _desk.Checkout(Ben, "dune-1")); _today = _today.AddDays(4); Assert.Equal(1, _desk.ExpireHolds()); // nobody else waiting: back on the shelf Assert.Equal(Result.Ok, _desk.Checkout(Ben, "dune-1"));}
[Fact]public async Task RacingKiosksNeverPushAMemberOverTheLimit(){ var shared = new SharedDesk(_desk); for (var i = 0; i < 4; i++) shared.Checkout(Ana, $"b{i}"); var start = new TaskCompletionSource(); var tries = Enumerable.Range(4, 16) .Select(i => Task.Run(async () => { await start.Task; return shared.Checkout(Ana, $"b{i}"); })) .ToArray(); start.SetResult(); // release all sixteen at once var results = await Task.WhenAll(tries); Assert.Single(results, r => r == Result.Ok); Assert.Equal(5, shared.LoanCount(Ana));}// fakeNotifier records each "your hold is ready" message instead of sending it.type fakeNotifier struct{ sent []string }
func (f *fakeNotifier) HoldReady(m Member, b Book, until time.Time) { f.sent = append(f.sent, m.ID+":"+b.ISBN+":"+until.Format("2006-01-02"))}
var ( dune = Book{"isbn-dune", "Dune", "Frank Herbert"} ana = Member{"ana", "Ana", Student} ben = Member{"ben", "Ben", Student} cy = Member{"cy", "Cy", Student})
type world struct { today time.Time notifier *fakeNotifier catalog *Catalog desk *LoanDesk}
// newWorld has two copies of Dune, and twenty other books with one copy each.func newWorld() *world { w := &world{today: time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC), notifier: &fakeNotifier{}, catalog: NewCatalog()} w.catalog.Add(Copy{"dune-1", dune}) w.catalog.Add(Copy{"dune-2", dune}) for i := range 20 { w.catalog.Add(Copy{fmt.Sprintf("b%d", i), Book{fmt.Sprintf("isbn-%d", i), fmt.Sprintf("Book %d", i), "Author"}}) } w.desk = NewLoanDesk(w.catalog, StandardPolicy{}, w.notifier, func() time.Time { return w.today }) return w}
func (w *world) advance(days int) { w.today = w.today.AddDate(0, 0, days) }
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestSearchFindsTheBookOnceAndEachCopyLendsSeparately(t *testing.T) { w := newWorld() found := w.catalog.Search("herbert") expect(t, 1, len(found)) expect(t, dune, found[0]) expect(t, OK, w.desk.Checkout(ana, "dune-1")) expect(t, NotAvailable, w.desk.Checkout(ben, "dune-1")) expect(t, OK, w.desk.Checkout(ben, "dune-2"))}
func TestAStudentStopsAtFiveBooks(t *testing.T) { w := newWorld() for i := range 5 { expect(t, OK, w.desk.Checkout(ana, fmt.Sprintf("b%d", i))) } expect(t, LimitReached, w.desk.Checkout(ana, "b5"))}
func TestAnOverdueBookBlocksNewLoans(t *testing.T) { w := newWorld() w.desk.Checkout(ana, "b0") w.advance(15) expect(t, HasOverdue, w.desk.Checkout(ana, "b1"))}
func TestALateReturnIsFinedPerDayUpToTheCap(t *testing.T) { w := newWorld() w.desk.Checkout(ana, "b0") w.desk.Checkout(ben, "b1") w.advance(14 + 3) w.desk.ReturnCopy("b0") expect(t, int64(75), w.desk.Owed(ana)) w.advance(200) w.desk.ReturnCopy("b1") expect(t, int64(2000), w.desk.Owed(ben))}
func TestFinesOverTheLimitBlockLoansUntilPaid(t *testing.T) { w := newWorld() w.desk.Checkout(ana, "b0") w.advance(14 + 30) w.desk.ReturnCopy("b0") expect(t, FinesOwed, w.desk.Checkout(ana, "b1")) expect(t, int64(750), w.desk.Pay(ana, 1000)) // never takes more than is owed expect(t, OK, w.desk.Checkout(ana, "b1"))}
func TestAReturnedCopyIsSetAsideForTheFirstMemberWaiting(t *testing.T) { w := newWorld() w.desk.Checkout(ana, "dune-1") w.desk.Checkout(ben, "dune-2") expect(t, OK, w.desk.PlaceHold(cy, dune.ISBN)) w.desk.ReturnCopy("dune-1") expect(t, "[cy:isbn-dune:2026-03-05]", fmt.Sprint(w.notifier.sent)) expect(t, HeldForSomeoneElse, w.desk.Checkout(ana, "dune-1")) expect(t, OK, w.desk.Checkout(cy, "dune-1"))}
func TestAHoldIsRefusedWhileACopyIsOnTheShelf(t *testing.T) { w := newWorld() w.desk.Checkout(ana, "dune-1") expect(t, CopyAvailable, w.desk.PlaceHold(ben, dune.ISBN))}
func TestRenewalsStopWhenSomeoneIsWaitingOrAtTheLimit(t *testing.T) { w := newWorld() w.desk.Checkout(ana, "b0") expect(t, OK, w.desk.Renew(ana, "b0")) expect(t, OK, w.desk.Renew(ana, "b0")) expect(t, RenewalLimit, w.desk.Renew(ana, "b0")) w.desk.Checkout(ana, "dune-1") w.desk.Checkout(ben, "dune-2") w.desk.PlaceHold(cy, dune.ISBN) expect(t, HoldWaiting, w.desk.Renew(ana, "dune-1"))}
func TestAnUncollectedHoldPassesToTheNextMemberThenToTheShelf(t *testing.T) { w := newWorld() w.desk.Checkout(ana, "dune-1") w.desk.Checkout(ana, "dune-2") w.desk.PlaceHold(ben, dune.ISBN) w.desk.PlaceHold(cy, dune.ISBN) w.desk.ReturnCopy("dune-1") // set aside for Ben until 2026-03-05 w.advance(4) expect(t, 1, w.desk.ExpireHolds()) // Ben didn't come; Cy is next expect(t, HeldForSomeoneElse, w.desk.Checkout(ben, "dune-1")) w.advance(4) expect(t, 1, w.desk.ExpireHolds()) // nobody else waiting: back on the shelf expect(t, OK, w.desk.Checkout(ben, "dune-1"))}
func TestRacingKiosksNeverPushAMemberOverTheLimit(t *testing.T) { w := newWorld() shared := NewSharedDesk(w.desk) for i := range 4 { shared.Checkout(ana, fmt.Sprintf("b%d", i)) } var ok atomic.Int32 var wg sync.WaitGroup start := make(chan struct{}) for i := 4; i < 20; i++ { wg.Add(1) go func() { defer wg.Done() <-start if shared.Checkout(ana, fmt.Sprintf("b%d", i)) == OK { ok.Add(1) } }() } close(start) wg.Wait() expect(t, int32(1), ok.Load()) expect(t, 5, shared.LoanCount(ana))}import { test } from 'node:test';import assert from 'node:assert/strict';
// Records each "your hold is ready" message instead of sending it.class FakeNotifier implements Notifier { sent: string[] = []; holdReady(m: Member, b: Book, until: Day): void { this.sent.push(`${m.id}:${b.isbn}:${until}`); }}
const DUNE: Book = { isbn: 'isbn-dune', title: 'Dune', author: 'Frank Herbert' };const ANA: Member = { id: 'ana', name: 'Ana', type: 'student' };const BEN: Member = { id: 'ben', name: 'Ben', type: 'student' };const CY: Member = { id: 'cy', name: 'Cy', type: 'student' };
// Two copies of Dune, and twenty other books with one copy each. Day 100 is "today".function world() { const w = { today: 100, notifier: new FakeNotifier(), catalog: new Catalog() } as { today: Day; notifier: FakeNotifier; catalog: Catalog; desk: LoanDesk; }; w.catalog.add({ barcode: 'dune-1', book: DUNE }); w.catalog.add({ barcode: 'dune-2', book: DUNE }); for (let i = 0; i < 20; i++) { w.catalog.add({ barcode: `b${i}`, book: { isbn: `isbn-${i}`, title: `Book ${i}`, author: 'Author' } }); } w.desk = new LoanDesk(w.catalog, new StandardPolicy(), w.notifier, () => w.today); return w;}
test('search finds the book once and each copy lends separately', () => { const { catalog, desk } = world(); assert.deepEqual(catalog.search('herbert'), [DUNE]); assert.equal(desk.checkout(ANA, 'dune-1'), 'ok'); assert.equal(desk.checkout(BEN, 'dune-1'), 'not-available'); assert.equal(desk.checkout(BEN, 'dune-2'), 'ok');});
test('a student stops at five books', () => { const { desk } = world(); for (let i = 0; i < 5; i++) assert.equal(desk.checkout(ANA, `b${i}`), 'ok'); assert.equal(desk.checkout(ANA, 'b5'), 'limit-reached');});
test('an overdue book blocks new loans', () => { const w = world(); w.desk.checkout(ANA, 'b0'); w.today += 15; assert.equal(w.desk.checkout(ANA, 'b1'), 'has-overdue');});
test('a late return is fined per day up to the cap', () => { const w = world(); w.desk.checkout(ANA, 'b0'); w.desk.checkout(BEN, 'b1'); w.today += 14 + 3; w.desk.returnCopy('b0'); assert.equal(w.desk.owed(ANA), 75); w.today += 200; w.desk.returnCopy('b1'); assert.equal(w.desk.owed(BEN), 2000);});
test('fines over the limit block loans until paid', () => { const w = world(); w.desk.checkout(ANA, 'b0'); w.today += 14 + 30; w.desk.returnCopy('b0'); assert.equal(w.desk.checkout(ANA, 'b1'), 'fines-owed'); assert.equal(w.desk.pay(ANA, 1000), 750); // never takes more than is owed assert.equal(w.desk.checkout(ANA, 'b1'), 'ok');});
test('a returned copy is set aside for the first member waiting', () => { const { desk, notifier } = world(); desk.checkout(ANA, 'dune-1'); desk.checkout(BEN, 'dune-2'); assert.equal(desk.placeHold(CY, DUNE.isbn), 'ok'); desk.returnCopy('dune-1'); assert.deepEqual(notifier.sent, ['cy:isbn-dune:103']); assert.equal(desk.checkout(ANA, 'dune-1'), 'held-for-someone-else'); assert.equal(desk.checkout(CY, 'dune-1'), 'ok');});
test('a hold is refused while a copy is on the shelf', () => { const { desk } = world(); desk.checkout(ANA, 'dune-1'); assert.equal(desk.placeHold(BEN, DUNE.isbn), 'copy-available');});
test('renewals stop when someone is waiting or at the limit', () => { const { desk } = world(); desk.checkout(ANA, 'b0'); assert.equal(desk.renew(ANA, 'b0'), 'ok'); assert.equal(desk.renew(ANA, 'b0'), 'ok'); assert.equal(desk.renew(ANA, 'b0'), 'renewal-limit'); desk.checkout(ANA, 'dune-1'); desk.checkout(BEN, 'dune-2'); desk.placeHold(CY, DUNE.isbn); assert.equal(desk.renew(ANA, 'dune-1'), 'hold-waiting');});
test('an uncollected hold passes to the next member, then to the shelf', () => { const w = world(); w.desk.checkout(ANA, 'dune-1'); w.desk.checkout(ANA, 'dune-2'); w.desk.placeHold(BEN, DUNE.isbn); w.desk.placeHold(CY, DUNE.isbn); w.desk.returnCopy('dune-1'); // set aside for Ben until day 103 w.today += 4; assert.equal(w.desk.expireHolds(), 1); // Ben didn't come; Cy is next assert.equal(w.desk.checkout(BEN, 'dune-1'), 'held-for-someone-else'); w.today += 4; assert.equal(w.desk.expireHolds(), 1); // nobody else waiting: back on the shelf assert.equal(w.desk.checkout(BEN, 'dune-1'), 'ok');});
// No race test here: the in-memory desk is synchronous, so two "kiosks" can't interleave in// one Node process. The database version is tested against a real database.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 |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| 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.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 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. Codecheckout,returnCopyandroute, 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.
Follow-up questions
Section titled “Follow-up questions”Now there are five branches. A member can borrow from one and return to another.
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.
- A copy gets a home and a location.
homeis the branch that owns it.locationis where it is right now: a branch, or “in transit” between two. - 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. routelooks 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.- 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.
Further reading
Section titled “Further reading”- Strategy pattern, Observer pattern, Decorator pattern
- Object modeling, on telling entities from values
- Design an ATM, another design where the order of a check and an act is the whole question
- Design a parking lot, the same “a spot is not a car” split between a type and a thing
- PostgreSQL: explicit locking, for the
SELECT ... FOR UPDATEin the TypeScript tab
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.