Vasu Patel logoVasu Patel
← Objects First: OOP Fundamentals
All chapters in this track
  1. 01Chapter 1: Why Object Orientation Exists
  2. 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
  3. 03Chapter 3: The Four Pillars I: Encapsulation
  4. 04Chapter 4: The Four Pillars II: Abstraction
  5. 05Chapter 5: The Four Pillars III: Inheritance
  6. 06Chapter 6: The Four Pillars IV: Polymorphism
  7. 07Chapter 7: Composition vs. Inheritance
  8. 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
  9. 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
  10. 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
  11. 11Chapter 11: Basic UML for Communicating Design
  12. 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
  13. 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
  14. 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
  15. 15Chapter 15: Design Exercise: Parking Lot System
  16. 16Chapter 16: Design Exercise: Library Management System
  17. 17Chapter 17: Design Exercise: Elevator Controller
  18. 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 LendingService exists despite Chapter 12's warning about anemic models: the distinction is that Loan, Member, and BookCopy each still protect their own invariants (a loan can't be closed twice, a member can't exceed their limit) — LendingService only 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 Book and BookCopy are 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

  1. Collapsing Book and BookCopy into 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.
  2. Putting the loan limit check inside LendingService instead of on Member, scattering an invariant that belongs to the member across the orchestration layer (an anemic-model smell from Chapter 12, applied to Member specifically).
  3. Forgetting the hold queue when designing checkout/return, then bolting it on afterward in a way that doesn't cleanly integrate with returnCopy.
  4. 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

  1. "Why model Book and BookCopy as two separate classes instead of one?" Strong answer: A library can hold multiple physical copies of the same title; Book is 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.

  2. "Where does the fine calculation logic belong, and why?" Strong answer: On Loan itself, 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).

  3. "Does LendingService violate the anemic-domain-model warning from Chapter 12?" Strong answer: No, as long as each entity (Member, Loan, BookCopy) still enforces its own invariants; LendingService only 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.

  4. "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 Hold entity, rather than overloading Loan or BookCopy).

Chapter summary / cheat sheet

  • Book (catalog data) and BookCopy (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 ◆ LibraryBranch is composition (branches die with the library); LibraryBranch ◇ BookCopy is aggregation (a copy's inventory-tracking lifetime is looser than the branch's).
  • Each entity (Member, Loan, BookCopy) protects its own invariant; LendingService exists 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.