Return to Encapsulation Hub
OOP Deep Dive · Narra Book Haven

Python OOP —
Classes, Encapsulation
& Design Patterns

A complete walkthrough of a Python bookstore management system — every class, every method, every pattern dissected through three lenses: why it was written, why it matters, and exactly how it works. Extended with OOP theory, design patterns, a Python cheat sheet, and practice challenges.

📅 March 7, 2026 ⏱ 35 min read 🎓 Beginner–Intermediate 🐍 Python 3.11+
Return to Encapsulation Hub

What is Object-Oriented Programming?

Before dissecting the code, it helps to understand the thinking behind OOP. Every line in this program was shaped by four fundamental principles — the pillars that define how professional Python code is structured.

🔒
Encapsulation

Each class bundles its data and the methods that operate on it. Book guards its price and quantity behind its own methods — external code cannot reach in and corrupt them directly.

🎭
Abstraction

Callers don't need to know how purchase() validates or logs — they just call it and trust it. Complexity is hidden behind a simple, clean interface.

🧬
Inheritance

While this guide focuses on composition, inheritance enables building specialized classes (like EBook) that extend Book without rewriting existing code.

🌀
Polymorphism

Different classes can respond to the same method call in their own way — __str__ works identically whether called on Book or EBook.

Why Python for OOP? Python treats everything as an object — integers, functions, even modules. Its clean syntax makes OOP ideas clear without ceremony. You focus on the logic, not the boilerplate.

1

Define the blueprint — the class

A class is a template. It describes what data (attributes) an object holds and what actions (methods) it can perform. class Book: is the blueprint; each book in your inventory is an instance of that blueprint.

2

Construct instances with __init__

When you write Book("Dune", "Herbert", 590, 15), Python calls __init__ automatically — it is the factory that stamps out a fresh object and stores its data in self.

3

Operate through methods

Methods (can_sell, sell, add_book) are functions that live inside a class. They always receive self — a reference to the instance they belong to — so they can read and modify its data.

4

Compose classes together

Bookstore contains a dictionary of Book objects. One class holding instances of another is called composition — the most common way to build complex systems from simple, tested pieces.

bookstore.py — Complete

Here's the complete program. Every line is explained in the sections below. Copy it, run it, and modify it — this is your code now.

bookstore.py
from datetime import datetime
from typing import Dict, Optional

class Book:
    """Represents a single book in the bookstore inventory."""

    def __init__(self, title: str, author: str, price_php: float, quantity: int):
        self.title     = title.strip()
        self.author    = author.strip()
        self.price_php = round(float(price_php), 2)
        self.quantity  = max(0, int(quantity))

    def __str__(self) -> str:
        return f"{self.title:<45} | {self.author:<30} | ₱{self.price_php:>8,.2f} | Stock: {self.quantity:>2}"

    def can_sell(self, qty: int) -> bool:
        return 0 < qty <= self.quantity

    def sell(self, qty: int):
        self.quantity -= qty

