Seshadri C
Engineering Notes
HomeBlogTopicsContact
Seshadri C
Engineering Notes

Seshadri C

Engineering, architecture, and leadership writing.

HomeBlogTopicsContactLinkedInGitHub
© 2026 Seshadri. All rights reserved.
Home/Blog/OOP in Python: Building a Real E-Commerce System from Scratch

OOP in Python: Building a Real E-Commerce System from Scratch

March 12, 2026•39 min read•7,647 words
PythonOOPDesign PatternsSoftware ArchitectureAdvanced Python

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.
  • Decimal for prices → never use float for money. 0.1 + 0.2 != 0.3 in floating point.
  • @classmethod as alternative constructor → from_dict, from_json, from_row are standard Python patterns for creating objects from different sources.
  • Equality by SKU → two Product objects 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 calling super() → Silent bugs. Always call super().__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?

FeatureProtocolABC
Requires inheritance❌ No (structural typing)✅ Yes (nominal typing)
Runtime isinstance checkOnly 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 whenYou want duck typing with type hintsYou 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:

DunderEnablesUse Case
__len__len(cart)Item count in cart/catalog
__contains__sku in cartCheck if product is in cart
__getitem__cart["SKU"]Look up item by SKU
__delitem__del cart["SKU"]Remove from cart
__iter__for item in cartLoop over cart/catalog
__add__cart1 + cart2Merge carts
__bool__if cart:Empty check
__eq__cart1 == cart2Compare carts
__repr__repr(cart)Debug output
__str__str(cart) / print()User-friendly display
__hash__{product} / dict keySet/dict membership
__lt__ / __gt__SortingSort products by price
__enter__ / __exit__with statementDB 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:

ScenarioUse
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:

  1. "Is it a is-a or has-a relationship?"

    • DigitalProduct is a Product → Inheritance ✅
    • NotificationService has channels → Composition ✅
  2. "Will I need multiple combinations?"

    • If yes → Composition. Inheritance can't do "Email + SMS but not Push."
  3. "Do I want to change behavior at runtime?"

    • If yes → Composition. You can add/remove channels dynamically.
    • Inheritance is fixed at class definition time.
  4. "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

ConceptWhat It DoesPython MechanismOur E-Commerce Example
Classes & ObjectsModel real-world entitiesclass, __init__, __slots__Product, ProductCatalog
EncapsulationProtect internal state_private, @property, settersCustomer (points, email)
InheritanceShare behavior via "is-a"class Sub(Base), super(), ABCPhysicalProduct, DigitalProduct
PolymorphismSame interface, different behaviorProtocol, duck typingPaymentMethod (card, PayPal, etc.)
AbstractionHide complexity behind interfacesABC, @abstractmethodOrderProcessor (template method)
Dunder MethodsIntegrate with Python builtins__len__, __iter__, __add__, etc.ShoppingCart
DataclassesReduce boilerplate for data objects@dataclass, frozen, slotsMoney, Invoice, Discount
DescriptorsReusable attribute validation__get__, __set__, __set_name__PositiveDecimal, NonEmptyString
CompositionBuild from parts, not parentsInject dependencies, own componentsNotificationService 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? → Protocol for flexibility, ABC for 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.

Related Posts

Back to all posts

SOLID Principles in Python: A Visual and Practical Guide

Learn the five SOLID principles through real e-commerce Python examples with interactive diagrams and side-by-side code comparisons. No fluff, just the stuff that actually matters when you're building production systems.

February 28, 2026 • 25 min read