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
- Add a
renew()method toLoanthat extends the due date by 14 days (max 2 renewals). - Add a
fee_owedproperty toLoanthat charges $0.25 per day overdue. - Add a
report()method toLibrarythat prints a summary of all patrons with overdue books. - Add JSON serialization (
to_dictandfrom_dict) to all three classes so the library can save and load its state. - What would change if the library wanted to track multiple copies of the same book? Which classes would need modification?