Case Study: Modeling a Library System

This case study walks through the design process of building an OOP library management system. The scenario is fictional but represents common design decisions that arise in real library software projects.

The Scenario

Maplewood Public Library needs a simple system to track their collection. Currently, the librarian, Dana, uses a spreadsheet with columns for title, author, ISBN, availability, and borrower name. The spreadsheet has 3,200 rows and is becoming unmanageable — sorting is slow, tracking who has what is error-prone, and there's no way to see overdue books at a glance.

Your job: design a Python prototype that models the core entities and operations. This isn't about building a full application yet — it's about getting the class design right.

Step 1: Identify the "Things" (Nouns = Classes)

Before writing any code, ask: what are the real-world things this system needs to model?

Reading through Dana's requirements: - Books (tracked individually) - Patrons (library card holders) - Loans (a patron borrowing a book)

Three nouns, three potential classes.

Step 2: What Does Each Thing Know? (Attributes)

Book: - Title, author, ISBN - Whether it's currently available - Genre/category

Patron: - Name, library card number - List of current loans - Contact email

Loan: - Which book, which patron - Date borrowed, due date - Whether it's been returned

Step 3: What Can Each Thing Do? (Methods)

Book: - Display itself nicely - Check out (mark as unavailable) - Return (mark as available)

Patron: - Borrow a book (creates a loan) - Return a book (completes a loan) - List current loans

Loan: - Calculate if overdue - Complete (mark as returned) - Display itself

Step 4: Implementation

The Book Class

class Book:
    """A book in the library's collection."""

    def __init__(self, title, author, isbn, genre="General"):
        self.title = title
        self.author = author
        self.isbn = isbn
        self.genre = genre
        self.available = True

    def check_out(self):
        """Mark book as checked out."""
        if not self.available:
            raise ValueError(f"'{self.title}' is already checked out")
        self.available = False

    def return_book(self):
        """Mark book as returned/available."""
        self.available = True

    def __str__(self):
        status = "Available" if self.available else "Checked Out"
        return f"'{self.title}' by {self.author} [{status}]"

    def __repr__(self):
        return f"Book(title={self.title!r}, author={self.author!r}, isbn={self.isbn!r})"

    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.isbn == other.isbn  # ISBN is the unique identifier

Design decision: We use ISBN for equality comparison. Two Book objects with the same ISBN are considered the same book, even if other fields differ (e.g., a corrected typo in the title). This reflects how real libraries work — ISBN is the canonical identifier.

The Loan Class

from datetime import date, timedelta


class Loan:
    """A record of a patron borrowing a book."""

    DEFAULT_LOAN_DAYS = 14

    def __init__(self, book, patron, loan_days=None):
        self.book = book
        self.patron = patron
        self.borrow_date = date.today()
        days = loan_days if loan_days is not None else self.DEFAULT_LOAN_DAYS
        self.due_date = self.borrow_date + timedelta(days=days)
        self.returned = False
        self.return_date = None

    @property
    def is_overdue(self):
        """Check if the loan is past its due date."""
        if self.returned:
            return False
        return date.today() > self.due_date

    @property
    def days_remaining(self):
        """Days until due (negative if overdue)."""
        if self.returned:
            return 0
        return (self.due_date - date.today()).days

    def complete(self):
        """Mark the loan as returned."""
        self.returned = True
        self.return_date = date.today()
        self.book.return_book()

    def __str__(self):
        status = "Returned" if self.returned else "Active"
        overdue = " [OVERDUE]" if self.is_overdue else ""
        return (f"Loan: '{self.book.title}' to {self.patron.name} "
                f"(due {self.due_date}) [{status}]{overdue}")

Design decision: is_overdue and days_remaining are properties, not regular methods. They're computed values that feel like attributes — you'd naturally say "is this loan overdue?" not "calculate this loan's overdue status." The @property decorator makes the interface intuitive: loan.is_overdue reads better than loan.is_overdue().

The Patron Class

class Patron:
    """A library card holder."""

    MAX_LOANS = 5

    def __init__(self, name, card_number, email=""):
        self.name = name
        self.card_number = card_number
        self.email = email
        self._loans = []  # Protected — managed through methods

    def borrow(self, book, loan_days=None):
        """Borrow a book from the library."""
        if len(self.active_loans) >= self.MAX_LOANS:
            raise ValueError(
                f"{self.name} has reached the maximum of {self.MAX_LOANS} loans"
            )
        if not book.available:
            raise ValueError(f"'{book.title}' is not available")
        book.check_out()
        loan = Loan(book, self, loan_days)
        self._loans.append(loan)
        return loan

    def return_book(self, book):
        """Return a borrowed book."""
        for loan in self._loans:
            if loan.book == book and not loan.returned:
                loan.complete()
                return loan
        raise ValueError(f"{self.name} does not have '{book.title}' checked out")

    @property
    def active_loans(self):
        """Get list of unreturned loans."""
        return [loan for loan in self._loans if not loan.returned]

    @property
    def overdue_loans(self):
        """Get list of overdue loans."""
        return [loan for loan in self._loans if loan.is_overdue]

    def __str__(self):
        active = len(self.active_loans)
        return f"{self.name} (Card #{self.card_number}) — {active} active loan(s)"

    def __repr__(self):
        return f"Patron(name={self.name!r}, card_number={self.card_number!r})"

