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.
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.
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.
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.
While this guide focuses on composition, inheritance enables building specialized classes (like EBook) that extend Book without rewriting existing code.
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.
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.
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.
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.
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.
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()
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}")
① 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.
| Feature | Python | Java equivalent | Notes |
|---|---|---|---|
| Class definition | class Book: | class Book {} | Python needs no braces |
| Constructor | def __init__(self) | Book() {} | self is explicit in Python |
| Access modifiers | _prot / __priv (convention) | private / protected | Python is convention-based |
| Multiple inheritance | ✓ Native | ✗ Not allowed | Python uses MRO to resolve order |
| Type hints | Optional, runtime-ignored | Mandatory | Checked by mypy/pyright |
| Operator overloading | ✓ via dunders | Partial | __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.
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).
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.
Implement a print_receipt method
Add Bookstore.print_receipt() that prints all entries in sales_log as a formatted receipt table.
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).
Make Book objects sortable by price
Implement __lt__ on Book so that sorted(bookstore.books.values()) returns books sorted by price.
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.
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.
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.
💬 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).
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).
[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.
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.
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.
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.
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.
Book("Dune", ...) creates one instance of the Book class.self.title, self.price_php, and self.quantity are all instance attributes of Book.self — the instance — giving them access to the object's data and other methods.__init__, __str__). Python calls these automatically in response to built-in operations.return that exits a function immediately when a pre-condition fails. Keeps the success path flat and readable.add_book so callers don't need to know which case applies.Bookstore containing a dict of Book objects). Usually preferred over inheritance for "has-a" relationships.Complete Overview
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.
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.
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 bookBook.__str__— formats a book as a table rowBook.can_sell— checks if stock allows a saleBook.sell— deducts sold quantity from stockBookstore.__init__— creates empty storeBookstore.add_book— adds or restocks a bookBookstore.find_book— searches by title or row numberBookstore.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
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.
🔒 Join 8,400+ developers. Your email is never shared or sold.