class Bookstore:
    """Manages the bookstore inventory and sales."""

    def __init__(self, name: str = "Narra Book Haven"):
        self.name      = name
        self.books: Dict[str, Book] = {}
        self.sales_log: list = []

    def add_book(self, title: str, author: str, price_php: float, quantity: int = 15) -> None:
        key = title.strip().lower()
        if key in self.books:
            self.books[key].quantity += max(0, quantity)
        else:
            self.books[key] = Book(title, author, price_php, quantity)

    def find_book(self, search_term: str) -> Optional[Book]:
        """Supports searching by Title or Row Number."""
        search_term = search_term.strip().lower()
        if search_term.isdigit():
            idx = int(search_term) - 1
            all_books = list(self.books.values())
            if 0 <= idx < len(all_books):
                return all_books[idx]
        return self.books.get(search_term)

    def purchase(self, customer_name: str, title_or_idx: str, quantity: int) -> None:
        book = self.find_book(title_or_idx)
        if not book:
            print(f"\n  [!] '{title_or_idx}' not found.")
            return
        if quantity < 1:
            print("\n  [!] Quantity must be at least 1.")
            return
        if not book.can_sell(quantity):
            print(f"\n  [!] Only {book.quantity} copies left.")
            return
        total     = book.price_php * quantity
        book.sell(quantity)
        timestamp = datetime.now().strftime("%Y-%m-%d %I:%M %p")
        self.sales_log.append({
            "time": timestamp, "customer": customer_name,
            "title": book.title, "qty": quantity, "total": total
        })
        print(f"\n  ✅ Purchase successful!")
        print(f"  Customer: {customer_name} | Total: ₱{total:,.2f}")

    def populate_with_sample_books(self):
        """Adds the initial inventory."""
        self.add_book("Noli Me Tangere", "Jose Rizal", 220.00, 10)
        self.add_book("El Filibusterismo", "Jose Rizal", 195.00, 8)
        self.add_book("Florante at Laura", "Francisco Balagtas", 150.00, 12)
        self.add_book("Ibong Adarna", "Anonymous", 175.00, 6)
        self.add_book("The Great Gatsby", "F. Scott Fitzgerald", 310.00, 5)

    def list_available_books(self):
        """Prints the full inventory as a formatted table."""
        print("\n" + "="*90)
        print(f"{'TITLE':<45} | {'AUTHOR':<30} | {'PRICE':>10} | STOCK")
        print("-"*90)
        for idx, (key, book) in enumerate(self.books.items(), start=1):
            print(f"{idx:>2}. {book}")
        print("="*90)

def main():
    store = Bookstore("Narra Book Haven")
    store.populate_with_sample_books()

    print("="*90)
    print(f"{store.name:^90}")
    print("="*90)

    while True:
        print("\n[1] View All Books  [2] Buy a Book  [3] Exit")
        choice = input("Enter Choice: ").strip()

        if choice == "1":
            store.list_available_books()

        elif choice == "2":
            store.list_available_books()
            target = input("Enter Title or Number (#): ").strip()
            try:
                qty = int(input("How many copies? : "))
            except ValueError:
                print("  Invalid quantity.")
                continue
            customer = input("Your name: ").strip() or "Guest"
            store.purchase(customer, target, qty)

        elif choice == "3":
            total_rev = sum(s['total'] for s in store.sales_log)
            print(f"\nTotal Revenue: ₱{total_rev:,.2f}")
            print(f"Thank you for visiting {store.name}!")
            break

if __name__ == "__main__":
    main()
Python 3 — Output
Press "Run" above to execute the script…
stdin >

Book — The Atomic Unit

The Book class is the atomic unit of the entire system. It models a single real-world book and contains everything needed to describe, display, and sell it.

Constructor — __init__

def __init__(self, title: str, author: str, price_php: float, quantity: int):
    self.title     = title.strip()
    self.author    = author.strip()
    self.price_php = round(float(price_php), 2)
    self.quantity  = max(0, int(quantity))

① Why used

This is the blueprint for creating any book object. Every book in the inventory is built through this constructor. It also silently sanitises incoming data — bad input gets corrected rather than crashing the program.

② Significance

No book object can exist without passing through here. It is the single point of data integrity for the entire inventory — enforcing clean titles, valid prices, and non-negative stock counts from the very moment a book is created.

③ How it operates

.strip() removes stray whitespace. round(..., 2) locks prices to 2 decimal places. max(0, int(quantity)) silently converts negatives to zero — passing -5 becomes 0.

Display — __str__

def __str__(self) -> str:
    return f"{self.title:<45} | {self.author:<30} | ₱{self.price_php:>8,.2f} | Stock: {self.quantity:>2}"

① Why used

Python calls this automatically whenever a Book object is passed to print(). It turns raw data into a formatted table row so the user sees a neat, aligned catalogue.

② Significance

This is the entire visual interface for the inventory. Without it, print(book) would output <Book object at 0x7f…> — useless to a customer browsing titles.

③ How it operates

Format codes inside the f-string do all the column alignment. Each code sets a fixed field width, ensuring every row lines up perfectly regardless of title length.

The Gatekeeper Pair — can_sell & sell

def can_sell(self, qty: int) -> bool:
    return 0 < qty <= self.quantity

def sell(self, qty: int):
    self.quantity -= qty

