OOP in Python: Building a Real E-Commerce System from Scratch
Object-Oriented Programming isn't just about slapping class on everything. It's a design philosophy — and Python gives you some of the most elegant tools to express it: protocols, descriptors, __slots__, dataclasses, ABCs, and a dunder method for basically everything.
We're going to build a basic e-commerce platform piece by piece. Products, customers, shopping carts, orders, payments. Every OOP concept will earn its place by solving an actual problem. No toy examples, no Animal → Dog hierarchies.
What We're Building
Here's the architecture of the e-commerce system we'll construct throughout this post:
flowchart TB
subgraph CORE["🏪 E-Commerce Platform"]
direction TB
P["📦 Product Catalog\nClasses & Objects\nDataclasses"]
U["👤 Customer Management\nEncapsulation\nProperties & Descriptors"]
C["🛒 Shopping Cart\nDunder Methods\nOperator Overloading"]
O["📋 Order Processing\nAbstraction\nABCs & Protocols"]
PAY["💳 Payment System\nPolymorphism\nStrategy Pattern"]
N["🔔 Notification Engine\nComposition\nDependency Injection"]
end
P --> C
U --> C
C --> O
O --> PAY
O --> N
style CORE fill:#f8fafc,stroke:#64748b,color:#1e293b
style P fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style U fill:#fce7f3,stroke:#ec4899,color:#831843
style C fill:#fef3c7,stroke:#f59e0b,color:#78350f
style O fill:#d1fae5,stroke:#10b981,color:#064e3b
style PAY fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
style N fill:#ffedd5,stroke:#f97316,color:#7c2d12
Every section maps to a real OOP concept. By the end, you'll have a system where all the pieces fit together — and you'll understand why each concept exists.
1. Classes & Objects: The Product Catalog
The foundation — modeling real-world entities as Python objects.
Why Classes Exist
A class is a blueprint. An object is a thing built from that blueprint. In our e-commerce system, a Product is the blueprint — each item in your store (a laptop, a phone case, a book) is an object instantiated from it.
But Python classes aren't just data containers. They bundle state (attributes) and behavior (methods) together, and they control how objects are created, represented, compared, and destroyed.
Building the Product Catalog
classDiagram
class Product {
-str _sku
-str _name
-Decimal _base_price
-Category _category
-datetime _created_at
+from_dict(data) Product$
+apply_markup(pct) Decimal
+is_available() bool
+__repr__() str
+__eq__(other) bool
+__hash__() int
}
class Category {
<<enumeration>>
ELECTRONICS
CLOTHING
BOOKS
HOME_GARDEN
SPORTS
}
class ProductCatalog {
-dict~str, Product~ _products
+add(product) void
+remove(sku) void
+find_by_sku(sku) Product
+search(query) list~Product~
+filter_by_category(cat) list~Product~
+__len__() int
+__contains__(sku) bool
+__iter__() Iterator
}
Product --> Category
ProductCatalog o-- Product
✅ Product class with proper structure
from decimal import Decimal
from enum import Enum, auto
from datetime import datetime, timezone
class Category(Enum):
"""Product categories — using Enum prevents invalid categories entirely."""
ELECTRONICS = auto()
CLOTHING = auto()
BOOKS = auto()
HOME_GARDEN = auto()
SPORTS = auto()
class Product:
"""
Represents a single product in our e-commerce catalog.
Uses __slots__ for memory efficiency — when you have 100k products
in memory, this matters. Each slot saves ~100 bytes per instance
vs a regular __dict__.
"""
__slots__ = ("_sku", "_name", "_base_price", "_category", "_created_at")
def __init__(
self,
sku: str,
name: str,
base_price: Decimal,
category: Category,
):
if not sku or not sku.strip():
raise ValueError("SKU cannot be empty")
if base_price < 0:
raise ValueError(f"Price cannot be negative: {base_price}")
self._sku = sku.upper().strip()
self._name = name.strip()
self._base_price = Decimal(str(base_price))
self._category = category
self._created_at = datetime.now(timezone.utc)
@classmethod
def from_dict(cls, data: dict) -> "Product":
"""Alternative constructor — common pattern for deserialization."""
return cls(
sku=data["sku"],
name=data["name"],
base_price=Decimal(str(data["price"])),
category=Category[data["category"].upper()],
)
@property
def sku(self) -> str:
return self._sku
@property
def name(self) -> str:
return self._name
@property
def base_price(self) -> Decimal:
return self._base_price
@property
def category(self) -> Category:
return self._category
def apply_markup(self, percentage: float) -> Decimal:
"""Calculate price with markup. Does NOT mutate the product."""
multiplier = Decimal(str(1 + percentage / 100))
return (self._base_price * multiplier).quantize(Decimal("0.01"))
def __repr__(self) -> str:
return (
f"Product(sku={self._sku!r}, name={self._name!r}, "
f"price=${self._base_price}, category={self._category.name})"
)
def __eq__(self, other: object) -> bool:
"""Two products are equal if they share the same SKU."""
if not isinstance(other, Product):
return NotImplemented
return self._sku == other._sku
def __hash__(self) -> int:
"""Hashable by SKU — so products can live in sets and dict keys."""
return hash(self._sku)
# --- Usage ---
laptop = Product(
sku="ELEC-001",
name="MacBook Pro 16\"",
base_price=Decimal("2499.99"),
category=Category.ELECTRONICS,
)
# Alternative construction from API/DB data
from_api = Product.from_dict({
"sku": "BOOK-042",
"name": "Fluent Python",
"price": 59.99,
"category": "books",
})
print(laptop)
# Product(sku='ELEC-001', name='MacBook Pro 16"', price=$2499.99, category=ELECTRONICS)
print(laptop.apply_markup(10))
# 2749.99
🔑 Key Decisions:
__slots__instead of__dict__→ 40-50% memory reduction per instance. Critical when loading entire product catalogs.Decimalfor prices → never usefloatfor money.0.1 + 0.2 != 0.3in floating point.@classmethodas alternative constructor →from_dict,from_json,from_roware standard Python patterns for creating objects from different sources.- Equality by SKU → two
Productobjects with the same SKU are the same product, even if other fields differ.
2. Encapsulation: Customer Management
Controlling access to internal state — Python's way.
Python's Take on Encapsulation
Python doesn't have private or protected keywords like Java. Instead, it uses conventions and properties:
_single_underscore→ "internal, don't touch unless you know what you're doing"__double_underscore→ name-mangled to_ClassName__attr(not truly private, but harder to access accidentally)@property→ computed attributes with getters/setters that look like regular attribute access
The real power of encapsulation in Python isn't hiding data — it's controlling how data changes and maintaining invariants.
Real-World Scenario: Customer Accounts
A customer's email, loyalty points, and order history all have rules. You can't set a negative loyalty balance. Email format must be valid. Order history should be append-only from the outside.
flowchart LR
subgraph EXPOSED["🔓 Public Interface"]
A["customer.email"]
B["customer.loyalty_points"]
C["customer.tier"]
D["customer.add_points(n)"]
E["customer.redeem_points(n)"]
end
subgraph PROTECTED["🔒 Internal State"]
F["_email: str"]
G["_loyalty_points: int"]
H["_order_history: list"]
I["_validate_email()"]
end
A -->|"@property\ngetter/setter"| F
B -->|"@property\nread-only"| G
C -->|"@property\ncomputed"| G
D -->|"validates\nthen mutates"| G
E -->|"checks balance\nthen mutates"| G
style EXPOSED fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style PROTECTED fill:#fef2f2,stroke:#ef4444,color:#991b1b
✅ Encapsulated Customer with invariant protection
import re
from datetime import datetime, timezone
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class Address:
"""Value object — immutable by design. Two addresses with the
same fields are equal. frozen=True gives us __eq__ and __hash__ for free."""
street: str
city: str
state: str
zip_code: str
country: str = "US"
class Customer:
"""
Encapsulates all customer state with strict invariant protection.
External code interacts through properties and methods — never
directly with internal attributes. This means we can change
the internal representation without breaking anything outside.
"""
_EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
TIER_THRESHOLDS = {
"Bronze": 0,
"Silver": 500,
"Gold": 2000,
"Platinum": 5000,
}
def __init__(self, customer_id: str, name: str, email: str):
self._customer_id = customer_id
self._name = name
self._email = "" # Set via property to trigger validation
self.email = email # <-- uses the setter
self._loyalty_points = 0
self._total_spent = Decimal("0.00")
self._addresses: list[Address] = []
self._created_at = datetime.now(timezone.utc)
# --- Properties: The Encapsulation Boundary ---
@property
def customer_id(self) -> str:
"""Read-only. Once created, a customer ID never changes."""
return self._customer_id
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
"""Validates format on every assignment — not just in __init__."""
cleaned = value.strip().lower()
if not self._EMAIL_PATTERN.match(cleaned):
raise ValueError(f"Invalid email format: {value!r}")
self._email = cleaned
@property
def loyalty_points(self) -> int:
"""Read-only externally. Use add_points() and redeem_points()."""
return self._loyalty_points
@property
def tier(self) -> str:
"""Computed property — derived from points, never stored directly.
If we change tier logic later, no external code breaks."""
for tier_name in reversed(self.TIER_THRESHOLDS):
if self._loyalty_points >= self.TIER_THRESHOLDS[tier_name]:
return tier_name
return "Bronze"
@property
def total_spent(self) -> Decimal:
return self._total_spent
# --- Methods: Controlled Mutations ---
def add_points(self, points: int, reason: str = "") -> None:
"""Only way to increase points. Enforces non-negative rule."""
if points <= 0:
raise ValueError(f"Points to add must be positive, got {points}")
self._loyalty_points += points
def redeem_points(self, points: int) -> None:
"""Only way to decrease points. Prevents overdraft."""
if points <= 0:
raise ValueError(f"Points to redeem must be positive, got {points}")
if points > self._loyalty_points:
raise ValueError(
f"Insufficient points: have {self._loyalty_points}, "
f"tried to redeem {points}"
)
self._loyalty_points -= points
def record_purchase(self, amount: Decimal) -> None:
"""Records a purchase and auto-awards loyalty points."""
if amount <= 0:
raise ValueError("Purchase amount must be positive")
self._total_spent += amount
# 1 point per dollar spent, rounded down
earned = int(amount)
self.add_points(earned, reason=f"Purchase of ${amount}")
def add_address(self, address: Address) -> None:
if address in self._addresses:
return # Idempotent — adding the same address twice is a no-op
self._addresses.append(address)
@property
def addresses(self) -> tuple[Address, ...]:
"""Returns immutable copy. External code can't .append() to our list."""
return tuple(self._addresses)
def __repr__(self) -> str:
return (
f"Customer(id={self._customer_id!r}, name={self._name!r}, "
f"tier={self.tier!r}, points={self._loyalty_points})"
)
# --- Usage ---
customer = Customer("CUST-001", "Sarah Chen", "sarah@example.com")
customer.record_purchase(Decimal("150.00"))
print(customer.loyalty_points) # 150
print(customer.tier) # "Bronze"
customer.add_points(400, reason="Welcome bonus")
print(customer.tier) # "Silver"
# This is impossible:
# customer._loyalty_points = 999999 # Convention says don't, but Python allows it
# customer.loyalty_points = 500 # AttributeError — no setter defined
# Email validation enforced:
# customer.email = "not-an-email" # ValueError: Invalid email format
💡 The Encapsulation Litmus Test:
- Can external code put your object into an invalid state? → ❌ You need better encapsulation
- Can you change internal storage (e.g.,
list→deque) without breaking callers? → ✅ Well encapsulated - Are invariants (points ≥ 0, valid email) enforced on every mutation? → ✅ Proper encapsulation
3. Inheritance: Product Type Hierarchies
Sharing behavior through "is-a" relationships — but knowing when to stop.
When Inheritance Actually Makes Sense
Inheritance gets abused more than any other OOP concept. But it's the right tool when you have a genuine "is-a" relationship with shared behavior (not just shared data). A DigitalProduct is a Product — but it doesn't need shipping. A SubscriptionProduct is a Product — but it has recurring billing.
classDiagram
class Product {
<<abstract>>
#_sku: str
#_name: str
#_base_price: Decimal
+get_final_price(qty)* Decimal
+requires_shipping()* bool
+describe() str
}
class PhysicalProduct {
-_weight_kg: float
-_dimensions: Dimensions
+get_final_price(qty) Decimal
+requires_shipping() bool
+shipping_weight() float
}
class DigitalProduct {
-_download_url: str
-_file_size_mb: float
-_max_downloads: int
+get_final_price(qty) Decimal
+requires_shipping() bool
+generate_download_link() str
}
class SubscriptionProduct {
-_billing_cycle: BillingCycle
-_trial_days: int
+get_final_price(qty) Decimal
+requires_shipping() bool
+monthly_cost() Decimal
}
class BundleProduct {
-_items: list~Product~
+get_final_price(qty) Decimal
+requires_shipping() bool
+savings() Decimal
}
Product <|-- PhysicalProduct
Product <|-- DigitalProduct
Product <|-- SubscriptionProduct
Product <|-- BundleProduct
BundleProduct o-- Product
✅ Well-designed inheritance hierarchy
from abc import ABC, abstractmethod
from decimal import Decimal
from enum import Enum, auto
from dataclasses import dataclass
@dataclass(frozen=True)
class Dimensions:
length_cm: float
width_cm: float
height_cm: float
@property
def volume_cm3(self) -> float:
return self.length_cm * self.width_cm * self.height_cm
class BillingCycle(Enum):
MONTHLY = auto()
QUARTERLY = auto()
ANNUAL = auto()
class Product(ABC):
"""
Abstract base class for all product types.
Template Method pattern: describe() uses get_final_price() and
requires_shipping() — subclasses define the specifics, the base
class defines the structure.
"""
def __init__(self, sku: str, name: str, base_price: Decimal):
self._sku = sku
self._name = name
self._base_price = Decimal(str(base_price))
@property
def sku(self) -> str:
return self._sku
@property
def name(self) -> str:
return self._name
@property
def base_price(self) -> Decimal:
return self._base_price
@abstractmethod
def get_final_price(self, quantity: int = 1) -> Decimal:
"""Each product type calculates its price differently."""
...
@abstractmethod
def requires_shipping(self) -> bool:
"""Physical products ship, digital ones don't."""
...
def describe(self) -> str:
"""Template method — same structure, polymorphic details."""
shipping = "🚚 Ships to your door" if self.requires_shipping() else "⚡ Instant delivery"
return f"[{self._sku}] {self._name} — ${self.get_final_price()} | {shipping}"
class PhysicalProduct(Product):
"""Tangible goods that need shipping."""
def __init__(
self,
sku: str,
name: str,
base_price: Decimal,
weight_kg: float,
dimensions: Dimensions,
):
super().__init__(sku, name, base_price)
self._weight_kg = weight_kg
self._dimensions = dimensions
def get_final_price(self, quantity: int = 1) -> Decimal:
return self._base_price * quantity
def requires_shipping(self) -> bool:
return True
def shipping_weight(self, quantity: int = 1) -> float:
"""Dimensional weight vs actual weight — carriers charge the higher one."""
dim_weight = self._dimensions.volume_cm3 / 5000 # industry standard divisor
return max(self._weight_kg, dim_weight) * quantity
class DigitalProduct(Product):
"""Downloadable goods — no shipping, instant delivery."""
def __init__(
self,
sku: str,
name: str,
base_price: Decimal,
file_size_mb: float,
max_downloads: int = 5,
):
super().__init__(sku, name, base_price)
self._file_size_mb = file_size_mb
self._max_downloads = max_downloads
self._download_count = 0
def get_final_price(self, quantity: int = 1) -> Decimal:
# Digital products: quantity doesn't change price (it's a license)
return self._base_price
def requires_shipping(self) -> bool:
return False
def generate_download_link(self) -> str:
if self._download_count >= self._max_downloads:
raise RuntimeError(
f"Download limit reached ({self._max_downloads} downloads)"
)
self._download_count += 1
return f"https://cdn.store.com/downloads/{self._sku}?attempt={self._download_count}"
class SubscriptionProduct(Product):
"""Recurring billing products."""
CYCLE_MULTIPLIERS = {
BillingCycle.MONTHLY: 1,
BillingCycle.QUARTERLY: 3,
BillingCycle.ANNUAL: 12,
}
CYCLE_DISCOUNTS = {
BillingCycle.MONTHLY: Decimal("1.0"),
BillingCycle.QUARTERLY: Decimal("0.9"), # 10% off
BillingCycle.ANNUAL: Decimal("0.75"), # 25% off
}
def __init__(
self,
sku: str,
name: str,
monthly_price: Decimal,
billing_cycle: BillingCycle,
trial_days: int = 0,
):
super().__init__(sku, name, monthly_price)
self._billing_cycle = billing_cycle
self._trial_days = trial_days
def get_final_price(self, quantity: int = 1) -> Decimal:
"""Price for one billing cycle with applicable discount."""
months = self.CYCLE_MULTIPLIERS[self._billing_cycle]
discount = self.CYCLE_DISCOUNTS[self._billing_cycle]
return (self._base_price * months * discount).quantize(Decimal("0.01"))
def requires_shipping(self) -> bool:
return False
@property
def monthly_cost(self) -> Decimal:
"""Effective monthly cost after cycle discount."""
return (
self._base_price * self.CYCLE_DISCOUNTS[self._billing_cycle]
).quantize(Decimal("0.01"))
class BundleProduct(Product):
"""A collection of products sold together at a discount."""
def __init__(
self,
sku: str,
name: str,
items: list[Product],
discount_pct: float = 15.0,
):
combined = sum(p.get_final_price() for p in items)
super().__init__(sku, name, combined)
self._items = list(items) # defensive copy
self._discount_pct = Decimal(str(discount_pct))
def get_final_price(self, quantity: int = 1) -> Decimal:
multiplier = 1 - self._discount_pct / 100
return (self._base_price * multiplier * quantity).quantize(Decimal("0.01"))
def requires_shipping(self) -> bool:
"""Ships if ANY item in the bundle requires shipping."""
return any(item.requires_shipping() for item in self._items)
@property
def savings(self) -> Decimal:
"""How much the customer saves vs buying items separately."""
return self._base_price - self.get_final_price()
# --- Usage ---
laptop = PhysicalProduct(
sku="ELEC-001",
name="MacBook Pro 16\"",
base_price=Decimal("2499.99"),
weight_kg=2.14,
dimensions=Dimensions(35.57, 24.81, 1.68),
)
ebook = DigitalProduct(
sku="BOOK-042",
name="Fluent Python (PDF)",
base_price=Decimal("49.99"),
file_size_mb=25.5,
)
cloud_plan = SubscriptionProduct(
sku="SUB-001",
name="Pro Cloud Storage",
monthly_price=Decimal("9.99"),
billing_cycle=BillingCycle.ANNUAL,
trial_days=14,
)
# Polymorphism in action — describe() works on any product type
for product in [laptop, ebook, cloud_plan]:
print(product.describe())
# [ELEC-001] MacBook Pro 16" — $2499.99 | 🚚 Ships to your door
# [BOOK-042] Fluent Python (PDF) — $49.99 | ⚡ Instant delivery
# [SUB-001] Pro Cloud Storage — $89.91 | ⚡ Instant delivery
⚠️ Inheritance Anti-Patterns to Avoid:
- Deep hierarchies (>3 levels) → Becomes impossible to reason about. Prefer composition.
- Inheriting for code reuse only → If there's no "is-a" relationship, use mixins or composition instead.
- Overriding
__init__without callingsuper()→ Silent bugs. Always callsuper().__init__(). - LSP violations → If a subclass can't be used everywhere the parent is expected, the hierarchy is wrong.
4. Polymorphism: The Payment System
Same interface, different behavior — the most powerful concept in OOP.
Polymorphism in Python
Python's polymorphism is more powerful than in statically typed languages because of duck typing and Protocols. You don't need to inherit from a common base class — you just need to implement the same methods. "If it walks like a duck and quacks like a duck…"
We'll build a payment system where credit cards, PayPal, crypto, and store credit all implement the same interface but behave completely differently.
flowchart TB
subgraph PROCESSOR["🔄 OrderProcessor"]
A["process_payment(method, amount)"]
end
A --> B{"Which payment\nmethod?"}
B --> C["💳 CreditCard\n.charge()"]
B --> D["🅿️ PayPal\n.charge()"]
B --> E["₿ Crypto\n.charge()"]
B --> F["🎁 StoreCredit\n.charge()"]
C --> G["✅ PaymentResult"]
D --> G
E --> G
F --> G
style PROCESSOR fill:#f8fafc,stroke:#64748b,color:#1e293b
style B fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style G fill:#d1fae5,stroke:#10b981,color:#064e3b
style C fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
style D fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
style E fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
style F fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
✅ Protocol-based polymorphism (Pythonic approach)
from typing import Protocol, runtime_checkable
from dataclasses import dataclass
from decimal import Decimal
from datetime import datetime, timezone
from enum import Enum, auto
import uuid
class PaymentStatus(Enum):
SUCCESS = auto()
FAILED = auto()
PENDING = auto()
REFUNDED = auto()
@dataclass(frozen=True)
class PaymentResult:
"""Immutable result of a payment attempt."""
transaction_id: str
status: PaymentStatus
amount: Decimal
message: str
timestamp: datetime
@runtime_checkable
class PaymentMethod(Protocol):
"""
Structural subtyping via Protocol — any class with a charge()
method matching this signature is automatically a PaymentMethod.
No inheritance required. No registration. Just implement the method.
"""
def charge(self, amount: Decimal) -> PaymentResult: ...
def refund(self, transaction_id: str, amount: Decimal) -> PaymentResult: ...
@property
def display_name(self) -> str: ...
class CreditCard:
"""Handles credit card payments through a payment gateway."""
def __init__(self, card_number: str, expiry: str, cvv: str):
# Store only last 4 digits — never store full card numbers
self._last_four = card_number[-4:]
self._expiry = expiry
self._cvv_provided = bool(cvv)
@property
def display_name(self) -> str:
return f"Credit Card ending in {self._last_four}"
def charge(self, amount: Decimal) -> PaymentResult:
# In production: call Stripe/Braintree API here
return PaymentResult(
transaction_id=f"CC-{uuid.uuid4().hex[:12]}",
status=PaymentStatus.SUCCESS,
amount=amount,
message=f"Charged ${amount} to card ending {self._last_four}",
timestamp=datetime.now(timezone.utc),
)
def refund(self, transaction_id: str, amount: Decimal) -> PaymentResult:
return PaymentResult(
transaction_id=f"REF-{transaction_id}",
status=PaymentStatus.REFUNDED,
amount=amount,
message=f"Refunded ${amount} to card ending {self._last_four}",
timestamp=datetime.now(timezone.utc),
)
class PayPal:
"""Handles PayPal payments via OAuth redirect flow."""
def __init__(self, email: str):
self._email = email
@property
def display_name(self) -> str:
return f"PayPal ({self._email})"
def charge(self, amount: Decimal) -> PaymentResult:
return PaymentResult(
transaction_id=f"PP-{uuid.uuid4().hex[:12]}",
status=PaymentStatus.SUCCESS,
amount=amount,
message=f"PayPal payment of ${amount} from {self._email}",
timestamp=datetime.now(timezone.utc),
)
def refund(self, transaction_id: str, amount: Decimal) -> PaymentResult:
return PaymentResult(
transaction_id=f"REF-{transaction_id}",
status=PaymentStatus.REFUNDED,
amount=amount,
message=f"PayPal refund of ${amount} to {self._email}",
timestamp=datetime.now(timezone.utc),
)
class StoreCredit:
"""Pay using store credit balance."""
def __init__(self, customer_id: str, balance: Decimal):
self._customer_id = customer_id
self._balance = balance
@property
def display_name(self) -> str:
return f"Store Credit (${self._balance} available)"
def charge(self, amount: Decimal) -> PaymentResult:
if amount > self._balance:
return PaymentResult(
transaction_id=f"SC-{uuid.uuid4().hex[:12]}",
status=PaymentStatus.FAILED,
amount=amount,
message=f"Insufficient store credit: ${self._balance} < ${amount}",
timestamp=datetime.now(timezone.utc),
)
self._balance -= amount
return PaymentResult(
transaction_id=f"SC-{uuid.uuid4().hex[:12]}",
status=PaymentStatus.SUCCESS,
amount=amount,
message=f"Deducted ${amount} from store credit",
timestamp=datetime.now(timezone.utc),
)
def refund(self, transaction_id: str, amount: Decimal) -> PaymentResult:
self._balance += amount
return PaymentResult(
transaction_id=f"REF-{transaction_id}",
status=PaymentStatus.REFUNDED,
amount=amount,
message=f"Restored ${amount} to store credit",
timestamp=datetime.now(timezone.utc),
)
# --- The magic of polymorphism ---
def process_payment(method: PaymentMethod, amount: Decimal) -> PaymentResult:
"""
This function doesn't know or care which payment type it's using.
It just calls .charge() — and the right thing happens.
Adding a new payment method (Apple Pay, crypto, bank transfer)
requires ZERO changes to this function.
"""
print(f"Processing ${amount} via {method.display_name}...")
result = method.charge(amount)
if result.status == PaymentStatus.SUCCESS:
print(f" ✅ {result.message} [{result.transaction_id}]")
else:
print(f" ❌ {result.message}")
return result
# --- Usage ---
methods: list[PaymentMethod] = [
CreditCard("4111111111111234", "12/27", "123"),
PayPal("buyer@example.com"),
StoreCredit("CUST-001", balance=Decimal("75.00")),
]
for method in methods:
process_payment(method, Decimal("49.99"))
print()
# Also works with isinstance checks thanks to @runtime_checkable:
assert isinstance(CreditCard("4111111111111234", "12/27", "123"), PaymentMethod)
🐍 Protocol vs ABC — When to Use Which?
| Feature | Protocol | ABC |
|---|---|---|
| Requires inheritance | ❌ No (structural typing) | ✅ Yes (nominal typing) |
Runtime isinstance check | Only with @runtime_checkable | ✅ Always |
| Enforces implementation at class definition | ❌ No (fails at call site) | ✅ Yes (TypeError on instantiation) |
| Works with third-party classes you can't modify | ✅ Yes | ❌ No |
| Use when | You want duck typing with type hints | You want a strict contract for your own hierarchy |
5. Abstraction: Order Processing Pipeline
Hiding complexity behind clean interfaces — using ABCs and the Template Method pattern.
Abstraction ≠ Abstract Classes
Abstraction is about exposing what an operation does while hiding how it does it. Abstract Base Classes (ABCs) are one tool for this, but abstraction is broader — it's any time you simplify a complex operation behind a clean interface.
Our order processing system handles validation, inventory checks, payment, and fulfillment. The steps are always the same, but the implementation varies by order type.
flowchart TB
subgraph PIPELINE["📋 Order Processing Pipeline"]
direction TB
A["1️⃣ validate_order()"] --> B["2️⃣ check_inventory()"]
B --> C["3️⃣ calculate_total()"]
C --> D["4️⃣ process_payment()"]
D --> E["5️⃣ fulfill_order()"]
E --> F["6️⃣ send_confirmation()"]
end
subgraph IMPL["🔌 Implementations"]
G["StandardOrder\nShip physical items"]
H["DigitalOrder\nSend download links"]
I["SubscriptionOrder\nActivate recurring billing"]
end
PIPELINE -.->|"Template Method\nSubclasses override steps"| IMPL
style PIPELINE fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
style IMPL fill:#d1fae5,stroke:#10b981,color:#064e3b
style A fill:#bfdbfe,stroke:#3b82f6,color:#1e3a5f
style B fill:#bfdbfe,stroke:#3b82f6,color:#1e3a5f
style C fill:#bfdbfe,stroke:#3b82f6,color:#1e3a5f
style D fill:#bfdbfe,stroke:#3b82f6,color:#1e3a5f
style E fill:#bfdbfe,stroke:#3b82f6,color:#1e3a5f
style F fill:#bfdbfe,stroke:#3b82f6,color:#1e3a5f
✅ Template Method pattern with ABC
from abc import ABC, abstractmethod
from decimal import Decimal
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum, auto
import uuid
class OrderStatus(Enum):
PENDING = auto()
VALIDATED = auto()
PAID = auto()
FULFILLED = auto()
CANCELLED = auto()
@dataclass
class OrderItem:
product_sku: str
product_name: str
unit_price: Decimal
quantity: int
@property
def subtotal(self) -> Decimal:
return self.unit_price * self.quantity
@dataclass
class OrderSummary:
order_id: str
status: OrderStatus
items: list[OrderItem]
subtotal: Decimal
tax: Decimal
shipping: Decimal
total: Decimal
fulfilled_at: datetime | None = None
class OrderProcessor(ABC):
"""
Abstract base class defining the order processing pipeline.
Uses the Template Method pattern: process() defines the algorithm
skeleton, subclasses override specific steps. This ensures every
order type follows the same validation → payment → fulfillment flow,
while allowing each type to customize the details.
"""
TAX_RATE = Decimal("0.08") # 8% tax
def __init__(self):
self._order_id = f"ORD-{uuid.uuid4().hex[:10].upper()}"
def process(self, items: list[OrderItem], payment_method: "PaymentMethod") -> OrderSummary:
"""
Template method — the algorithm skeleton.
DO NOT override this in subclasses.
"""
# Step 1: Validate
self._validate(items)
# Step 2: Check inventory
self._check_inventory(items)
# Step 3: Calculate totals
subtotal = sum(item.subtotal for item in items)
tax = self._calculate_tax(subtotal)
shipping = self._calculate_shipping(items)
total = subtotal + tax + shipping
# Step 4: Process payment
result = payment_method.charge(total)
if result.status != PaymentStatus.SUCCESS:
raise RuntimeError(f"Payment failed: {result.message}")
# Step 5: Fulfill
self._fulfill(items)
# Step 6: Summary
return OrderSummary(
order_id=self._order_id,
status=OrderStatus.FULFILLED,
items=items,
subtotal=subtotal,
tax=tax,
shipping=shipping,
total=total,
fulfilled_at=datetime.now(timezone.utc),
)
def _validate(self, items: list[OrderItem]) -> None:
"""Default validation — subclasses can override for custom rules."""
if not items:
raise ValueError("Order must contain at least one item")
for item in items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.product_name}")
def _calculate_tax(self, subtotal: Decimal) -> Decimal:
"""Override for tax-exempt order types."""
return (subtotal * self.TAX_RATE).quantize(Decimal("0.01"))
@abstractmethod
def _check_inventory(self, items: list[OrderItem]) -> None:
"""Each order type checks inventory differently."""
...
@abstractmethod
def _calculate_shipping(self, items: list[OrderItem]) -> Decimal:
"""Physical orders have shipping; digital don't."""
...
@abstractmethod
def _fulfill(self, items: list[OrderItem]) -> None:
"""Ship boxes, send emails, activate licenses — depends on type."""
...
class StandardOrderProcessor(OrderProcessor):
"""Processes orders for physical goods."""
def _check_inventory(self, items: list[OrderItem]) -> None:
for item in items:
# In production: check warehouse inventory system
print(f" 📦 Checking warehouse stock for {item.product_name}...")
def _calculate_shipping(self, items: list[OrderItem]) -> Decimal:
total_qty = sum(item.quantity for item in items)
# Base $5.99 + $1.50 per additional item
return Decimal("5.99") + Decimal("1.50") * max(0, total_qty - 1)
def _fulfill(self, items: list[OrderItem]) -> None:
for item in items:
print(f" 🚚 Shipping {item.quantity}x {item.product_name}")
print(f" 📧 Shipping confirmation sent for order {self._order_id}")
class DigitalOrderProcessor(OrderProcessor):
"""Processes orders for digital goods — no shipping, instant delivery."""
def _check_inventory(self, items: list[OrderItem]) -> None:
for item in items:
print(f" ☁️ Verifying download availability for {item.product_name}...")
def _calculate_shipping(self, items: list[OrderItem]) -> Decimal:
return Decimal("0.00") # Digital goods don't ship
def _calculate_tax(self, subtotal: Decimal) -> Decimal:
# Digital goods may have different tax rules
return (subtotal * Decimal("0.05")).quantize(Decimal("0.01"))
def _fulfill(self, items: list[OrderItem]) -> None:
for item in items:
print(f" ⚡ Download link generated for {item.product_name}")
print(f" 📧 Download links emailed for order {self._order_id}")
💡 Abstraction Quality Check:
- The
process()template method reads like English — validate, check, calculate, pay, fulfill. Anyone can understand the flow without reading implementation details. - Subclass authors only need to implement 3 methods. They can't accidentally skip validation or payment — the template enforces it.
- New order types (gift cards, pre-orders) just need a new subclass. Zero changes to existing code.
6. Magic Methods: The Shopping Cart
Dunder methods make your objects behave like built-in Python types.
Why Dunder Methods Matter
Python's magic methods (double-underscore methods, or "dunders") let your objects integrate seamlessly with the language itself: len(), in, for loops, +, [], ==, <, printing, context managers, and more.
A well-built ShoppingCart shouldn't need methods like cart.get_item_count() or cart.get_total(). It should work like this:
len(cart) # How many items?
"ELEC-001" in cart # Is this product in the cart?
cart["ELEC-001"] # Get the cart line for this SKU
cart + other_cart # Merge two carts
for item in cart: # Iterate over items
...
The Full Shopping Cart Implementation
flowchart LR
subgraph CART["🛒 ShoppingCart"]
direction TB
A["__len__\nlen(cart)"]
B["__contains__\nsku in cart"]
C["__getitem__\ncart[sku]"]
D["__iter__\nfor item in cart"]
E["__add__\ncart1 + cart2"]
F["__iadd__\ncart += item"]
G["__bool__\nif cart:"]
H["__repr__\nprint(cart)"]
I["__eq__\ncart1 == cart2"]
end
style CART fill:#fef3c7,stroke:#f59e0b,color:#78350f
✅ Shopping Cart with comprehensive dunder methods
from __future__ import annotations
from decimal import Decimal
from dataclasses import dataclass, field
from typing import Iterator
from collections.abc import Mapping
@dataclass
class CartItem:
"""A single line in the shopping cart."""
product_sku: str
product_name: str
unit_price: Decimal
quantity: int = 1
@property
def subtotal(self) -> Decimal:
return self.unit_price * self.quantity
def __repr__(self) -> str:
return f"CartItem({self.product_name!r}, qty={self.quantity}, ${self.subtotal})"
class ShoppingCart:
"""
A shopping cart that feels like a native Python collection.
Supports: len(), in, [], iteration, +, +=, bool(), ==,
and context manager protocol (with statement).
"""
def __init__(self, customer_id: str | None = None):
self._items: dict[str, CartItem] = {} # SKU → CartItem
self._customer_id = customer_id
# --- Container Protocol ---
def __len__(self) -> int:
"""Total number of distinct products in the cart."""
return len(self._items)
def __contains__(self, sku: str) -> bool:
"""Check if a product is in the cart: 'ELEC-001' in cart"""
return sku in self._items
def __getitem__(self, sku: str) -> CartItem:
"""Access cart items by SKU: cart['ELEC-001']"""
try:
return self._items[sku]
except KeyError:
raise KeyError(f"Product {sku!r} is not in the cart")
def __delitem__(self, sku: str) -> None:
"""Remove an item: del cart['ELEC-001']"""
try:
del self._items[sku]
except KeyError:
raise KeyError(f"Cannot remove {sku!r} — not in cart")
def __iter__(self) -> Iterator[CartItem]:
"""Iterate over cart items: for item in cart: ..."""
return iter(self._items.values())
# --- Boolean & Comparison ---
def __bool__(self) -> bool:
"""Empty cart is falsy: if not cart: print('Cart is empty')"""
return len(self._items) > 0
def __eq__(self, other: object) -> bool:
"""Two carts are equal if they contain the same items."""
if not isinstance(other, ShoppingCart):
return NotImplemented
return self._items == other._items
# --- Arithmetic (Merging Carts) ---
def __add__(self, other: ShoppingCart) -> ShoppingCart:
"""Merge two carts: merged = cart1 + cart2"""
if not isinstance(other, ShoppingCart):
return NotImplemented
merged = ShoppingCart(self._customer_id)
for item in self:
merged.add(item.product_sku, item.product_name, item.unit_price, item.quantity)
for item in other:
merged.add(item.product_sku, item.product_name, item.unit_price, item.quantity)
return merged
def __iadd__(self, item: CartItem) -> ShoppingCart:
"""Add item with +=: cart += CartItem(...)"""
self.add(item.product_sku, item.product_name, item.unit_price, item.quantity)
return self
# --- Representation ---
def __repr__(self) -> str:
items_str = ", ".join(repr(item) for item in self)
return f"ShoppingCart(customer={self._customer_id!r}, items=[{items_str}])"
def __str__(self) -> str:
"""Human-readable cart summary."""
if not self:
return "🛒 Empty cart"
lines = [f"🛒 Shopping Cart ({len(self)} items):"]
for item in self:
lines.append(f" • {item.product_name} × {item.quantity} = ${item.subtotal}")
lines.append(f" ─────────────────────────")
lines.append(f" Total: ${self.total}")
return "\n".join(lines)
# --- Business Logic ---
def add(
self,
sku: str,
name: str,
unit_price: Decimal,
quantity: int = 1,
) -> None:
"""Add a product or increase quantity if already in cart."""
if quantity <= 0:
raise ValueError(f"Quantity must be positive, got {quantity}")
if sku in self._items:
# Product already in cart — increase quantity
existing = self._items[sku]
self._items[sku] = CartItem(
product_sku=sku,
product_name=existing.product_name,
unit_price=existing.unit_price,
quantity=existing.quantity + quantity,
)
else:
self._items[sku] = CartItem(
product_sku=sku,
product_name=name,
unit_price=unit_price,
quantity=quantity,
)
def update_quantity(self, sku: str, quantity: int) -> None:
"""Set exact quantity. Use 0 to remove."""
if quantity < 0:
raise ValueError("Quantity cannot be negative")
if quantity == 0:
del self[sku] # Uses __delitem__
return
item = self[sku] # Uses __getitem__ — raises KeyError if not found
self._items[sku] = CartItem(
product_sku=sku,
product_name=item.product_name,
unit_price=item.unit_price,
quantity=quantity,
)
@property
def total(self) -> Decimal:
"""Sum of all item subtotals."""
return sum(item.subtotal for item in self)
@property
def total_quantity(self) -> int:
"""Total number of units across all products."""
return sum(item.quantity for item in self)
# --- Usage ---
cart = ShoppingCart(customer_id="CUST-001")
# Add items
cart.add("ELEC-001", "MacBook Pro", Decimal("2499.99"))
cart.add("BOOK-042", "Fluent Python", Decimal("49.99"), quantity=2)
cart.add("ELEC-001", "MacBook Pro", Decimal("2499.99")) # adds to existing qty
# Python built-ins work naturally:
print(len(cart)) # 2 (distinct products)
print(cart.total_quantity) # 4 (1+2+1 units total)
print("ELEC-001" in cart) # True
print(cart["BOOK-042"].subtotal) # 99.98
# Iteration:
for item in cart:
print(f" {item.product_name}: {item.quantity}x @ ${item.unit_price}")
# String representation:
print(cart)
# 🛒 Shopping Cart (2 items):
# • MacBook Pro × 2 = $4999.98
# • Fluent Python × 2 = $99.98
# ─────────────────────────
# Total: $5099.96
# Delete an item:
del cart["BOOK-042"]
print(len(cart)) # 1
# Merge carts (guest → logged in):
guest_cart = ShoppingCart()
guest_cart.add("SPRT-007", "Yoga Mat", Decimal("29.99"))
merged = cart + guest_cart
print(merged.total_quantity) # 3
🔮 Dunder Methods Cheat Sheet for E-Commerce:
| Dunder | Enables | Use Case |
|---|---|---|
__len__ | len(cart) | Item count in cart/catalog |
__contains__ | sku in cart | Check if product is in cart |
__getitem__ | cart["SKU"] | Look up item by SKU |
__delitem__ | del cart["SKU"] | Remove from cart |
__iter__ | for item in cart | Loop over cart/catalog |
__add__ | cart1 + cart2 | Merge carts |
__bool__ | if cart: | Empty check |
__eq__ | cart1 == cart2 | Compare carts |
__repr__ | repr(cart) | Debug output |
__str__ | str(cart) / print() | User-friendly display |
__hash__ | {product} / dict key | Set/dict membership |
__lt__ / __gt__ | Sorting | Sort products by price |
__enter__ / __exit__ | with statement | DB transactions |
7. Dataclasses: Clean Data Modeling
Less boilerplate, more business logic.
When to Use Dataclasses
Dataclasses eliminate the tedium of writing __init__, __repr__, __eq__, and __hash__ for classes that are primarily about holding data. But they're more powerful than most people realize — field validators, post-init processing, frozen instances, and slots all come built-in.
Rule of thumb: If a class is mostly about storing and passing data, use @dataclass. If it's mostly about behavior, use a regular class.
✅ Dataclasses for e-commerce domain objects
from dataclasses import dataclass, field
from decimal import Decimal
from datetime import datetime, timezone
from enum import Enum, auto
from typing import Self
class Currency(Enum):
USD = "USD"
EUR = "EUR"
GBP = "GBP"
@dataclass(frozen=True)
class Money:
"""
Value object for monetary amounts. Frozen = immutable.
$10 USD is always $10 USD. If you want $20, create a new Money.
frozen=True gives us:
- Immutability (raises FrozenInstanceError on assignment)
- Free __hash__ (so Money can be a dict key or set member)
"""
amount: Decimal
currency: Currency = Currency.USD
def __post_init__(self):
# Frozen dataclasses need object.__setattr__ for validation
if self.amount < 0:
raise ValueError(f"Amount cannot be negative: {self.amount}")
# Normalize to 2 decimal places
object.__setattr__(self, "amount", self.amount.quantize(Decimal("0.01")))
def __add__(self, other: Self) -> Self:
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency.value} and {other.currency.value}")
return Money(self.amount + other.amount, self.currency)
def __mul__(self, factor: int | float) -> Self:
return Money(self.amount * Decimal(str(factor)), self.currency)
def __rmul__(self, factor: int | float) -> Self:
return self.__mul__(factor)
def __str__(self) -> str:
symbols = {"USD": "$", "EUR": "€", "GBP": "£"}
symbol = symbols.get(self.currency.value, self.currency.value)
return f"{symbol}{self.amount:,.2f}"
@dataclass
class ShippingAddress:
"""Mutable dataclass — address can be updated before order ships."""
recipient_name: str
street_line_1: str
city: str
state: str
zip_code: str
country: str = "US"
street_line_2: str = ""
phone: str = ""
def one_line(self) -> str:
parts = [self.street_line_1]
if self.street_line_2:
parts.append(self.street_line_2)
parts.extend([self.city, self.state, self.zip_code, self.country])
return ", ".join(parts)
@dataclass(frozen=True, slots=True)
class Discount:
"""
frozen + slots = maximum efficiency for discount rules.
slots=True (Python 3.10+) prevents __dict__ creation.
"""
code: str
description: str
percentage: Decimal
min_order_amount: Money = field(default_factory=lambda: Money(Decimal("0")))
max_uses: int = 0 # 0 = unlimited
def apply(self, subtotal: Money) -> Money:
if subtotal.amount < self.min_order_amount.amount:
return Money(Decimal("0"), subtotal.currency)
discount_amount = subtotal.amount * (self.percentage / 100)
return Money(discount_amount, subtotal.currency)
@dataclass
class Invoice:
"""
Complex dataclass with computed fields and factory defaults.
Shows how dataclasses handle real business objects.
"""
order_id: str
customer_name: str
items: list[OrderItem]
shipping_address: ShippingAddress
subtotal: Money
tax: Money
shipping_cost: Money
discount: Money = field(default_factory=lambda: Money(Decimal("0")))
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
notes: list[str] = field(default_factory=list)
@property
def total(self) -> Money:
return Money(
self.subtotal.amount + self.tax.amount +
self.shipping_cost.amount - self.discount.amount,
self.subtotal.currency,
)
def add_note(self, note: str) -> None:
self.notes.append(f"[{datetime.now(timezone.utc):%Y-%m-%d %H:%M}] {note}")
# --- Usage ---
price = Money(Decimal("49.99"))
total = price * 3
print(total) # $149.97
discount = Discount(
code="WELCOME20",
description="20% off first order",
percentage=Decimal("20"),
min_order_amount=Money(Decimal("50")),
)
savings = discount.apply(total)
print(f"Discount: {savings}") # Discount: $29.99
💡 Dataclass Decision Matrix:
| Scenario | Use |
|---|---|
| Immutable value objects (Money, Address) | @dataclass(frozen=True) |
| High-volume objects (100k+ instances) | @dataclass(slots=True) |
| DTOs between layers (API → Service → DB) | @dataclass |
| Objects with complex behavior (Cart, Order) | Regular class |
| Configuration / settings | @dataclass(frozen=True) |
| Database rows | @dataclass with @classmethod factory |
8. Properties & Descriptors: Smart Validation
Computed attributes and reusable validation logic.
Properties: Computed Attributes
We already used @property for read-only access. But properties also let you create computed attributes that look like simple fields but run logic behind the scenes — maintaining encapsulation while keeping the interface clean.
Descriptors: Reusable Validation
Descriptors are Python's secret weapon for reusable attribute validation. When you find yourself writing the same @property setter validation across multiple classes, that's a descriptor waiting to happen. Django ORM fields, SQLAlchemy columns, Pydantic validators — they're all descriptors under the hood.
✅ Custom descriptors for e-commerce validation
from decimal import Decimal
from typing import Any
class ValidatedField:
"""
Base descriptor for validated attributes.
How descriptors work:
1. Define __set_name__ → Python calls this when the class is created,
telling the descriptor what attribute name it's assigned to.
2. Define __set__ → Python calls this instead of directly setting the attribute.
3. Define __get__ → Python calls this instead of directly getting the attribute.
The actual value is stored on the INSTANCE (not the descriptor),
using a mangled name to avoid conflicts.
"""
def __set_name__(self, owner: type, name: str) -> None:
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, obj: Any, objtype: type | None = None) -> Any:
if obj is None:
return self # Accessed on the class itself, return descriptor
return getattr(obj, self.private_name)
def __set__(self, obj: Any, value: Any) -> None:
self.validate(value)
setattr(obj, self.private_name, value)
def validate(self, value: Any) -> None:
"""Override in subclasses to add validation logic."""
pass
class PositiveDecimal(ValidatedField):
"""Ensures a Decimal field is always positive."""
def __init__(self, max_value: Decimal | None = None):
self.max_value = max_value
def validate(self, value: Any) -> None:
if not isinstance(value, Decimal):
raise TypeError(f"{self.public_name} must be a Decimal, got {type(value).__name__}")
if value <= 0:
raise ValueError(f"{self.public_name} must be positive, got {value}")
if self.max_value and value > self.max_value:
raise ValueError(f"{self.public_name} cannot exceed {self.max_value}")
class NonEmptyString(ValidatedField):
"""Ensures a string field is never empty or whitespace-only."""
def __init__(self, max_length: int = 255, pattern: str | None = None):
self.max_length = max_length
self.pattern = pattern
def validate(self, value: Any) -> None:
if not isinstance(value, str):
raise TypeError(f"{self.public_name} must be a string, got {type(value).__name__}")
if not value.strip():
raise ValueError(f"{self.public_name} cannot be empty")
if len(value) > self.max_length:
raise ValueError(
f"{self.public_name} exceeds max length of {self.max_length}"
)
if self.pattern:
import re
if not re.match(self.pattern, value):
raise ValueError(f"{self.public_name} doesn't match required format")
class BoundedInt(ValidatedField):
"""Ensures an integer is within a range."""
def __init__(self, min_val: int = 0, max_val: int | None = None):
self.min_val = min_val
self.max_val = max_val
def validate(self, value: Any) -> None:
if not isinstance(value, int):
raise TypeError(f"{self.public_name} must be an integer, got {type(value).__name__}")
if value < self.min_val:
raise ValueError(f"{self.public_name} cannot be less than {self.min_val}")
if self.max_val is not None and value > self.max_val:
raise ValueError(f"{self.public_name} cannot exceed {self.max_val}")
# --- Using descriptors --- clean and declarative! ---
class ProductListing:
"""
Look how clean this class definition is.
All validation is declared at the class level.
Every assignment — including in __init__ — goes through validation.
"""
name = NonEmptyString(max_length=200)
sku = NonEmptyString(max_length=20, pattern=r"^[A-Z]+-\d{3,}$")
price = PositiveDecimal(max_value=Decimal("99999.99"))
stock = BoundedInt(min_val=0, max_val=100_000)
rating = BoundedInt(min_val=1, max_val=5)
def __init__(self, name: str, sku: str, price: Decimal, stock: int):
self.name = name # triggers NonEmptyString.__set__
self.sku = sku # triggers NonEmptyString.__set__ with pattern
self.price = price # triggers PositiveDecimal.__set__
self.stock = stock # triggers BoundedInt.__set__
self.rating = 5 # default rating
def __repr__(self) -> str:
return f"ProductListing({self.name!r}, {self.sku}, ${self.price})"
# --- Usage ---
listing = ProductListing(
name="Wireless Keyboard",
sku="ELEC-001",
price=Decimal("79.99"),
stock=150,
)
# Validation works on every assignment, not just construction:
listing.price = Decimal("89.99") # ✅ Fine
# listing.price = Decimal("-10") # ❌ ValueError: price must be positive
# listing.sku = "invalid" # ❌ ValueError: sku doesn't match format
# listing.stock = -5 # ❌ ValueError: stock cannot be less than 0
# listing.name = " " # ❌ ValueError: name cannot be empty
🔧 How Descriptors Play With the Descriptor Protocol:
listing.price = Decimal("89.99")
│
▼
Python sees: ProductListing.price is a descriptor (has __set__)
│
▼
Calls: ProductListing.price.__set__(listing, Decimal("89.99"))
│
▼
PositiveDecimal.validate() runs → checks type, sign, max
│
▼
setattr(listing, "_price", Decimal("89.99")) ← stored on instance
This is the same mechanism behind @property, @staticmethod, @classmethod, and every ORM field you've ever used. Understanding descriptors means understanding Python.
9. Composition vs Inheritance: The Notification System
"Favor composition over inheritance" — and here's why.
The Problem With Inheritance for Code Reuse
Imagine our e-commerce platform needs to send notifications: email, SMS, push, in-app. Your first instinct might be a Notifier base class with subclasses. But what if you need to send the same notification through multiple channels? What if different events need different channel combinations?
Inheritance locks you into a single tree. Composition lets you mix and match.
flowchart TB
subgraph INHERIT["❌ Inheritance Approach"]
direction TB
A["Notifier\n(base)"]
B["EmailNotifier"]
C["SMSNotifier"]
D["EmailAndSMSNotifier???"]
E["EmailSMSPushNotifier???"]
A --> B
A --> C
A --> D
A --> E
F["💥 Combinatorial explosion!\n3 channels = 7 subclasses\n4 channels = 15 subclasses"]
end
subgraph COMPOSE["✅ Composition Approach"]
direction TB
G["NotificationService"]
H["📧 EmailChannel"]
I["📱 SMSChannel"]
J["🔔 PushChannel"]
K["💬 InAppChannel"]
G --> H
G --> I
G --> J
G --> K
L["Mix and match freely!\nAny combination of channels"]
end
style INHERIT fill:#fef2f2,stroke:#ef4444,color:#991b1b
style COMPOSE fill:#f0fdf4,stroke:#22c55e,color:#166534
style D fill:#fee2e2,stroke:#ef4444,color:#991b1b
style E fill:#fee2e2,stroke:#ef4444,color:#991b1b
style F fill:#fee2e2,stroke:#ef4444,color:#991b1b
style L fill:#d1fae5,stroke:#10b981,color:#064e3b
✅ Notification system using composition and dependency injection
from typing import Protocol
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum, auto
class NotificationPriority(Enum):
LOW = auto()
NORMAL = auto()
HIGH = auto()
URGENT = auto()
@dataclass(frozen=True)
class Notification:
"""Notification payload — agnostic of delivery channel."""
recipient: str
subject: str
body: str
priority: NotificationPriority = NotificationPriority.NORMAL
metadata: dict | None = None
class NotificationChannel(Protocol):
"""Any class with a send() method is a notification channel."""
def send(self, notification: Notification) -> bool: ...
@property
def channel_name(self) -> str: ...
class EmailChannel:
"""Sends notifications via email."""
def __init__(self, smtp_host: str = "smtp.store.com"):
self._smtp_host = smtp_host
@property
def channel_name(self) -> str:
return "email"
def send(self, notification: Notification) -> bool:
print(f" 📧 Email → {notification.recipient}: {notification.subject}")
# In production: connect to SMTP/SES/SendGrid
return True
class SMSChannel:
"""Sends notifications via SMS."""
def __init__(self, api_key: str = ""):
self._api_key = api_key
@property
def channel_name(self) -> str:
return "sms"
def send(self, notification: Notification) -> bool:
# SMS has length limits — truncate body
short_body = notification.body[:160]
print(f" 📱 SMS → {notification.recipient}: {short_body}")
return True
class PushChannel:
"""Sends push notifications to mobile/web."""
@property
def channel_name(self) -> str:
return "push"
def send(self, notification: Notification) -> bool:
print(f" 🔔 Push → {notification.recipient}: {notification.subject}")
return True
class InAppChannel:
"""Creates in-app notification entries."""
def __init__(self):
self._inbox: dict[str, list[Notification]] = {}
@property
def channel_name(self) -> str:
return "in_app"
def send(self, notification: Notification) -> bool:
self._inbox.setdefault(notification.recipient, []).append(notification)
print(f" 💬 In-App → {notification.recipient}: {notification.subject}")
return True
class NotificationService:
"""
COMPOSITION: This service OWNS channels. It doesn't inherit from them.
Channels are injected at construction time (Dependency Injection).
Add or remove channels without changing this class. Swap
real channels for mocks in tests. Configure different channel
sets for different environments.
"""
def __init__(self, channels: list[NotificationChannel]):
self._channels = {ch.channel_name: ch for ch in channels}
def notify(
self,
notification: Notification,
via: list[str] | None = None,
) -> dict[str, bool]:
"""
Send through specified channels (or all if none specified).
Returns a dict of channel → success/failure.
"""
target_channels = via or list(self._channels.keys())
results: dict[str, bool] = {}
for channel_name in target_channels:
channel = self._channels.get(channel_name)
if channel is None:
print(f" ⚠️ Unknown channel: {channel_name}")
results[channel_name] = False
continue
results[channel_name] = channel.send(notification)
return results
def add_channel(self, channel: NotificationChannel) -> None:
"""Add a new channel at runtime — no code changes needed."""
self._channels[channel.channel_name] = channel
def remove_channel(self, channel_name: str) -> None:
self._channels.pop(channel_name, None)
# --- Event-driven notification routing ---
class OrderEventNotifier:
"""
Maps business events to notification configurations.
This is where composition shines — different events use
different combinations of channels and priorities.
"""
def __init__(self, service: NotificationService):
self._service = service
def on_order_placed(self, customer_email: str, order_id: str) -> None:
notification = Notification(
recipient=customer_email,
subject=f"Order Confirmed: {order_id}",
body=f"Thanks for your order! Your order {order_id} has been confirmed.",
priority=NotificationPriority.NORMAL,
)
self._service.notify(notification, via=["email", "in_app"])
def on_order_shipped(self, customer_email: str, order_id: str, tracking: str) -> None:
notification = Notification(
recipient=customer_email,
subject=f"Order Shipped: {order_id}",
body=f"Your order {order_id} is on its way! Tracking: {tracking}",
priority=NotificationPriority.NORMAL,
)
self._service.notify(notification, via=["email", "sms", "push"])
def on_payment_failed(self, customer_email: str, order_id: str) -> None:
notification = Notification(
recipient=customer_email,
subject=f"⚠️ Payment Failed: {order_id}",
body=f"We couldn't process payment for order {order_id}. Please update your payment method.",
priority=NotificationPriority.URGENT,
)
# Urgent → send through ALL channels
self._service.notify(notification)
# --- Usage ---
service = NotificationService([
EmailChannel(),
SMSChannel(),
PushChannel(),
InAppChannel(),
])
events = OrderEventNotifier(service)
events.on_order_placed("sarah@example.com", "ORD-12345")
# 📧 Email → sarah@example.com: Order Confirmed: ORD-12345
# 💬 In-App → sarah@example.com: Order Confirmed: ORD-12345
events.on_order_shipped("sarah@example.com", "ORD-12345", "1Z999AA10123456784")
# 📧 Email → sarah@example.com: Order Shipped: ORD-12345
# 📱 SMS → sarah@example.com: Your order ORD-12345 is on its way!
# 🔔 Push → sarah@example.com: Order Shipped: ORD-12345
💡 Composition vs Inheritance Decision Guide:
Ask yourself these questions:
-
"Is it a is-a or has-a relationship?"
DigitalProductis aProduct→ Inheritance ✅NotificationServicehas channels → Composition ✅
-
"Will I need multiple combinations?"
- If yes → Composition. Inheritance can't do "Email + SMS but not Push."
-
"Do I want to change behavior at runtime?"
- If yes → Composition. You can add/remove channels dynamically.
- Inheritance is fixed at class definition time.
-
"Am I inheriting for code reuse or for polymorphism?"
- Code reuse only → Use composition or mixins instead.
- True polymorphism → Inheritance is fine.
Putting It All Together
Here's how all the pieces compose into a complete e-commerce workflow:
sequenceDiagram
actor Customer
participant Cart as 🛒 ShoppingCart
participant Order as 📋 OrderProcessor
participant Pay as 💳 PaymentMethod
participant Notify as 🔔 NotificationService
Customer->>Cart: add("ELEC-001", laptop, $2499.99)
Customer->>Cart: add("BOOK-042", ebook, $49.99)
Note over Cart: __len__ = 2, __contains__ = True<br/>total = $2549.98
Customer->>Order: process(cart.items, credit_card)
Order->>Order: _validate(items) ← Template Method
Order->>Order: _check_inventory(items) ← Abstract
Order->>Order: _calculate_shipping(items) ← Abstract
Order->>Pay: charge($2693.48) ← Polymorphism
Pay-->>Order: PaymentResult(SUCCESS)
Order->>Order: _fulfill(items) ← Abstract
Order->>Notify: on_order_placed() ← Composition
Notify-->>Customer: 📧 Email + 💬 In-App
Note over Customer, Notify: Classes + Encapsulation + Inheritance<br/>+ Polymorphism + Abstraction + Dunder Methods<br/>+ Dataclasses + Descriptors + Composition<br/>All working together 🎯
from decimal import Decimal
# 1. Create products (Classes, Inheritance, Descriptors)
laptop = PhysicalProduct(
sku="ELEC-001",
name="MacBook Pro 16\"",
base_price=Decimal("2499.99"),
weight_kg=2.14,
dimensions=Dimensions(35.57, 24.81, 1.68),
)
ebook = DigitalProduct(
sku="BOOK-042",
name="Fluent Python",
base_price=Decimal("49.99"),
file_size_mb=25.5,
)
# 2. Customer with encapsulated state (Encapsulation, Properties)
customer = Customer("CUST-001", "Sarah Chen", "sarah@example.com")
# 3. Shopping cart with Pythonic interface (Dunder Methods)
cart = ShoppingCart(customer_id=customer.customer_id)
cart.add(laptop.sku, laptop.name, laptop.base_price)
cart.add(ebook.sku, ebook.name, ebook.base_price, quantity=2)
print(f"Cart has {len(cart)} products, {cart.total_quantity} items")
print(f"Total: ${cart.total}")
# 4. Process payment (Polymorphism, Protocols)
payment = CreditCard("4111111111111234", "12/27", "123")
# 5. Process order (Abstraction, Template Method)
processor = StandardOrderProcessor()
order_items = [
OrderItem(laptop.sku, laptop.name, laptop.base_price, 1),
OrderItem(ebook.sku, ebook.name, ebook.base_price, 2),
]
summary = processor.process(order_items, payment)
# 6. Send notifications (Composition, Dependency Injection)
notifier = OrderEventNotifier(
NotificationService([EmailChannel(), PushChannel(), InAppChannel()])
)
notifier.on_order_placed(customer.email, summary.order_id)
# 7. Award loyalty points (Encapsulation)
customer.record_purchase(summary.total)
print(f"Customer tier: {customer.tier} ({customer.loyalty_points} points)")
Quick Reference: All OOP Concepts
| Concept | What It Does | Python Mechanism | Our E-Commerce Example |
|---|---|---|---|
| Classes & Objects | Model real-world entities | class, __init__, __slots__ | Product, ProductCatalog |
| Encapsulation | Protect internal state | _private, @property, setters | Customer (points, email) |
| Inheritance | Share behavior via "is-a" | class Sub(Base), super(), ABC | PhysicalProduct, DigitalProduct |
| Polymorphism | Same interface, different behavior | Protocol, duck typing | PaymentMethod (card, PayPal, etc.) |
| Abstraction | Hide complexity behind interfaces | ABC, @abstractmethod | OrderProcessor (template method) |
| Dunder Methods | Integrate with Python builtins | __len__, __iter__, __add__, etc. | ShoppingCart |
| Dataclasses | Reduce boilerplate for data objects | @dataclass, frozen, slots | Money, Invoice, Discount |
| Descriptors | Reusable attribute validation | __get__, __set__, __set_name__ | PositiveDecimal, NonEmptyString |
| Composition | Build from parts, not parents | Inject dependencies, own components | NotificationService with channels |
Final Thoughts
OOP in Python isn't about following rules from a textbook — it's about picking the right tool for each problem:
- Need to model a thing? → Class with
__slots__and properties - Need immutable data? →
@dataclass(frozen=True) - Need a contract? →
Protocolfor flexibility,ABCfor strictness - Need reusable validation? → Descriptors
- Need extensibility? → Composition + dependency injection
- Need Pythonic feel? → Dunder methods
- Need a hierarchy? → Inheritance (but keep it shallow)
The e-commerce system we built isn't hypothetical. These are the same patterns you'll find in Django, FastAPI, SQLAlchemy, and every serious Python codebase. Master them, and you can build anything.