Python: Zero to Hero
Home/Object-Oriented Programming
Share

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:

  1. View catalogue — see all books
  2. Search "Orwell" — find both his books
  3. Checkout ISBN 978-0-7432-7356-5 for member M001 — 1984 checked out
  4. View catalogue again — see "Checked out" status
  5. Checkout 978-0-14-303943-3 for member M001 — digital book downloads
  6. View member history for M001 — see both transactions
  7. Return 978-0-7432-7356-5 for M001 — 1984 back on shelf
  8. Save and quit — library.json written
  9. 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 Librarian subclass of Member with 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 Reservation system — 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: Book manages book state, Member manages member state, Library coordinates them.
  • Inheritance (DigitalBook) adds or overrides behavior without duplicating code.
  • Serialization (to_dict / from_dict) is a clean pattern for JSON persistence.
  • @classmethod with from_dict creates alternative constructors — common in real libraries.
  • isinstance() in Library.checkout() enables different behavior for physical vs digital books without if chains 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.

© 2026 Abhilash Sahoo. Python: Zero to Hero.