π 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.
π― 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()
π Logical Explanation
A component-by-component breakdown of the system's most important pieces.
@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.
@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 -= qtyWhy? 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.
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 totalWhy? 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.")
π DDD Concepts Reference
Quick-reference guide to the patterns used in this system.
| Concept | Class / Function | Pattern 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 |
Address | Value Object | Immutable | Shipping address without identity |
Order | Entity | Mutable | Order lifecycle tracking from PENDING to SHIPPED |
InMemoryProductRepository | Repository | Storage | Persist and query products (swappable with DB) |
InMemoryOrderRepository | Repository | Storage | Save and retrieve placed orders |
place_order() | Application Service | Orchestrator | Coordinates 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.
π 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.
β‘ 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.
π 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.
π 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.
π§ 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.
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.
π¬ Comments
Questions, insights, or feedback? Join the conversation below.
π 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.
π¬ Never Miss an Article
Get the latest Python tutorials, architecture guides, and coding insights delivered straight to your inbox. No spam β ever.
π 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
Leave a Comment