① Why used

The two methods are deliberately separated — one checks whether a sale is valid, the other executes it. Stock can never be deducted without first confirming availability.

② Significance

can_sell is the safety lock. sell is the trigger. You cannot fire the trigger without first passing the lock — preventing overselling regardless of how complex the calling code becomes.

③ How it operates

can_sell returns True only when qty > 0 and doesn't exceed current stock. If 5 copies remain and a customer requests 6, it returns False. sell then simply subtracts the sold number.

💡 Dunder methods: __init__ (constructor), __str__ (string for print()), __repr__ (developer representation), __len__ (for len()), __eq__ (for ==), __lt__ (for sorting). Implementing dunders makes your class behave like a native Python type.

Bookstore — The Manager

The Bookstore class is the manager of the entire system. It owns the inventory dictionary and sales log, and exposes all the operations a real bookstore needs.

def __init__(self, name: str = "Narra Book Haven"):
    self.name       = name
    self.books: Dict[str, Book] = {}
    self.sales_log: list = []

① Why used

books is a dictionary — O(1) average lookup by key — while sales_log is an append-only list that grows chronologically with every purchase.

② Significance

A dictionary keyed by lowercased title makes find_book() blazing fast even with thousands of entries. A list for sales preserves insertion order, making session revenue easy to compute.

③ How it operates

The default parameter name="Narra Book Haven" means Bookstore() works without arguments. Dict[str, Book] is a type hint: string keys mapping to Book objects.

add_book — Upsert Pattern

def add_book(self, title, author, price_php, quantity=15) -> None:
    key = title.strip().lower()
    if key in self.books:
        self.books[key].quantity += max(0, quantity)
    else:
        self.books[key] = Book(title, author, price_php, quantity)

📖 The Upsert Pattern: "Upsert" = Update if exists, Insert if new. This is one of the most common patterns in database and inventory code — one method handles both cases cleanly.

find_book — Dual Search

def find_book(self, search_term: str) -> Optional[Book]:
    search_term = search_term.strip().lower()
    if search_term.isdigit():
        idx = int(search_term) - 1
        all_books = list(self.books.values())
        if 0 <= idx < len(all_books):
            return all_books[idx]
    return self.books.get(search_term)

① Why used

Users can find a book either by typing its title or by typing its row number. Combining both lookup strategies in one place means the rest of the code never has to worry about which input type the user chose.

② Significance

Every purchase flows through here. Making it flexible — number or title — dramatically improves usability with zero extra complexity for callers. Optional[Book] means callers must handle the None case.

③ How it operates

.isdigit() detects numeric input. The - 1 converts display numbering (starts at 1) to list indexing (starts at 0). Text input falls through to a direct dictionary lookup. Returns None if nothing matches.

Validating & Executing Sales

The purchase method is the most critical in the program. It orchestrates the entire buying process — three sequential safety checks, calculation, stock update, and sale recording.

def purchase(self, customer_name: str, title_or_idx: str, quantity: int) -> None:
    book = self.find_book(title_or_idx)

    if not book:
        print(f"\n  [!] '{title_or_idx}' not found."); return

    if quantity < 1:
        print("\n  [!] Quantity must be at least 1."); return

    if not book.can_sell(quantity):
        print(f"\n  [!] Only {book.quantity} copies left."); return

    total     = book.price_php * quantity
    book.sell(quantity)
    timestamp = datetime.now().strftime("%Y-%m-%d %I:%M %p")
    self.sales_log.append({
        "time": timestamp, "customer": customer_name,
        "title": book.title, "qty": quantity, "total": total
    })
    print(f"\n  ✅ Purchase successful!")
    print(f"  Customer: {customer_name} | Total: ₱{total:,.2f}")
Guard 1Book exists?
→
Guard 2Qty ≥ 1?
→
Guard 3Stock sufficient?
→
All clearCalculate · Deduct · Log

① Why used

Layered validation ensures no money is calculated, no stock reduced, and no sale recorded unless every condition is fully satisfied. Any failure exits immediately with a clear error message.

② Significance

