Chapter 20: Intermediate OOP Project — Build a Mini Library System
The last three chapters gave you the full OOP toolkit: classes, inheritance, polymorphism, encapsulation, properties, and dunder methods. Now it's time to use all of it together.
This chapter builds a complete mini library system from scratch — the kind of system a small library or bookstore might use. You'll design the classes, implement them step by step, connect them, add file persistence, and end up with a real working program.
Unlike the quick examples in previous chapters, this one is a full project. Take your time with it. Read each step, understand the design decisions, then type it out yourself.
What We're Building
A library management system with:
- Books — with title, author, ISBN, and availability status
- Digital books — extend Book with file format and download size
- Members — registered library members who borrow and return books
- Library — the main system that manages everything
Features:
- Check books in and out
- Search by title, author, or ISBN
- Show a member's borrowing history
- Save and load the entire state to/from a JSON file
- A simple menu-driven interface
Step 1: Design the Classes First
Before writing code, sketch what each class needs:
Book
├── isbn (str) — unique identifier
├── title (str)
├── author (str)
├── year (int)
├── available (bool) — is it on the shelf?
├── borrower (str|None) — member_id if borrowed
├── checkout(member_id) -> None
├── return_book() -> None
└── to_dict() / from_dict() — for JSON persistence
DigitalBook(Book)
├── file_format (str) — "EPUB", "PDF", etc.
├── size_mb (float)
└── download() -> str
Member
├── member_id (str)
├── name (str)
├── email (str)
├── borrowed (list[str]) — list of ISBNs currently borrowed
├── history (list[dict]) — full borrowing history
├── borrow(isbn) -> None
├── return_book(isbn) -> None
└── to_dict() / from_dict()
Library
├── name (str)
├── books (dict[isbn -> Book])
├── members (dict[member_id -> Member])
├── add_book(book)
├── remove_book(isbn)
├── register_member(member)
├── checkout(member_id, isbn)
├── return_book(member_id, isbn)
├── search(query) -> list[Book]
├── save(filepath)
└── load(filepath)
Good design starts on paper (or a text file), not in code. Now let's build it.
Step 2: The Book Class
# library_system.py
from datetime import datetime
class Book:
"""Represents a physical book in the library."""
def __init__(self, isbn, title, author, year):
self.isbn = isbn
self.title = title
self.author = author
self.year = year
self.available = True
self.borrower = None # member_id of current borrower
def checkout(self, member_id):
"""Mark the book as checked out by member_id."""
if not self.available:
raise ValueError(
f"'{self.title}' is already checked out by member {self.borrower}."
)
self.available = False
self.borrower = member_id
def return_book(self):
"""Mark the book as returned."""
if self.available:
raise ValueError(f"'{self.title}' is not currently checked out.")
self.available = True
self.borrower = None
def to_dict(self):
"""Serialize to a dictionary for JSON storage."""
return {
"type": "Book",
"isbn": self.isbn,
"title": self.title,
"author": self.author,
"year": self.year,
"available": self.available,
"borrower": self.borrower,
}
@classmethod
def from_dict(cls, data):
"""Reconstruct a Book from a dictionary."""
book = cls(data["isbn"], data["title"], data["author"], data["year"])
book.available = data["available"]
book.borrower = data["borrower"]
return book
def __str__(self):
status = "Available" if self.available else f"Checked out (member: {self.borrower})"
return f"[{self.isbn}] {self.title} by {self.author} ({self.year}) — {status}"
def __repr__(self):
return f"Book({self.isbn!r}, {self.title!r})"
def __eq__(self, other):
if not isinstance(other, Book):
return NotImplemented
return self.isbn == other.isbn
Test it:
b1 = Book("978-0-06-112008-4", "To Kill a Mockingbird", "Harper Lee", 1960)
b2 = Book("978-0-7432-7356-5", "1984", "George Orwell", 1949)
print(b1)
b1.checkout("M001")
print(b1)
b1.return_book()
print(b1)
Output:
[978-0-06-112008-4] To Kill a Mockingbird by Harper Lee (1960) — Available
[978-0-06-112008-4] To Kill a Mockingbird by Harper Lee (1960) — Checked out (member: M001)
[978-0-06-112008-4] To Kill a Mockingbird by Harper Lee (1960) — Available
Step 3: The DigitalBook Subclass
class DigitalBook(Book):
"""A book available in digital format — always available (no checkout limit)."""
VALID_FORMATS = {"EPUB", "PDF", "MOBI", "AZW3"}
def __init__(self, isbn, title, author, year, file_format, size_mb):
super().__init__(isbn, title, author, year)
if file_format.upper() not in self.VALID_FORMATS:
raise ValueError(
f"Invalid format {file_format!r}. Must be one of {self.VALID_FORMATS}."
)
self.file_format = file_format.upper()
self.size_mb = float(size_mb)
def checkout(self, member_id):
"""Digital books can be borrowed by multiple people simultaneously."""
self.borrower = member_id # track last downloader
# available stays True — digital copies are unlimited
def return_book(self):
"""Returning a digital book — nothing to do physically."""
pass # nothing changes for digital
def download(self):
"""Simulate downloading the book."""
return (
f"Downloading '{self.title}' ({self.file_format}, "
f"{self.size_mb:.1f} MB)... Done."
)
def to_dict(self):
data = super().to_dict()
data["type"] = "DigitalBook"
data["file_format"] = self.file_format
data["size_mb"] = self.size_mb
return data
@classmethod
def from_dict(cls, data):
book = cls(
data["isbn"], data["title"], data["author"], data["year"],
data["file_format"], data["size_mb"]
)
book.available = data["available"]
book.borrower = data["borrower"]
return book
def __str__(self):
return (
f"[{self.isbn}] {self.title} by {self.author} ({self.year}) "
f"[{self.file_format}, {self.size_mb:.1f} MB]"
)
def __repr__(self):
return f"DigitalBook({self.isbn!r}, {self.title!r}, {self.file_format!r})"
Test it:
db = DigitalBook("978-0-14-028329-7", "The Great Gatsby", "F. Scott Fitzgerald",
1925, "EPUB", 0.8)
print(db)
print(db.download())
Output:
[978-0-14-028329-7] The Great Gatsby by F. Scott Fitzgerald (1925) [EPUB, 0.8 MB]
Downloading 'The Great Gatsby' (EPUB, 0.8 MB)... Done.
Step 4: The Member Class
class Member:
"""A registered library member."""
MAX_BOOKS = 5 # class attribute — borrowing limit
def __init__(self, member_id, name, email):
self.member_id = member_id
self.name = name
self.email = email
self._borrowed = [] # list of ISBNs currently borrowed
self._history = [] # full history of all transactions
@property
def borrowed(self):
"""Read-only view of currently borrowed ISBNs."""
return list(self._borrowed)
@property
def borrowed_count(self):
return len(self._borrowed)
def borrow(self, isbn):
if self.borrowed_count >= self.MAX_BOOKS:
raise ValueError(
f"{self.name} has reached the borrowing limit of {self.MAX_BOOKS} books."
)
if isbn in self._borrowed:
raise ValueError(f"{self.name} already has book {isbn!r}.")
self._borrowed.append(isbn)
self._history.append({
"action": "borrowed",
"isbn": isbn,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
})
def return_book(self, isbn):
if isbn not in self._borrowed:
raise ValueError(f"{self.name} does not have book {isbn!r} checked out.")
self._borrowed.remove(isbn)
self._history.append({
"action": "returned",
"isbn": isbn,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
})
def print_history(self):
print(f"\nBorrowing history for {self.name} ({self.member_id}):")
if not self._history:
print(" No transactions yet.")
return
for entry in self._history:
print(f" [{entry['timestamp']}] {entry['action'].capitalize()}: {entry['isbn']}")
def to_dict(self):
return {
"member_id": self.member_id,
"name": self.name,
"email": self.email,
"borrowed": self._borrowed,
"history": self._history,
}
@classmethod
def from_dict(cls, data):
m = cls(data["member_id"], data["name"], data["email"])
m._borrowed = data["borrowed"]
m._history = data["history"]
return m
def __str__(self):
return (f"Member({self.member_id}) {self.name} <{self.email}> "
f"--- {self.borrowed_count}/{self.MAX_BOOKS} books")
def __repr__(self):
return f"Member({self.member_id!r}, {self.name!r})"
Step 5: The Library Class
import json
from pathlib import Path
class Library:
"""The main library system."""
def __init__(self, name):
self.name = name
self._books = {} # isbn -> Book
self._members = {} # member_id -> Member
# ── Book management ──────────────────────────────────────────────────
def add_book(self, book):
if book.isbn in self._books:
raise ValueError(f"Book with ISBN {book.isbn!r} already exists.")
self._books[book.isbn] = book
print(f"Added: {book.title!r}")
def remove_book(self, isbn):
book = self._get_book(isbn)
if not book.available:
raise ValueError(f"Cannot remove '{book.title}' — it is currently checked out.")
del self._books[isbn]
print(f"Removed: {book.title!r}")
def _get_book(self, isbn):
if isbn not in self._books:
raise KeyError(f"No book with ISBN {isbn!r} found.")
return self._books[isbn]
# ── Member management ─────────────────────────────────────────────────
def register_member(self, member):
if member.member_id in self._members:
raise ValueError(f"Member {member.member_id!r} already registered.")
self._members[member.member_id] = member
print(f"Registered member: {member.name}")
def _get_member(self, member_id):
if member_id not in self._members:
raise KeyError(f"No member with ID {member_id!r} found.")
return self._members[member_id]
# ── Transactions ───────────────────────────────────────────────────────
def checkout(self, member_id, isbn):
member = self._get_member(member_id)
book = self._get_book(isbn)
book.checkout(member_id)
member.borrow(isbn)
if isinstance(book, DigitalBook):
print(book.download())
else:
print(f"[x] '{book.title}' checked out to {member.name}.")
def return_book(self, member_id, isbn):
member = self._get_member(member_id)
book = self._get_book(isbn)
book.return_book()
member.return_book(isbn)
print(f"[x] '{book.title}' returned by {member.name}.")
# ── Search ────────────────────────────────────────────────────────────
def search(self, query):
"""Search books by title, author, or ISBN (case-insensitive)."""
q = query.lower()
return [
book for book in self._books.values()
if q in book.title.lower()
or q in book.author.lower()
or q in book.isbn
]
def available_books(self):
"""Return all available (not checked-out) physical books."""
return [b for b in self._books.values()
if b.available and not isinstance(b, DigitalBook)]
# ── Display ───────────────────────────────────────────────────────────
def print_catalogue(self):
print(f"\n{'='*60}")
print(f" {self.name} — Catalogue ({len(self._books)} books)")
print(f"{'='*60}")
for book in sorted(self._books.values(), key=lambda b: b.title):
marker = "" if isinstance(book, DigitalBook) else ""
print(f" {marker} {book}")
print()
def print_members(self):
print(f"\n{'='*60}")
print(f" Registered Members ({len(self._members)})")
print(f"{'='*60}")
for member in self._members.values():
print(f" {member}")
print()
def print_summary(self):
total = len(self._books)
digital = sum(1 for b in self._books.values() if isinstance(b, DigitalBook))
physical = total - digital
available = sum(1 for b in self._books.values()
if b.available and not isinstance(b, DigitalBook))
checked_out = physical - available
print(f"\n{self.name} Summary:")
print(f" Total books: {total} ({physical} physical, {digital} digital)")
print(f" Available: {available}")
print(f" Checked out: {checked_out}")
print(f" Members: {len(self._members)}")
# ── Persistence ───────────────────────────────────────────────────────
def save(self, filepath="library.json"):
data = {
"name": self.name,
"books": [book.to_dict() for book in self._books.values()],
"members": [m.to_dict() for m in self._members.values()],
}
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
print(f"Library saved to {filepath}.")
@classmethod
def load(cls, filepath="library.json"):
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"No save file found at {filepath!r}.")
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
library = cls(data["name"])
for book_data in data["books"]:
if book_data["type"] == "DigitalBook":
book = DigitalBook.from_dict(book_data)
else:
book = Book.from_dict(book_data)
library._books[book.isbn] = book
for member_data in data["members"]:
member = Member.from_dict(member_data)
library._members[member.member_id] = member
print(f"Library loaded from {filepath}.")
return library
Step 6: The Menu Interface
def run_library():
"""Interactive menu-driven library interface."""
SAVE_FILE = "library.json"
# Load existing data or start fresh
try:
lib = Library.load(SAVE_FILE)
except FileNotFoundError:
lib = Library("City Library")
_seed_data(lib)
while True:
print(f"\n{'─'*40}")
print(f" {lib.name}")
print(f"{'─'*40}")
print(" 1. View catalogue")
print(" 2. Search books")
print(" 3. Checkout a book")
print(" 4. Return a book")
print(" 5. View member history")
print(" 6. View summary")
print(" 7. Save and quit")
print(f"{'─'*40}")
choice = input("Choice: ").strip()
try:
if choice == "1":
lib.print_catalogue()
elif choice == "2":
query = input("Search (title / author / ISBN): ").strip()
results = lib.search(query)
if results:
print(f"\n{len(results)} result(s):")
for book in results:
print(f" {book}")
else:
print("No books found.")
elif choice == "3":
member_id = input("Member ID: ").strip()
isbn = input("Book ISBN: ").strip()
lib.checkout(member_id, isbn)
elif choice == "4":
member_id = input("Member ID: ").strip()
isbn = input("Book ISBN: ").strip()
lib.return_book(member_id, isbn)
elif choice == "5":
member_id = input("Member ID: ").strip()
member = lib._get_member(member_id)
member.print_history()
elif choice == "6":
lib.print_summary()
elif choice == "7":
lib.save(SAVE_FILE)
print("Goodbye!")
break
else:
print("Invalid choice. Enter 1--7.")
except (KeyError, ValueError) as e:
print(f"Error: {e}")
def _seed_data(lib):
"""Populate a fresh library with sample data."""
books = [
Book("978-0-06-112008-4", "To Kill a Mockingbird", "Harper Lee", 1960),
Book("978-0-7432-7356-5", "1984", "George Orwell", 1949),
Book("978-0-14-028329-7", "The Great Gatsby", "F. Scott Fitzgerald",1925),
Book("978-0-06-093546-9", "Brave New World", "Aldous Huxley", 1932),
Book("978-0-7432-7357-2", "Animal Farm", "George Orwell", 1945),
DigitalBook("978-0-14-303943-3", "Crime and Punishment",
"Fyodor Dostoevsky", 1866, "EPUB", 1.2),
DigitalBook("978-0-14-044913-6", "The Odyssey",
"Homer", -800, "PDF", 2.1),
]
members = [
Member("M001", "Alice Smith", "alice@example.com"),
Member("M002", "Bob Jones", "bob@example.com"),
Member("M003", "Carlos Rivera", "carlos@example.com"),
]
for book in books:
lib.add_book(book)
for member in members:
lib.register_member(member)
print("\nLibrary initialized with sample data.")
Step 7: Run It
if __name__ == "__main__":
run_library()
Save everything as library_system.py. Run it:
python library_system.py
Walk through these steps to test every feature:
- View catalogue — see all books
- Search "Orwell" — find both his books
- Checkout ISBN
978-0-7432-7356-5for memberM001— 1984 checked out - View catalogue again — see "Checked out" status
- Checkout
978-0-14-303943-3for memberM001— digital book downloads - View member history for
M001— see both transactions - Return
978-0-7432-7356-5forM001— 1984 back on shelf - Save and quit —
library.jsonwritten - Run again — data loads from file, state preserved
What This Project Demonstrates
Look at every OOP concept from the last three chapters in this one program:
| Concept | Where |
|---|---|
Classes with __init__ |
Book, Member, Library |
| Inheritance | DigitalBook(Book) |
| Method overriding | DigitalBook.checkout(), DigitalBook.return_book() |
super() |
DigitalBook.__init__, DigitalBook.to_dict() |
isinstance() |
Library sorts digital vs physical, triggers download |
| Encapsulation | _borrowed, _history, _books, _members |
@property |
Member.borrowed, Member.borrowed_count |
| Class attributes | Member.MAX_BOOKS |
@classmethod |
Book.from_dict(), Library.load() |
__str__ / __repr__ |
All four classes |
__eq__ |
Book.__eq__ compares by ISBN |
| Error handling | try/except in every transaction |
| File persistence | json.dump / json.load via to_dict/from_dict |
| Polymorphism | Library.checkout() calls book.checkout() regardless of type |
A real system. Real design decisions. Real OOP.
Extensions to Try
Once the base system works, push further:
Medium extensions:
- Add a due date when a book is checked out (use
datetime+timedelta). Flag overdue books. - Add a
Librariansubclass ofMemberwith permission to add and remove books. - Add a
search_by_availability()method that returns only currently available books.
Harder extensions:
- Replace the JSON file with an SQLite database using
sqlite3(covered in Chapter 27). - Add a
Reservationsystem — members can reserve a book that's currently checked out. - Build a web API for the library using FastAPI (covered in Chapter 41).
What You Learned in This Chapter
- Real OOP projects start with design — sketch your classes and their relationships before writing code.
- Each class has a single responsibility:
Bookmanages book state,Membermanages member state,Librarycoordinates them. - Inheritance (
DigitalBook) adds or overrides behavior without duplicating code. - Serialization (
to_dict/from_dict) is a clean pattern for JSON persistence. @classmethodwithfrom_dictcreates alternative constructors — common in real libraries.isinstance()inLibrary.checkout()enables different behavior for physical vs digital books withoutifchains everywhere.- Error handling in every transaction keeps the program alive when something goes wrong.
- A menu loop is the simplest way to build an interactive CLI program.
What's Next?
Part 3 is complete. You've gone from writing your first class to building a multi-class system with inheritance, persistence, and a working interface.
Part 4 starts now — Functional Programming and Advanced Functions. In Chapter 21 you'll learn about first-class functions and closures: what it means that functions are objects in Python, how to pass them around, and how closures let functions remember their surrounding environment. It's a completely different way of thinking about code — and it leads directly to decorators, generators, and some of Python's most elegant patterns.
Your turn: Add a due-date system. When a book is checked out, record the date. Add a due_date property to Book that returns 14 days after checkout. Add an overdue_books() method to Library that returns all books past their due date. Test it by manipulating the checkout date directly to simulate overdue books.