Python Shopping Cart · Domain-Driven Design | Poetic Bytes
Learn to build a production-ready Python shopping cart using Domain-Driven Design patterns and clean architecture.
Python Shopping Cart —
Domain-Driven Design
in Action.
A production-grade e-commerce engine built on clean architecture — from value objects and aggregate roots to repositories and application services. Complete with live, interactive code examples you can run in your browser.
This guide walks you through every component of a real-world shopping cart system using Domain-Driven Design (DDD) principles. You'll learn how to model money safely, enforce business invariants, and build a system that's ready for a REST API or database layer.
Building a DDD Shopping Cart
📘 A Production-Grade Shopping Cart in Python
This project demonstrates how to build a real-world e-commerce shopping cart in Python using Domain-Driven Design (DDD) — an architectural approach that puts business logic at the heart of your codebase.
Unlike tutorial-grade CRUD apps, this system mirrors how large platforms structure their backend services. The cart handles currency-safe arithmetic, inventory management, order placement, and a full interactive CLI — all without a single external framework.
Every code example in this guide is fully interactive — you can copy and run each snippet right in your browser using the Pyodide Python runtime. No setup required.
🎯 Who Is This For?
- Junior developers — understand how real software is structured beyond tutorials
- Intermediate Pythonistas — see
@dataclass,Decimal,uuid,datetimeused together - CS students — study applied OOP: encapsulation, abstraction, SOLID
- Backend engineers — a reference implementation for e-commerce domain models
- Tech educators — a production-pattern example to teach from
🏛 Architecture at a Glance
- Domain Layer —
Money,Product,CartItem,ShoppingCart,Order,Address - Repository Layer —
InMemoryProductRepository,InMemoryOrderRepository - Application Layer —
place_order()service function - Presentation Layer — interactive CLI in
main()
💡 Layer Separation: Each layer has one responsibility. Swapping the CLI for a REST API requires touching only the presentation layer — the domain stays untouched.
Deep Dive into Each Component
A component-by-component breakdown of the system's most important pieces.
🔹 1. The Money Value Object
from dataclasses import dataclass from decimal import Decimal from typing import Union @dataclass(frozen=True) class Money: amount: Decimal currency: str = "PHP" def __add__(self, other: 'Money') -> 'Money': if not isinstance(other, Money): return NotImplemented if self.currency != other.currency: raise ValueError("Cannot add different currencies") return Money(self.amount + other.amount, self.currency) def __mul__(self, quantity: Union[int, float, Decimal]) -> 'Money': if isinstance(quantity, float): quantity = Decimal(str(quantity)) elif isinstance(quantity, int): quantity = Decimal(quantity) return Money(self.amount * quantity, self.currency) def __str__(self) -> str: if self.currency == "PHP": return f"₱{self.amount:,.2f}" return f"{self.amount:,.2f} {self.currency}"
-
Why
Decimal? Using floats for money leads to rounding errors —0.1 + 0.2 ≠ 0.3.Decimalprovides exact decimal arithmetic. -
Why
frozen=True? Money is a value object — it should be immutable. Two instances with the same amount and currency are interchangeable. Freezing prevents mutation and makes it hashable. -
Safe Arithmetic:
__add__validates currency compatibility and returns a newMoneyinstance.__mul__safely converts types before multiplying. -
Localized Formatting:
__str__adds the ₱ symbol and thousand separators for Philippine Peso display.
🔹 2. The Product Entity
@dataclass class Product: id: str name: str description: str price: Money sku: str stock_quantity: int = 0 def has_stock(self, qty: int = 1) -> bool: return self.stock_quantity >= qty def reduce_stock(self, qty: int) -> None: if qty <= 0: raise ValueError("Quantity to reduce must be positive") if not self.has_stock(qty): raise ValueError(f"Insufficient stock for {self.name}") self.stock_quantity -= qty
-
Entity vs Value Object: Products have an identity (
id) and mutable state (stock) — they are entities, not value objects. -
Inventory Logic in the Domain:
has_stockandreduce_stockcentralise business rules, preventing negative stock anywhere in the system. -
Invariant Enforcement: Both methods raise descriptive
ValueErrorexceptions on violation — keeping error handling explicit and domain-driven.
🔹 3. The ShoppingCart Aggregate Root
class ShoppingCart: def __init__(self, cart_id: str = None): self.id = cart_id or str(uuid.uuid4()) self.items: Dict[str, CartItem] = {} self.created_at = datetime.now() self.updated_at = self.created_at def add_item(self, product: Product, quantity: int = 1) -> None: if quantity <= 0: return if not product.has_stock(quantity): raise ValueError(f"Not enough stock for {product.name}") if product.id in self.items: current = self.items[product.id] new_qty = current.quantity + quantity if not product.has_stock(new_qty): raise ValueError(f"Not enough stock for {product.name}") self.items[product.id] = CartItem( product=product, quantity=new_qty, added_at=current.added_at ) else: self.items[product.id] = CartItem( product=product, quantity=quantity, added_at=datetime.now() ) self.updated_at = datetime.now() @property def total(self) -> Money: if not self.items: return Money(Decimal(0)) total = Money(Decimal(0)) for item in self.items.values(): total += item.subtotal return total
- What is an Aggregate Root? The cart is the entry point that enforces consistency rules for all its items. It guarantees that adding an item never exceeds available stock.
- O(1) Lookups: Using a dictionary keyed by product ID makes lookups fast and naturally merges duplicate additions.
-
Computed Total: The
totalproperty is computed on-demand using safeMoneyarithmetic — always accurate without manual recalculation. -
Timestamps:
created_atandupdated_atenable audit logs and debugging.
Complete Runnnable Implementation
Copy and save as shopping_cart.py — or Run it directly in your browser.
from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal from datetime import datetime from typing import Dict, Union import uuid @dataclass(frozen=True) class Money: amount: Decimal currency: str = "PHP" def __add__(self, other: 'Money') -> 'Money': if not isinstance(other, Money): return NotImplemented if self.currency != other.currency: raise ValueError("Cannot add different currencies") return Money(self.amount + other.amount, self.currency) def __mul__(self, quantity: Union[int, float, Decimal]) -> 'Money': if isinstance(quantity, float): quantity = Decimal(str(quantity)) elif isinstance(quantity, int): quantity = Decimal(quantity) return Money(self.amount * quantity, self.currency) def __str__(self) -> str: if self.currency == "PHP": return f"₱{self.amount:,.2f}" return f"{self.amount:,.2f} {self.currency}" @dataclass class Product: id: str name: str description: str price: Money sku: str stock_quantity: int = 0 def has_stock(self, qty: int = 1) -> bool: return self.stock_quantity >= qty def reduce_stock(self, qty: int) -> None: if qty <= 0: raise ValueError("Quantity to reduce must be positive") if not self.has_stock(qty): raise ValueError(f"Insufficient stock for {self.name}") self.stock_quantity -= qty @dataclass class CartItem: product: Product quantity: int added_at: datetime = field(default_factory=datetime.now) @property def subtotal(self) -> Money: return self.product.price * self.quantity class ShoppingCart: def __init__(self, cart_id: str = None): self.id = cart_id or str(uuid.uuid4()) self.items: Dict[str, CartItem] = {} self.created_at = datetime.now() self.updated_at = self.created_at def add_item(self, product: Product, quantity: int = 1) -> None: if quantity <= 0: return if not product.has_stock(quantity): raise ValueError(f"Not enough stock for {product.name}") if product.id in self.items: current = self.items[product.id] new_qty = current.quantity + quantity if not product.has_stock(new_qty): raise ValueError(f"Not enough stock for {product.name}") self.items[product.id] = CartItem( product=product, quantity=new_qty, added_at=current.added_at ) else: self.items[product.id] = CartItem( product=product, quantity=quantity, added_at=datetime.now() ) self.updated_at = datetime.now() def remove_item(self, product_id: str) -> None: if product_id in self.items: del self.items[product_id] self.updated_at = datetime.now() def clear(self) -> None: self.items.clear() self.updated_at = datetime.now() @property def total(self) -> Money: if not self.items: return Money(Decimal(0)) total = Money(Decimal(0)) for item in self.items.values(): total += item.subtotal return total def __repr__(self) -> str: return f"ShoppingCart(id={self.id!r}, items={len(self.items)}, total={self.total})" class InMemoryCartRepository: def __init__(self): self._store: Dict[str, ShoppingCart] = {} def save(self, cart: ShoppingCart) -> None: self._store[cart.id] = cart def find_by_id(self, cart_id: str) -> ShoppingCart | None: return self._store.get(cart_id) def delete(self, cart_id: str) -> None: self._store.pop(cart_id, None) if __name__ == "__main__": print("=" * 50) print(" Python Shopping Cart — DDD Demo") print("=" * 50) laptop = Product( id="p1", name="Laptop Pro 15", description="High-performance laptop", price=Money(Decimal("59999.00")), sku="LAP-PRO-15", stock_quantity=5 ) mouse = Product( id="p2", name="Wireless Mouse", description="Ergonomic wireless mouse", price=Money(Decimal("899.00")), sku="MSE-WRL-01", stock_quantity=20 ) bag = Product( id="p3", name="Laptop Bag", description="Padded 15-inch laptop bag", price=Money(Decimal("1299.00")), sku="BAG-LAP-15", stock_quantity=10 ) cart = ShoppingCart() repo = InMemoryCartRepository() print(f"\nCart ID : {cart.id}") print(f"Total : {cart.total} (empty cart)\n") print("Adding items...") cart.add_item(laptop, 1) cart.add_item(mouse, 2) cart.add_item(bag, 1) print(f"{'Product':<20} {'Qty':>5} {'Unit Price':>14} {'Subtotal':>14}") print("-" * 57) for item in cart.items.values(): print(f"{item.product.name:<20} {item.quantity:>5} {str(item.product.price):>14} {str(item.subtotal):>14}") print("-" * 57) print(f"{'TOTAL':<20} {'':>5} {'':>14} {str(cart.total):>14}\n") print("Reducing stock after checkout...") for item in cart.items.values(): item.product.reduce_stock(item.quantity) print(f" {item.product.name}: stock remaining = {item.product.stock_quantity}") repo.save(cart) loaded = repo.find_by_id(cart.id) print(f"\nRepository round-trip OK: {loaded is cart}") print("\nTesting domain invariants...") try: cart.add_item(laptop, 999) except ValueError as e: print(f" ✅ Caught expected error: {e}") try: laptop.reduce_stock(-1) except ValueError as e: print(f" ✅ Caught expected error: {e}") print("\nDone.")
Patterns & Components
Quick-reference guide to the patterns used in this system.
| Concept | Class | Type | Key Responsibility |
|---|---|---|---|
Money | Value Object | Immutable | Safe decimal arithmetic with currency awareness |
Product | Entity | Mutable | Product identity + inventory management |
CartItem | Value Object | Immutable | A line-item snapshot within the cart |
ShoppingCart | Aggregate Root | Controller | Enforces all cart business invariants |
Order | Entity | Mutable | Order lifecycle tracking |
InMemoryCartRepository | Repository | Storage | Persist and query carts (swappable with DB) |
Pro Tips for DDD in Python
Key Takeaways
🔢 Always use Decimal for money — Python's float cannot represent all decimal fractions exactly. Initialize with strings: Decimal("19.99") not Decimal(19.99).
🧊 Freeze your value objects — Use @dataclass(frozen=True) on value objects like Money. This makes them hashable and prevents accidental mutation.
🏗 Push logic into the domain — Business rules belong in the domain entity, not in a controller. Domain objects should validate their own invariants and raise exceptions on violation.
🔑 Use UUIDs for identity — Generate IDs with str(uuid.uuid4()). UUIDs are globally unique and make distributed systems simpler.
📦 Repository pattern = swappable storage — Your in-memory repository can be replaced with SQLAlchemy, MongoDB, or Firebase without touching domain code.
🔄 Timestamps on every mutation — Store created_at and updated_at on mutable entities for audit logs and debugging.
From DDD to Production
Follow this progression to master Python e-commerce architecture step by step.
🥚 Stage 1 — Python Fundamentals
Before diving into DDD, ensure you're comfortable with Python classes, @dataclass, type hints, and exception handling. These are the grammar of domain models.
🐍 Stage 2 — This Project (DDD Domain Layer)
Study Money, Product, CartItem, ShoppingCart. Understand why each is either a value object or an entity, and how aggregate roots enforce invariants.
⚡ Stage 3 — Add a REST API Layer (FastAPI)
Wrap the domain in FastAPI endpoints. Define Pydantic request/response schemas, create route handlers that call your application service, and test with Swagger UI at /docs.
🗄 Stage 4 — Persist with SQLAlchemy + PostgreSQL
Replace InMemoryRepository with a real database. Map domain entities to ORM models using the Data Mapper pattern. Run Alembic migrations to keep your schema in sync.
🚀 Stage 5 — Production Deployment
Containerise with Docker, set up CI/CD with GitHub Actions, add Redis caching for product listings, and deploy to Fly.io or Railway. Add PayMongo or Stripe for real payment processing.
DDD Concepts Quiz
A quick quiz on DDD concepts from this article. See how well you understood the patterns.
Great effort — review any missed questions above.
See the Difference
See exactly how DDD patterns improve naive Python approaches — side by side.
price = 19.99
qty = 3
total = price * qty
# Result: 59.97000000000001 ❌
tax = total * 0.12
grand = total + tax
print(f"Total: {grand:.2f}")
# Fragile, loses precision
price = Money(Decimal("19.99"))
qty = 3
total = price * qty
# Result: ₱59.97 ✅
tax = total * Decimal("0.12")
grand = total + tax
print(grand) # ₱67.17
# Precise, safe, readable
stock = 5
ordered = 10
# Validation scattered everywhere:
if ordered > stock:
print("No stock")
# No central enforcement
# Bug: easy to bypass check
product.reduce_stock(qty) # raises ValueError automatically # if insufficient stock. # Rules live in ONE place — # impossible to bypass or forget. # Domain is always consistent.
💡 Key insight: Moving validation into domain objects means a bug fix or rule change happens in one class — not scattered across dozens of controllers, scripts, and API handlers.
Subscribe to Poetic Bytes
Level up your Python knowledge.
In-depth Python tutorials, DDD guides, and software engineering insights — delivered straight to your inbox. No spam, ever.
🔒 Join 8,400+ developers. Your email is never shared or sold.
Follow Poetic Bytes
Stay connected across every platform. Get daily Python tips, OOP insights, and the latest DDD developments — wherever you spend your time online.
Wrapping Up: Your DDD Journey
🏁 What You've Accomplished
You've just completed a deep-dive into Domain-Driven Design with a production-grade Python Shopping Cart implementation.
You now understand how to model value objects like Money,
entities like Product, and
aggregate roots like ShoppingCart.
You've seen how the Repository pattern decouples storage from domain logic,
and how domain invariants are enforced through explicit validation.
Every code example was interactive — you ran Python in your browser, experimented with the code, and saw the results instantly.
Key Takeaways
- Use
Decimalfor money — floats are dangerous for financial calculations - Freeze value objects — immutability prevents bugs and enables hashing
- Enforce invariants in the domain — business rules belong in entities, not controllers
- Repository pattern = swappable storage — in-memory → database requires zero domain changes
- Aggregate roots enforce consistency — the cart controls all item operations
Your Next Steps
Now that you've mastered DDD fundamentals, you're ready to build on this foundation:
✨ "Great software is built on great domain models. When you master DDD, you write code that mirrors the business — making it easier to understand, easier to change, and easier to scale. Keep building, keep modeling, and remember: the domain is the heart of your application."
— Glenn Junsay Pansensoy, Poetic Bytes
Comments
Post a Comment