DDD · Value Objects · 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.

📅 July 15, 2026 ⏱ 20 min read 🎓 Intermediate 🐍 Python 3.9+
11Code Components
6DDD Patterns
₱PHP Support
▶Run in Browser
Back to Abstraction Guide

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.

6 DDD patterns explained 11 code components 5 knowledge-check questions Live code runner Real-world extensions

🎯 Who Is This For?

  • Junior developers — understand how real software is structured beyond tutorials
  • Intermediate Pythonistas — see @dataclass, Decimal, uuid, datetime used 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 This Matters
  1. Why Decimal? Using floats for money leads to rounding errors — 0.1 + 0.2 ≠ 0.3. Decimal provides exact decimal arithmetic.
  2. 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.
  3. Safe Arithmetic: __add__ validates currency compatibility and returns a new Money instance. __mul__ safely converts types before multiplying.
  4. 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
📝 Why This Matters
  1. Entity vs Value Object: Products have an identity (id) and mutable state (stock) — they are entities, not value objects.
  2. Inventory Logic in the Domain: has_stock and reduce_stock centralise business rules, preventing negative stock anywhere in the system.
  3. Invariant Enforcement: Both methods raise descriptive ValueError exceptions 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
📝 Why This Matters
  1. 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.
  2. O(1) Lookups: Using a dictionary keyed by product ID makes lookups fast and naturally merges duplicate additions.
  3. Computed Total: The total property is computed on-demand using safe Money arithmetic — always accurate without manual recalculation.
  4. Timestamps: created_at and updated_at enable audit logs and debugging.

Complete Runnnable Implementation

Copy and save as shopping_cart.py — or Run it directly in your browser.

python · shopping_cart.py
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.

ConceptClassTypeKey Responsibility
MoneyValue ObjectImmutableSafe decimal arithmetic with currency awareness
ProductEntityMutableProduct identity + inventory management
CartItemValue ObjectImmutableA line-item snapshot within the cart
ShoppingCartAggregate RootControllerEnforces all cart business invariants
OrderEntityMutableOrder lifecycle tracking
InMemoryCartRepositoryRepositoryStoragePersist 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.

Classes & OOPDataclassesType HintsExceptions

🐍 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.

Value ObjectsEntitiesAggregate RootRepository Pattern

⚡ 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.

FastAPIPydanticRESTSwagger/OpenAPI

🗄 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.

SQLAlchemyPostgreSQLAlembicData Mapper

🚀 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.

DockerGitHub ActionsRedisPayMongo / Stripe

DDD Concepts Quiz

A quick quiz on DDD concepts from this article. See how well you understood the patterns.

0/5

Great effort — review any missed questions above.

See the Difference

See exactly how DDD patterns improve naive Python approaches — side by side.

❌ Naive Approach (Float Money)
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
✅ DDD Value Object (Decimal Money)
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
❌ Logic Scattered in Script
stock = 5
ordered = 10
# Validation scattered everywhere:
if ordered > stock:
  print("No stock")
# No central enforcement
# Bug: easy to bypass check
✅ Invariant in Domain Entity
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.

Share This Guide

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.

Weekly Python tutorials DDD & architecture guides OOP series updates Free, cancel anytime

🔒 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.

6 DDD patterns mastered 11 code components 5 knowledge-check questions Live code runner

Key Takeaways

  • Use Decimal for 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

Back to Abstraction Back to Top