All chapters in this track
- 01Chapter 1: Why Object Orientation Exists
- 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
- 03Chapter 3: The Four Pillars I: Encapsulation
- 04Chapter 4: The Four Pillars II: Abstraction
- 05Chapter 5: The Four Pillars III: Inheritance
- 06Chapter 6: The Four Pillars IV: Polymorphism
- 07Chapter 7: Composition vs. Inheritance
- 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
- 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
- 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
- 11Chapter 11: Basic UML for Communicating Design
- 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
- 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
- 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
- 15Chapter 15: Design Exercise: Parking Lot System
- 16Chapter 16: Design Exercise: Library Management System
- 17Chapter 17: Design Exercise: Elevator Controller
- 18Chapter 18: OOP in Interviews: What a Strong Answer Looks Like
Chapter 16 of 18
Chapter 16: Design Exercise: Library Management System
Why this matters
The library system exercise is where Chapter 11's UML relationships (composition vs. aggregation, specifically) become unavoidable — a Book genuinely outlives any one LibraryBranch, which is a different lifetime relationship than the parking lot's Spots (which make no sense outside their Floor). It's also a good exercise for practicing SRP (Chapter 9): a naive design tangles "catalog," "lending," and "member" concerns into one class.
Requirements
- A library has multiple branches; each branch holds copies of books.
- Members can borrow and return books; each member has a borrowing limit.
- Overdue books accrue a fine.
- A book can exist in multiple branches, and a specific physical copy is what's actually borrowed (not the abstract "book" itself — two copies of the same title are two different lendable items).
- (Clarifying question a strong candidate asks: "Should the system support reservations/holds when a book is checked out?" — assume yes, since it changes whether checkout needs to check a queue, not just availability.)
The design
Class diagram, precisely distinguishing composition from aggregation (Chapter 11):
+----------------+ +----------------+
| Library | ◆────── | LibraryBranch | composition: a branch is destroyed
+----------------+ 1 * +----------------+ with its Library
|
| ◇ 1 *
v
+----------------+
| BookCopy | aggregation: a copy is tied to one
+----------------+ branch's inventory, but the abstract
| Book it represents exists independently
| ───── * 1
v
+----------------+
| Book | (title, author, ISBN — catalog data)
+----------------+
+----------------+ +----------------+
| Member | ─────── | Loan | association: a loan links a member
+----------------+ +----------------+ to a specific BookCopy for a period
|
| ───── 1
v
+----------------+
| BookCopy |
+----------------+
Note the deliberate distinction: Book (the catalog entry — title, author, ISBN) is associated with, not composed by, BookCopy (the specific lendable physical item) — a Book's catalog data persists even if every physical copy is lost or removed, so it isn't owned by any one copy.
SRP applied (Chapter 9): catalog, lending, and membership are separate responsibilities:
class Book {
constructor(
public readonly isbn: string,
public readonly title: string,
public readonly author: string
) {}
}
class BookCopy {
private status: "available" | "checked_out" = "available";
constructor(public readonly id: string, public readonly book: Book) {}
isAvailable(): boolean { return this.status === "available"; }
markCheckedOut(): void { this.status = "checked_out"; }
markReturned(): void { this.status = "available"; }
}
class Member {
private activeLoans: Loan[] = [];
constructor(public readonly id: string, private readonly loanLimit: number) {}
canBorrow(): boolean { return this.activeLoans.length < this.loanLimit; }
addLoan(loan: Loan): void { this.activeLoans.push(loan); }
removeLoan(loan: Loan): void {
this.activeLoans = this.activeLoans.filter(l => l !== loan);
}
}
Encapsulation (Chapter 3): Loan protects its own overdue/fine calculation — no external code can compute a fine incorrectly, because there's only one method that does it:
class Loan {
private static readonly LOAN_PERIOD_DAYS = 14;
private static readonly FINE_PER_DAY_CENTS = 25;
private returnedAt: Date | null = null;
constructor(
public readonly member: Member,
public readonly copy: BookCopy,
private readonly borrowedAt: Date
) {}
returnBook(returnDate: Date): number {
if (this.returnedAt !== null) throw new Error("Loan already closed");
this.returnedAt = returnDate;
this.copy.markReturned();
this.member.removeLoan(this);
return this.calculateFineCents(returnDate);
}
private calculateFineCents(returnDate: Date): number {
const dueDate = new Date(this.borrowedAt);
dueDate.setDate(dueDate.getDate() + Loan.LOAN_PERIOD_DAYS);
const overdueDays = Math.max(0, Math.ceil((returnDate.getTime() - dueDate.getTime()) / 86_400_000));
return overdueDays * Loan.FINE_PER_DAY_CENTS;
}
}
A dedicated LendingService orchestrates across Member, BookCopy, and Loan — this cross-entity coordination is exactly the legitimate use of a service class flagged in Chapter 12's anemic-domain-model discussion, as distinct from logic that belongs on a single entity:
class LendingService {
private activeLoans: Map<string, Loan> = new Map(); // copyId -> Loan
private holdQueues: Map<string, Member[]> = new Map(); // bookIsbn -> waiting members
checkOut(member: Member, copy: BookCopy, date: Date): Loan {
if (!member.canBorrow()) throw new Error("Loan limit reached");
if (!copy.isAvailable()) throw new Error("Copy is not available");
copy.markCheckedOut();
const loan = new Loan(member, copy, date);
member.addLoan(loan);
this.activeLoans.set(copy.id, loan);
return loan;
}
placeHold(member: Member, book: Book): void {
const queue = this.holdQueues.get(book.isbn) ?? [];
queue.push(member);
this.holdQueues.set(book.isbn, queue);
}
returnCopy(copy: BookCopy, date: Date): number {
const loan = this.activeLoans.get(copy.id);
if (!loan) throw new Error("No active loan for this copy");
const fine = loan.returnBook(date);
this.activeLoans.delete(copy.id);
this.notifyNextInHold(copy.book);
return fine;
}
private notifyNextInHold(book: Book): void {
const queue = this.holdQueues.get(book.isbn);
if (queue && queue.length > 0) {
const next = queue.shift();
console.log(`Notifying ${next?.id} that a copy of "${book.title}" is available`);
}
}
}
from dataclasses import dataclass, field
from datetime import date, timedelta
@dataclass
class Book:
isbn: str
title: str
author: str
class BookCopy:
def __init__(self, copy_id: str, book: Book) -> None:
self.id = copy_id
self.book = book
self._available = True
def is_available(self) -> bool: return self._available
def mark_checked_out(self) -> None: self._available = False
def mark_returned(self) -> None: self._available = True
class Loan:
LOAN_PERIOD_DAYS = 14
FINE_PER_DAY_CENTS = 25
def __init__(self, member: "Member", copy: BookCopy, borrowed_at: date) -> None:
self.member = member
self.copy = copy
self._borrowed_at = borrowed_at
self._returned_at: date | None = None
def return_book(self, return_date: date) -> int:
if self._returned_at is not None:
raise ValueError("Loan already closed")
self._returned_at = return_date
self.copy.mark_returned()
self.member.remove_loan(self)
due_date = self._borrowed_at + timedelta(days=self.LOAN_PERIOD_DAYS)
overdue_days = max(0, (return_date - due_date).days)
return overdue_days * self.FINE_PER_DAY_CENTS
Trade-offs a strong candidate would raise
[SENIOR INSIGHT]
- Why
LendingServiceexists despite Chapter 12's warning about anemic models: the distinction is thatLoan,Member, andBookCopyeach still protect their own invariants (a loan can't be closed twice, a member can't exceed their limit) —LendingServiceonly coordinates across them (checking a hold queue, updating two objects' state together), which is legitimate cross-entity orchestration, not a bypass of any single entity's protections. - Hold queue fairness and concurrency: two members placing a hold at the same instant, or a copy being returned while a hold is being placed, are real race conditions worth naming (again, Chapter 14).
- Why
BookandBookCopyare separate classes: this is the composition-vs-aggregation distinction from Chapter 11 made concrete — collapsing them into one class would make it impossible to represent "3 copies of the same title," which the requirements explicitly need.
Common mistakes
- Collapsing
BookandBookCopyinto one class, which breaks the moment there's more than one physical copy of a title — a design that doesn't survive its own requirements. - Putting the loan limit check inside
LendingServiceinstead of onMember, scattering an invariant that belongs to the member across the orchestration layer (an anemic-model smell from Chapter 12, applied toMemberspecifically). - Forgetting the hold queue when designing checkout/return, then bolting it on afterward in a way that doesn't cleanly integrate with
returnCopy. - Not distinguishing "which entity protects its own invariant" from "which orchestrates across entities" — this is the core judgment call the trade-offs section above is built around.
Discussion / interview questions
-
"Why model
BookandBookCopyas two separate classes instead of one?" Strong answer: A library can hold multiple physical copies of the same title;Bookis catalog data (title, author, ISBN) that's independent of any specific copy's lending status — collapsing them can't represent multiple copies, which the requirements need. -
"Where does the fine calculation logic belong, and why?" Strong answer: On
Loanitself, since it's the entity whose own state (borrow date, return date) the calculation depends on — keeping it there means there's exactly one place that can compute a fine, and it can't be bypassed (ties to Chapter 3's encapsulation and Chapter 1's invariant-protection theme). -
"Does
LendingServiceviolate the anemic-domain-model warning from Chapter 12?" Strong answer: No, as long as each entity (Member,Loan,BookCopy) still enforces its own invariants;LendingServiceonly orchestrates across multiple entities (checking a hold queue when a copy is returned) — that's legitimate cross-entity coordination, distinct from moving an entity's own invariant logic outside of it. -
"How would you extend this design to support hold expiration (a held copy is released back to the pool if not picked up in 48 hours)?" Strong answer: Proposes adding an expiration timestamp when a hold is fulfilled and a copy is reserved, and a mechanism (scheduled check or lazy check-on-access) to release it — reasoning about where that state and logic belongs (likely a new small
Holdentity, rather than overloadingLoanorBookCopy).
Chapter summary / cheat sheet
Book(catalog data) andBookCopy(a specific lendable item) are separate classes, related by association — not composition — because a title outlives any one copy and a library can hold multiple copies of one title.Library ◆ LibraryBranchis composition (branches die with the library);LibraryBranch ◇ BookCopyis aggregation (a copy's inventory-tracking lifetime is looser than the branch's).- Each entity (
Member,Loan,BookCopy) protects its own invariant;LendingServiceexists only to coordinate across entities (hold queues, multi-entity state changes) — this is the line between legitimate orchestration and an anemic domain model. - Watch for the same recurring concurrency and premature-abstraction judgment calls from Chapters 12 and 14, now applied in a design-exercise context.