Advertisement
🐍 Python · Domain-Driven Design · E-Commerce

πŸ›’ Python Shopping Cart System

A production-grade e-commerce engine built on clean architecture β€” from value objects to aggregate roots, fully explained.

11
Code Components
111
Products Demoed
DDD
Architecture
PHP β‚±
Currency Support

πŸ“– Comprehensive Introduction

What this project is, why it matters, and who it's for.

What is this Python Shopping Cart System?

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 like Amazon, Lazada, and Shopify structure their backend services.

The cart handles currency-safe arithmetic, inventory management, order placement, customer addressing, and a full interactive CLI β€” all without a single external framework. It runs on pure Python 3.9+, making it a perfect learning foundation before adding FastAPI, Django, or SQLAlchemy on top.

Whether you're a junior developer learning software architecture, an intermediate Python programmer looking to level up, or a tech educator preparing course material, this codebase provides a model you can study, extend, and deploy confidently.

πŸ— Clean Architecture 🧩 Value Objects πŸ”’ Invariant Enforcement πŸ“¦ Aggregate Root πŸ’Ύ In-Memory Repository 🧾 Order Lifecycle 🐍 Python 3.9+ β‚± Philippine Peso

🎯 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()
πŸ’‘ Each layer has one responsibility. Swapping the CLI for a REST API requires touching only the presentation layer β€” the domain stays untouched.

πŸ“˜ Logical Explanation

A component-by-component breakdown of the system's most important pieces.

πŸ”Ή 1. The Money Value Object
@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):
        if self.currency == "PHP":
            return f"β‚±{self.amount:,.2f}"
        return f"{self.amount:,.2f} {self.currency}"

Why? Money is a pervasive concept in any commerce system. Using floats leads to rounding errors β€” 0.1 + 0.2 β‰  0.3 in floating-point arithmetic. Using a dedicated immutable class guarantees type safety, encapsulates formatting, and prevents currency confusion.

Significance: As a value object, two instances with the same amount and currency are interchangeable. The object is frozen (frozen=True), meaning it cannot be mutated after creation β€” arithmetic always returns new instances. The __str__ method formats with thousand separators and the β‚± symbol for localised Philippine Peso display.

Operation: __add__ validates currency compatibility and returns a new Money sum. __mul__ safely converts int and float to Decimal before multiplying. This makes expressions like price * quantity both safe and readable.


πŸ”Ή 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? Products are the core items sold. They have an identity (id) and mutable state (stock) that changes over time β€” making them entities, not value objects.

Significance: Inventory logic lives inside the domain, not scattered across controllers or scripts. has_stock and reduce_stock centralise business rules, preventing negative stock anywhere in the system. The sku field allows real-world warehouse integration.

Operation: has_stock returns a boolean after comparing the requested quantity with available stock. reduce_stock validates the input is positive, ensures stock sufficiency, then decrements. Both violations raise descriptive ValueError exceptions, keeping error handling explicit.


πŸ”Ή 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? The cart must guarantee that adding an item never exceeds available stock β€” even when the same product is added twice. Centralising this in an aggregate root prevents inconsistency.

Significance: The cart holds a dictionary of CartItem objects keyed by product ID. This makes lookups O(1) and naturally merges duplicate additions. The total property is computed on-demand, so it is always accurate without manual recalculation.

Operation: Adding an item checks stock, handles both new and existing items, and updates the updated_at timestamp. The total property iterates all items, accumulating subtotals using the safe Money.__add__ operator. A UUID is auto-generated if no cart ID is provided.

πŸ“„ Full Python Source Code

Complete, runnable implementation β€” copy and save as 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):
        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):
        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.")
Advertisement

πŸ—‚ DDD Concepts Reference

Quick-reference guide to the patterns used in this system.

Concept Class / Function Pattern Type Key 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
AddressValue ObjectImmutableShipping address without identity
OrderEntityMutableOrder lifecycle tracking from PENDING to SHIPPED
InMemoryProductRepositoryRepositoryStoragePersist and query products (swappable with DB)
InMemoryOrderRepositoryRepositoryStorageSave and retrieve placed orders
place_order()Application ServiceOrchestratorCoordinates cart β†’ order creation + stock reduction

πŸ’‘ Best Practices & Pro Tips

Key takeaways every Python developer should know when building domain models.

πŸ”’

Always use Decimal for money

Python's float type cannot represent all decimal fractions exactly. Use from decimal import Decimal and initialise with strings: Decimal("19.99") not Decimal(19.99).

🧊

Freeze your value objects

Use @dataclass(frozen=True) on value objects like Money and Address. This makes them hashable, prevents accidental mutation, and communicates intent clearly to other developers.

πŸ—

Push logic into the domain

Business rules like "can't add negative stock" belong in the domain entity, not in a controller or script. Domain objects should validate their own invariants and raise exceptions on violation.

πŸ”‘

Use UUIDs for identity

Generate IDs with str(uuid.uuid4()) rather than sequential integers. UUIDs are globally unique, safe to generate without a database, and make distributed systems much simpler to build.

πŸ“¦

Repository pattern = swappable storage

Your InMemoryRepository can be replaced with a SQLAlchemy, MongoDB, or Firebase implementation without touching a single line of domain code β€” that's the power of the repository abstraction.

πŸ”„

Timestamps on every mutation

Always store created_at and updated_at on mutable entities. This enables audit logs, debugging, and cache invalidation with no extra effort β€” a habit that pays off at scale.

πŸ—Ί Learning Roadmap

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 (Optional, List, Dict), 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, Order, and Address. 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 (place_order), and test with Swagger UI at /docs.

FastAPIPydantic SchemasREST EndpointsSwagger/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 with the domain.

SQLAlchemy ORMPostgreSQLAlembic MigrationsData 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

🧠 Test Your Knowledge

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

βš–οΈ Bad Code vs Clean Code

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.
βœ… Link copied to clipboard!

πŸ’¬ Comments

Questions, insights, or feedback? Join the conversation below.

Leave a Comment

πŸš€ Real-World Extensions

How to evolve this system into a production-ready application.

🌐 Add a REST API

Wrap this domain in a FastAPI application. Each cart action becomes an HTTP endpoint β€” POST /cart/items, DELETE /cart/items/{id}, POST /orders. The domain stays unchanged; only the presentation layer changes.

πŸ—„ Persist to a Database

Replace InMemoryRepository with a SQLAlchemy implementation. Define ORM models that map to your domain entities. Your application service (place_order) doesn't need to know whether storage is in-memory or PostgreSQL.

πŸ’³ Payment Integration

Add a PaymentService interface and implement it with PayMongo (for the Philippine market) or Stripe. The Order entity gains a PAYMENT_PENDING β†’ PAID transition in its status lifecycle.

πŸ“§ Email Notifications

After place_order() succeeds, publish a domain event (OrderPlacedEvent). An event handler sends confirmation emails via SendGrid or AWS SES β€” decoupled from the core order logic.

🀝 Follow Valleys & Bytes

Stay connected for more in-depth Python, codes, and techniques.

πŸŽ“ You've Reached the End β€” Great Work!

You've just explored a production-grade Python e-commerce system from value objects and aggregate roots all the way to order placement and CLI interaction. The patterns here β€” DDD, repository abstraction, invariant enforcement β€” are the same ones used by engineering teams at top tech companies.

Your next step: clone the code, run it locally, then try replacing the CLI with a FastAPI REST layer. Once you do that, you'll truly understand why clean architecture matters.

Written with ❀️ by Valleys & Bytes · Hosted on code-sense.pansensoyglenn.workers.dev

page preview