This pattern — guard clauses or early return — keeps the success path readable and flat. Deeply nested if/else becomes unreadable as conditions grow. Flat is better than nested (The Zen of Python).

③ How it operates

After all guards pass: price × quantity calculates total, book.sell() deducts stock, datetime.now().strftime(...) records the timestamp, and a dictionary with all sale details is appended to sales_log.

🕐 Timestamp format: "%Y-%m-%d %I:%M %p" produces 2026-03-06 02:45 PM. %I is 12-hour clock, %p adds AM/PM. The %Y-%m-%d ordering makes records sort chronologically by default.

Patterns in the Code

This program uses several well-known design patterns — recurring solutions to common programming problems. Recognising them helps you apply the same thinking to new projects.

🏭

Factory / Constructor Pattern

Both Book.__init__ and Bookstore.__init__ act as factories — every valid object of that type is created through them. This centralises data validation so nothing invalid ever enters the system.

🛡️

Guard Clause (Early Return)

Instead of deeply nested if/else, purchase() checks failure conditions first and returns immediately. The happy path is flat, readable, and at the bottom — easy to scan at a glance.

🔄

Upsert (Insert or Update)

add_book() checks whether a book already exists: if yes, it restocks; if no, it creates. One method handles both cases — callers don't need to know which scenario applies.

📋

Append-Only Log

sales_log is never modified after a sale is appended. Each entry is immutable history — making the log safe, auditable, and trivially summed with a generator expression.

🔗

Composition over Inheritance

Bookstore contains Book objects rather than inheriting from them. Composition keeps classes small, testable, and loosely coupled — replacing Book later requires no changes to Bookstore.

🎭

Adapter / Normaliser

Both add_book and find_book normalise input (.strip().lower()) before using it as a dictionary key. Callers don't need to know about the internal key format.

📐 The Zen of Python: "Flat is better than nested." "Explicit is better than implicit." "Errors should never pass silently." Open a Python interpreter and run import this to read all 19 principles — they explain every design decision in this codebase.

Python OOP Quick Reference

Every syntax pattern and built-in used in this program, with a concise definition. Bookmark this section for quick reference while coding.

Class Anatomy
class Name:Define a class blueprint
def __init__(self, ...):Constructor — runs on creation
self.attr = valInstance attribute
def method(self):Instance method
def __str__(self):Called by print(obj)
def __repr__(self):Called in REPL / debug
Type Hints
param: strString parameter
param: intInteger parameter
-> boolReturns a boolean
-> NoneReturns nothing
Optional[T]T or None
Dict[K, V]Dict with key K, value V
String Formatting
f"{val}"Basic f-string
f"{val:<20}"Left-align, 20 wide
f"{val:>20}"Right-align, 20 wide
f"{val:^20}"Centre-align, 20 wide
f"{val:,.2f}"Comma + 2 decimal places
"%Y-%m-%d"strftime: 2026-03-06
Error Handling & Control
try: / except E:Catch specific exception
except E as e:Bind exception to name
finally:Always runs (cleanup)
raise ValueError(msg)Raise an exception
continueSkip to next loop iteration
breakExit the loop entirely
FeaturePythonJava equivalentNotes
Class definitionclass Book:class Book {}Python needs no braces
Constructordef __init__(self)Book() {}self is explicit in Python
Access modifiers_prot / __priv (convention)private / protectedPython is convention-based
Multiple inheritance✓ Native✗ Not allowedPython uses MRO to resolve order
Type hintsOptional, runtime-ignoredMandatoryChecked by mypy/pyright
Operator overloading✓ via dundersPartial__add__, __eq__, etc.

Practice Challenges

The best way to internalise OOP is to extend the system yourself. Each challenge below targets a specific concept covered in this guide. Try them in order — each one builds on the last.

01

Add a __repr__ method to Book

Implement __repr__ so that typing a Book object in the Python REPL shows something like Book(title="Dune", price=590.00, stock=15).

Easy
02

Add a category attribute to Book

Extend Book.__init__ to accept a category: str = "Fiction" argument. Then add a Bookstore.list_by_category(cat) method.

Easy
03

Implement a print_receipt method

Add Bookstore.print_receipt() that prints all entries in sales_log as a formatted receipt table.