Design decision: _loans is protected (single underscore). External code shouldn't modify the loan list directly — it should use borrow() and return_book(), which enforce business rules (max loans, availability checks). This is encapsulation in practice: the Patron controls how its loan data is modified.

The Library Class (Bringing It Together)

class Library:
    """A library that manages books, patrons, and loans."""

    def __init__(self, name):
        self.name = name
        self._books = {}      # isbn -> Book
        self._patrons = {}    # card_number -> Patron

    def add_book(self, book):
        """Add a book to the collection."""
        if book.isbn in self._books:
            raise ValueError(f"Book with ISBN {book.isbn} already exists")
        self._books[book.isbn] = book

    def add_patron(self, patron):
        """Register a new patron."""
        if patron.card_number in self._patrons:
            raise ValueError(
                f"Patron with card #{patron.card_number} already exists"
            )
        self._patrons[patron.card_number] = patron

    def search_books(self, keyword):
        """Search books by title or author (case-insensitive)."""
        keyword_lower = keyword.lower()
        return [
            book for book in self._books.values()
            if keyword_lower in book.title.lower()
            or keyword_lower in book.author.lower()
        ]

    def available_books(self):
        """Get all currently available books."""
        return [b for b in self._books.values() if b.available]

    def all_overdue(self):
        """Get all overdue loans across all patrons."""
        overdue = []
        for patron in self._patrons.values():
            overdue.extend(patron.overdue_loans)
        return overdue

    def __len__(self):
        return len(self._books)

    def __str__(self):
        total = len(self._books)
        available = len(self.available_books())
        patrons = len(self._patrons)
        return (f"{self.name}: {total} books ({available} available), "
                f"{patrons} patrons")

Step 5: Testing the Design

# Set up the library
lib = Library("Maplewood Public Library")

# Add books
book1 = Book("The Great Gatsby", "F. Scott Fitzgerald", "978-0743273565", "Fiction")
book2 = Book("Clean Code", "Robert C. Martin", "978-0132350884", "Technology")
book3 = Book("Dune", "Frank Herbert", "978-0441013593", "Science Fiction")

lib.add_book(book1)
lib.add_book(book2)
lib.add_book(book3)

# Add patrons
dana = Patron("Dana Torres", "LIB-001", "dana@email.com")
marcus = Patron("Marcus Lee", "LIB-002", "marcus@email.com")

lib.add_patron(dana)
lib.add_patron(marcus)

# Borrow books
dana.borrow(book1)
marcus.borrow(book2)

# Check library status
print(lib)
print()

# Search
results = lib.search_books("dune")
for book in results:
    print(f"  Found: {book}")
print()

# Show patron status
print(dana)
for loan in dana.active_loans:
    print(f"  {loan}")
print()

# Return a book
dana.return_book(book1)
print(f"After return: {book1}")
print(dana)

Output:

Maplewood Public Library: 3 books (1 available), 2 patrons

  Found: 'Dune' by Frank Herbert [Available]

Dana Torres (Card #LIB-001) — 1 active loan(s)
  Loan: 'The Great Gatsby' to Dana Torres (due 2026-03-28) [Active]

After return: 'The Great Gatsby' by F. Scott Fitzgerald [Available]
Dana Torres (Card #LIB-001) — 0 active loan(s)

Analysis: Design Decisions Worth Noting

1. Objects Reference Each Other

A Loan holds references to both a Book and a Patron. This is natural — a loan connects two entities. In the procedural approach, you'd track this relationship with IDs and lookups. With OOP, the relationship is direct.

2. Business Logic Lives Where the Data Lives

The rule "a patron can't have more than 5 loans" is enforced inside Patron.borrow(). The rule "a book can't be checked out twice" is enforced inside Book.check_out(). Each class guards its own constraints. No external code needs to remember to check these rules.

3. Properties vs. Methods

is_overdue is a property because it's a yes/no characteristic of the loan — it reads naturally as a description. borrow() is a method because it performs an action that changes state. The distinction isn't always clear-cut, but asking "is this a question about the object or an action the object performs?" usually points you the right way.

4. What's Missing

This prototype doesn't handle: - Persistent storage (saving to a file or database) - Multiple copies of the same book (each copy would need its own tracking) - Renewal of loans - Fees for overdue books - Patron authentication

Each of these would be a natural next step — and each maps cleanly to extending the existing classes or adding new ones. That's the payoff of good OOP design: the structure is ready to grow.

Exercises Based on This Case Study

  1. Add a renew() method to Loan that extends the due date by 14 days (max 2 renewals).
  2. Add a fee_owed property to Loan that charges $0.25 per day overdue.
  3. Add a report() method to Library that prints a summary of all patrons with overdue books.
  4. Add JSON serialization (to_dict and from_dict) to all three classes so the library can save and load its state.
  5. What would change if the library wanted to track multiple copies of the same book? Which classes would need modification?