Easy
04

Add a discount system using a property

Add a discount attribute to Book (default 0.0). Add a @property called final_price that returns price_php * (1 - discount).

Medium
05

Make Book objects sortable by price

Implement __lt__ on Book so that sorted(bookstore.books.values()) returns books sorted by price.

Medium
06

Create an EBook subclass using inheritance

Create class EBook(Book): that adds file_format: str and download_size_mb: float. Override __str__ to append this info.

Medium
07

Persist inventory to a JSON file

Add Bookstore.save_to_json(path) and Bookstore.load_from_json(path) using Python's built-in json module.

Hard
08

Build a unit test suite with unittest

Create test_bookstore.py using Python's standard unittest module. Write at least 8 test cases covering edge cases.

Hard

💬 Study tip: After each challenge, read your code aloud and explain every line. If you can explain it clearly, you understand it. If you can't, that's the exact gap to study.

Frequently Asked Questions

Quick answers to common questions about Python OOP and the bookstore project.

What is the difference between a class and an object?

+

A class is a blueprint or template — it defines the structure and behaviour. An object (or instance) is a specific realisation of that blueprint. For example, class Book: is the class; Book("Dune", ...) creates an object (an instance).

Why do methods always have self as the first parameter?

+

self is a reference to the current instance. When you call book.can_sell(2), Python passes the book object as self automatically. It lets methods access and modify the instance's attributes and call other methods.

What are dunder methods and why are they useful?

+

Dunder (double-underscore) methods like __init__, __str__, __repr__ are special methods Python calls automatically in response to built‑in operations. They allow your custom classes to integrate seamlessly with Python's syntax — e.g., print(obj) calls __str__, len(obj) calls __len__.

How does encapsulation protect data?

+

Encapsulation bundles data (attributes) and methods that operate on that data inside a class. It restricts direct access to internal state from outside the class. In this project, Book guards its stock via can_sell and sell — external code cannot just set book.quantity = -5 without going through the class logic.

What is the difference between inheritance and composition?

+

Inheritance is an "is‑a" relationship (e.g., EBook is a Book). Composition is a "has‑a" relationship (e.g., Bookstore has a dictionary of Book objects). This project uses composition because it keeps classes loosely coupled and more flexible.

What are type hints and why are they used?

+

Type hints (like title: str or -> bool) document the expected data types. They help IDEs provide better autocompletion and catch errors early when used with tools like mypy. They do not affect runtime behaviour.

How does the upsert pattern work in add_book?

+

The method normalises the title and checks if it already exists in the dictionary. If it exists, it adds the new quantity to the existing stock (update). If not, it creates a new Book object (insert). This "upsert" pattern is common in databases.

Why use guard clauses instead of nested if statements?

+

Guard clauses check for failure conditions at the start of a function and return early. This keeps the main logic (the "happy path") flat and readable. In purchase(), each check exits early if something is wrong, so the successful path is clean and obvious.

How can I test this code?

+

You can write unit tests using Python's built‑in unittest module or a third‑party library like pytest. Focus on testing each method independently — especially edge cases like negative quantities, missing books, and stock limits. Challenge 08 above asks you to build a test suite.

Where can I learn more about design patterns?

+

A great starting point is the classic book Design Patterns: Elements of Reusable Object‑Oriented Software (Gamma et al.) and the online resource Refactoring Guru. Also, look at Python's standard library — it uses many patterns internally.

More Practice Exercises

These exercises go beyond the basic challenges and ask you to extend the system in more complex ways. Each comes with a hint to get you started.

1 Apply a discount to all books in a category

Add a method Bookstore.apply_discount(category, discount_percent) that reduces the price of every book in a given category (you'll need to add a category attribute first).

Hint: Iterate over self.books.values(), check book.category, and update book.price_php *= (1 - discount_percent/100).

2 Search for books by author

Implement Bookstore.search_by_author(author_name) that returns a list of all books by that author (case‑insensitive).

Hint: Use a list comprehension: [book for book in self.books.values() if author_name.lower() in book.author.lower()].

3 Remove a book from inventory

Add Bookstore.remove_book(title) that deletes a book by title and returns True if successful, else False.

Hint: Use self.books.pop(key, None) and check if the result is None.

4 Generate a sales report

Add Bookstore.sales_report() that prints a summary of total revenue, number of transactions, and the most‑sold book.

Hint: Sum totals from self.sales_log; count entries; use collections.Counter to count titles sold.

5 Log sales to a file

Modify purchase() to also write each sale to a CSV file (e.g., sales.csv) with columns: timestamp, customer, title, quantity, total.

Hint: Use Python's csv module. Open the file in append mode ('a') and write a row.

6 Refactor purchase() to use exceptions

Instead of printing errors and returning, raise custom exceptions (e.g., BookNotFoundError, InsufficientStockError) and handle them in the calling code.

Hint: Define exception classes that inherit from Exception. In main(), wrap the call to store.purchase(...) in a try/except block.

✨ Pro tip: For each exercise, write a small test script that verifies your new functionality before you move on. This builds good testing habits.

Key Terms

Every important term used in this guide, defined precisely in the context of Python OOP.

classA blueprint for creating objects. Defines the attributes (data) and methods (behaviour) that all instances of that type will share.
instance / objectA specific realisation of a class. Book("Dune", ...) creates one instance of the Book class.
selfThe first parameter of every instance method. Python passes the object itself here automatically — it lets methods read and modify the object's own attributes.
attributeA variable that belongs to an object. self.title, self.price_php, and self.quantity are all instance attributes of Book.
methodA function defined inside a class. Methods operate on self — the instance — giving them access to the object's data and other methods.
dunder / magic methodMethods with double-underscore prefix and suffix (e.g. __init__, __str__). Python calls these automatically in response to built-in operations.
encapsulationBundling data and the methods that operate on it inside one class. Protects data integrity by controlling how attributes are accessed and modified.
guard clauseAn early return that exits a function immediately when a pre-condition fails. Keeps the success path flat and readable.
upsertA combined "Update if exists, Insert if new" operation. Used in add_book so callers don't need to know which case applies.
compositionWhen one class holds instances of another class as attributes (e.g. Bookstore containing a dict of Book objects). Usually preferred over inheritance for "has-a" relationships.
__name__ == "__main__"A Python idiom that runs code only when the file is executed directly — not when it's imported as a module. Makes files dual-purpose: runnable and importable.

Complete Overview

📦
Encapsulation

Each class owns its own data. Book manages its title, price, and stock internally. Bookstore manages its inventory and sales log. Neither class reaches into the other's internals directly.

🛡️
Validation

Data is cleaned on entry (.strip(), max(0,...), round(...,2)), checked on use (can_sell, guard clauses), and never mutated without first passing a check.

🔗
Separation of Concerns

Book knows only about itself. Bookstore knows how to manage books. main() knows only how to talk to the user. Each layer does exactly one job.

What every method does

  • Book.__init__ — creates and cleans a book
  • Book.__str__ — formats a book as a table row
  • Book.can_sell — checks if stock allows a sale
  • Book.sell — deducts sold quantity from stock
  • Bookstore.__init__ — creates empty store
  • Bookstore.add_book — adds or restocks a book
  • Bookstore.find_book — searches by title or row number
  • Bookstore.purchase — validates, sells, and logs a transaction

Key Python concepts used

  • Dunder methods — __init__, __str__
  • f-string formatting — alignment, decimals, padding
  • Dictionary — O(1) inventory lookup by key
  • Type hints — Dict, Optional, str, int
  • Guard clauses — early return on invalid state
  • try/except — graceful input error handling
  • Generator expression — sum(s['total'] for s in ...)
  • Entry point guard — if __name__ == "__main__"
  • Upsert pattern — insert-or-update in add_book

Share This Guide

Stay in the Loop

Level up your Python knowledge.

In-depth Python tutorials, OOP deep-dives, and software engineering guides — delivered straight to your inbox. No spam, ever.

Weekly Python tutorials OOP & design patterns AI & ML deep-dives Free, cancel anytime

🔒 Join 8,400+ developers. Your email is never shared or sold.

Join the Discussion

Leave a Comment

Sort by: