Implementación de DDD en Python
Implementación completa de extremo a extremo de un modelo de dominio de comercio electrónico en Python, integrando todos los patrones tácticos, Entidades, Objetos de Valor, Agregados, Repositorios, Servicios de Dominio y Eventos de Dominio
Esta lección final reúne todo lo que has aprendido en el curso: Lenguaje Ubicuo, Contextos Delimitados, Entidades, Objetos de Valor, Agregados, Repositorios, Servicios de Dominio y Eventos de Dominio. Construiremos un modelo de dominio completo de comercio electrónico en Python.
Este no es un ejemplo de "juguete". El código es de calidad de producción y sigue prácticas reales de DDD. Cada patrón se usa donde le corresponde. El foco está en la capa de dominio: las preocupaciones de infraestructura (base de datos, framework web, broker de mensajes) quedan abstraídas detrás de interfaces.
Estructura del proyecto
ecommerce/
domain/
__init__.py
models/
__init__.py
customer.py
product.py
order.py
payment.py
shipping.py
services/
__init__.py
pricing_service.py
fraud_detection.py
inventory_service.py
events/
__init__.py
order_events.py
payment_events.py
repositories/
__init__.py
customer_repository.py
order_repository.py
product_repository.py
application/
__init__.py
commands/
place_order.py
cancel_order.py
queries/
order_queries.py
services/
order_application_service.py
infrastructure/
__init__.py
persistence/
sqlite_order_repo.py
sqlite_customer_repo.py
eventbus/
in_process_event_bus.py
1. Objetos de Valor
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Optional
from datetime import datetime
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("Money amount cannot be negative")
if not self.currency or len(self.currency) != 3:
raise ValueError("Currency must be a 3-letter ISO code")
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Cannot add {other.currency} to {self.currency}")
return Money(self.amount + other.amount, self.currency)
def __sub__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Cannot subtract {other.currency} from {self.currency}")
if self.amount < other.amount:
raise ValueError("Insufficient funds")
return Money(self.amount - other.amount, self.currency)
def __mul__(self, multiplier: int) -> "Money":
return Money(self.amount * multiplier, self.currency)
def __repr__(self) -> str:
return f"{self.currency} {self.amount:.2f}"
@dataclass(frozen=True)
class Address:
street: str
city: str
state: str
zip_code: str
country: str
def is_domestic(self) -> bool:
return self.country.upper() == "US"
@dataclass(frozen=True)
class CustomerName:
first: str
last: str
def __repr__(self) -> str:
return f"{self.first} {self.last}"
@dataclass(frozen=True)
class Email:
address: str
def __post_init__(self) -> None:
if "@" not in self.address or "." not in self.address:
raise ValueError(f"Invalid email: {self.address}")
@dataclass(frozen=True)
class Sku:
"""Stock Keeping Unit — a unique product identifier."""
value: str
def __post_init__(self) -> None:
if not self.value or len(self.value) < 3:
raise ValueError("SKU must be at least 3 characters")2. Eventos de Dominio
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
@dataclass
class DomainEvent:
occurred_at: datetime = field(default_factory=datetime.now)
@dataclass
class CustomerRegistered(DomainEvent):
customer_id: str
email: str
name: str
@dataclass
class ProductCreated(DomainEvent):
product_id: str
sku: str
name: str
@dataclass
class OrderPlaced(DomainEvent):
order_id: str
customer_id: str
total: Money
items: List[dict]
@dataclass
class OrderConfirmed(DomainEvent):
order_id: str
confirmed_at: datetime = field(default_factory=datetime.now)
@dataclass
class OrderCancelled(DomainEvent):
order_id: str
reason: str
cancelled_at: datetime = field(default_factory=datetime.now)
@dataclass
class PaymentCaptured(DomainEvent):
order_id: str
transaction_id: str
amount: Money
@dataclass
class InventoryAdjusted(DomainEvent):
product_id: str
quantity_change: int
reason: str3. Entidades y Agregados
Agregado Cliente
from typing import List, Optional
from dataclasses import dataclass, field
import uuid
class Customer:
"""Aggregate Root for customer management."""
def __init__(self, name: CustomerName, email: Email):
self._id = f"CUST-{uuid.uuid4().hex[:8].upper()}"
self._name = name
self._email = email
self._loyalty_points: int = 0
self._tier: str = "standard"
self._is_active: bool = True
self._addresses: List[Address] = []
self._events: List[DomainEvent] = []
@property
def id(self) -> str: return self._id
@property
def name(self) -> CustomerName: return self._name
@property
def email(self) -> Email: return self._email
@property
def tier(self) -> str: return self._tier
@property
def is_active(self) -> bool: return self._is_active
@property
def loyalty_points(self) -> int: return self._loyalty_points
def register(self) -> None:
self._events.append(CustomerRegistered(
customer_id=self._id,
email=self._email.address,
name=str(self._name)
))
def add_address(self, address: Address) -> None:
self._addresses.append(address)
def add_loyalty_points(self, points: int) -> None:
if points < 0:
raise ValueError("Points cannot be negative")
self._loyalty_points += points
if self._loyalty_points >= 1000:
self._tier = "gold"
elif self._loyalty_points >= 500:
self._tier = "silver"
def change_email(self, new_email: Email) -> None:
if not self._is_active:
raise ValueError("Cannot change email on inactive account")
self._email = new_email
def deactivate(self) -> None:
self._is_active = False
def pull_events(self) -> List[DomainEvent]:
events = list(self._events)
self._events.clear()
return events
def __eq__(self, other: object) -> bool:
if not isinstance(other, Customer):
return False
return self._id == other._id
def __hash__(self) -> int:
return hash(self._id)Agregado Producto
class Product:
"""Aggregate Root for product catalog."""
def __init__(self, sku: Sku, name: str, price: Money, stock: int):
self._id = f"PROD-{sku.value}"
self._sku = sku
self._name = name
self._price = price
self._stock_quantity = stock
self._reserved_quantity: int = 0
self._is_active: bool = True
self._events: List[DomainEvent] = []
@property
def id(self) -> str: return self._id
@property
def sku(self) -> Sku: return self._sku
@property
def name(self) -> str: return self._name
@property
def price(self) -> Money: return self._price
@property
def available_stock(self) -> int:
return self._stock_quantity - self._reserved_quantity
def adjust_price(self, new_price: Money) -> None:
if not self._is_active:
raise ValueError("Cannot adjust price of inactive product")
self._price = new_price
def reserve_stock(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("Reservation quantity must be positive")
if quantity > self.available_stock:
raise ValueError(f"Insufficient stock. Available: {self.available_stock}")
self._reserved_quantity += quantity
def release_stock(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("Release quantity must be positive")
if quantity > self._reserved_quantity:
raise ValueError(f"Cannot release more than reserved: {self._reserved_quantity}")
self._reserved_quantity -= quantity
def receive_stock(self, quantity: int) -> None:
if quantity <= 0:
raise ValueError("Received quantity must be positive")
self._stock_quantity += quantity
self._events.append(InventoryAdjusted(
product_id=self._id,
quantity_change=quantity,
reason="stock_receipt"
))
def deactivate(self) -> None:
self._is_active = False
def pull_events(self) -> List[DomainEvent]:
events = list(self._events)
self._events.clear()
return events
def __eq__(self, other: object) -> bool:
if not isinstance(other, Product):
return False
return self._id == other._id
def __hash__(self) -> int:
return hash(self._id)Agregado Pedido
class OrderLine:
"""Entity inside Order aggregate — not a root."""
def __init__(self, product_id: str, product_name: str,
sku: str, quantity: int, unit_price: Money):
self._line_id = f"LN-{uuid.uuid4().hex[:8].upper()}"
self._product_id = product_id
self._product_name = product_name
self._sku = sku
self._quantity = quantity
self._unit_price = unit_price
@property
def line_id(self) -> str: return self._line_id
@property
def product_id(self) -> str: return self._product_id
@property
def product_name(self) -> str: return self._product_name
@property
def sku(self) -> str: return self._sku
@property
def quantity(self) -> int: return self._quantity
@property
def unit_price(self) -> Money: return self._unit_price
def subtotal(self) -> Money:
return self._unit_price * self._quantity
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class Order:
"""Aggregate Root for orders — the most important aggregate."""
def __init__(self, customer_id: str, shipping_address: Address):
self._id = f"ORD-{uuid.uuid4().hex[:8].upper()}"
self._customer_id = customer_id
self._shipping_address = shipping_address
self._lines: List[OrderLine] = []
self._status = OrderStatus.PENDING
self._placed_at = datetime.now()
self._events: List[DomainEvent] = []
@property
def id(self) -> str: return self._id
@property
def customer_id(self) -> str: return self._customer_id
@property
def status(self) -> OrderStatus: return self._status
@property
def lines(self) -> List[OrderLine]: return list(self._lines)
@property
def placed_at(self) -> datetime: return self._placed_at
def add_product(self, product_id: str, product_name: str,
sku: str, quantity: int, unit_price: Money) -> None:
if quantity <= 0:
raise ValueError("Quantity must be positive")
if self._status != OrderStatus.PENDING:
raise ValueError(f"Cannot modify order in status {self._status.value}")
self._lines.append(OrderLine(
product_id, product_name, sku, quantity, unit_price
))
def remove_product(self, product_id: str) -> None:
if self._status != OrderStatus.PENDING:
raise ValueError(f"Cannot modify order in status {self._status.value}")
original = len(self._lines)
self._lines = [l for l in self._lines if l.product_id != product_id]
if len(self._lines) == original:
raise ValueError(f"Product {product_id} not in order")
@property
def total(self) -> Money:
if not self._lines:
return Money(Decimal("0.00"), "USD")
currency = self._lines[0].unit_price.currency
total = Money(Decimal("0.00"), currency)
for line in self._lines:
total = total + line.subtotal()
return total
@property
def item_count(self) -> int:
return len(self._lines)
def confirm(self) -> None:
if self._status != OrderStatus.PENDING:
raise ValueError(f"Cannot confirm order in status {self._status.value}")
if not self._lines:
raise ValueError("Cannot confirm an empty order")
self._status = OrderStatus.CONFIRMED
self._events.append(OrderConfirmed(order_id=self._id))
def mark_paid(self) -> None:
if self._status != OrderStatus.CONFIRMED:
raise ValueError(f"Cannot mark unpaid order as paid")
self._status = OrderStatus.PAID
def ship(self) -> None:
if self._status != OrderStatus.PAID:
raise ValueError("Can only ship paid orders")
self._status = OrderStatus.SHIPPED
def mark_delivered(self) -> None:
if self._status != OrderStatus.SHIPPED:
raise ValueError("Can only mark shipped orders as delivered")
self._status = OrderStatus.DELIVERED
def cancel(self, reason: str) -> None:
if self._status in (OrderStatus.SHIPPED, OrderStatus.DELIVERED):
raise ValueError("Cannot cancel shipped or delivered order")
self._status = OrderStatus.CANCELLED
self._events.append(OrderCancelled(
order_id=self._id, reason=reason
))
def pull_events(self) -> List[DomainEvent]:
events = list(self._events)
self._events.clear()
return events
def __eq__(self, other: object) -> bool:
if not isinstance(other, Order):
return False
return self._id == other._id
def __hash__(self) -> int:
return hash(self._id)4. Interfaces de Repositorio
from typing import Protocol, List, Optional
class CustomerRepository(Protocol):
def find_by_id(self, customer_id: str) -> Optional[Customer]: ...
def find_by_email(self, email: str) -> Optional[Customer]: ...
def save(self, customer: Customer) -> None: ...
def delete(self, customer_id: str) -> None: ...
class ProductRepository(Protocol):
def find_by_id(self, product_id: str) -> Optional[Product]: ...
def find_by_sku(self, sku: str) -> Optional[Product]: ...
def find_active_products(self) -> List[Product]: ...
def save(self, product: Product) -> None: ...
def delete(self, product_id: str) -> None: ...
class OrderRepository(Protocol):
def find_by_id(self, order_id: str) -> Optional[Order]: ...
def find_by_customer(self, customer_id: str) -> List[Order]: ...
def find_pending_orders(self) -> List[Order]: ...
def save(self, order: Order) -> None: ...
def delete(self, order_id: str) -> None: ...5. Implementaciones de Repositorio en memoria (para pruebas)
class InMemoryCustomerRepository:
def __init__(self):
self._customers: dict[str, Customer] = {}
def find_by_id(self, customer_id: str) -> Optional[Customer]:
return self._customers.get(customer_id)
def find_by_email(self, email: str) -> Optional[Customer]:
for c in self._customers.values():
if c.email.address == email:
return c
return None
def save(self, customer: Customer) -> None:
self._customers[customer.id] = customer
def delete(self, customer_id: str) -> None:
self._customers.pop(customer_id, None)
class InMemoryProductRepository:
def __init__(self):
self._products: dict[str, Product] = {}
def find_by_id(self, product_id: str) -> Optional[Product]:
return self._products.get(product_id)
def find_by_sku(self, sku: str) -> Optional[Product]:
for p in self._products.values():
if p.sku.value == sku:
return p
return None
def find_active_products(self) -> List[Product]:
return [p for p in self._products.values() if p.is_active]
def save(self, product: Product) -> None:
self._products[product.id] = product
def delete(self, product_id: str) -> None:
self._products.pop(product_id, None)
class InMemoryOrderRepository:
def __init__(self):
self._orders: dict[str, Order] = {}
def find_by_id(self, order_id: str) -> Optional[Order]:
return self._orders.get(order_id)
def find_by_customer(self, customer_id: str) -> List[Order]:
return [o for o in self._orders.values()
if o.customer_id == customer_id]
def find_pending_orders(self) -> List[Order]:
return [o for o in self._orders.values()
if o.status == OrderStatus.PENDING]
def save(self, order: Order) -> None:
self._orders[order.id] = order
def delete(self, order_id: str) -> None:
self._orders.pop(order_id, None)6. Servicios de Dominio
class PricingService:
"""Domain Service for pricing calculations."""
GOLD_DISCOUNT_RATE = Decimal("0.15")
SILVER_DISCOUNT_RATE = Decimal("0.10")
STANDARD_TAX_RATE = Decimal("0.08")
def calculate_total(self, order: Order, customer: Customer) -> Money:
subtotal = order.total
discount = self._apply_tier_discount(subtotal, customer.tier)
tax = self._calculate_tax(discount)
return discount + tax
def _apply_tier_discount(self, total: Money, tier: str) -> Money:
if tier == "gold":
discount_amount = total.amount * self.GOLD_DISCOUNT_RATE
return Money(total.amount - discount_amount, total.currency)
elif tier == "silver":
discount_amount = total.amount * self.SILVER_DISCOUNT_RATE
return Money(total.amount - discount_amount, total.currency)
return total
def _calculate_tax(self, amount: Money) -> Money:
tax_amount = amount.amount * self.STANDARD_TAX_RATE
return Money(tax_amount, amount.currency)
class FraudDetectionService:
"""Domain Service for fraud assessment."""
def assess(self, order: Order, customer: Customer, order_repo: OrderRepository) -> dict:
score = 0
reasons = []
if order.total.amount > 5000:
score += 30
reasons.append("Order exceeds $5,000")
recent = order_repo.find_by_customer(customer.id)
recent_24h = sum(1 for o in recent if
(datetime.now() - o.placed_at).total_seconds() < 86400)
if recent_24h >= 3:
score += 25
reasons.append("More than 3 orders in 24 hours")
if customer.loyalty_points < 100 and order.total.amount > 2000:
score += 20
reasons.append("New customer, high value order")
return {"risk": "high" if score > 50 else "medium" if score > 25 else "low",
"score": score, "reasons": reasons}7. Bus de eventos
from typing import Callable, Type, Dict, List
class EventBus:
def __init__(self):
self._handlers: Dict[Type, List[Callable]] = {}
def subscribe(self, event_type: Type, handler: Callable) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def publish(self, event: DomainEvent) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)8. Servicios de aplicación
class PlaceOrderHandler:
"""Application service: handles the PlaceOrder use case."""
def __init__(self, order_repo: OrderRepository, product_repo: ProductRepository,
customer_repo: CustomerRepository, pricing: PricingService,
fraud: FraudDetectionService, bus: EventBus):
self._order_repo = order_repo
self._product_repo = product_repo
self._customer_repo = customer_repo
self._pricing = pricing
self._fraud = fraud
self._bus = bus
def handle(self, customer_id: str, items: List[dict],
shipping_address: Address) -> Order:
customer = self._customer_repo.find_by_id(customer_id)
if not customer:
raise ValueError(f"Customer {customer_id} not found")
if not customer.is_active:
raise ValueError("Customer account is inactive")
order = Order(customer_id, shipping_address)
for item in items:
product = self._product_repo.find_by_id(item["product_id"])
if not product or not product.is_active:
raise ValueError(f"Product {item['product_id']} not available")
product.reserve_stock(item["quantity"])
order.add_product(
product.id, product.name, product.sku.value,
item["quantity"], product.price
)
self._product_repo.save(product)
fraud = self._fraud.assess(order, customer, self._order_repo)
if fraud["risk"] == "high":
raise ValueError(f"Order rejected by fraud detection: {fraud['reasons']}")
order.confirm()
self._order_repo.save(order)
for event in order.pull_events():
self._bus.publish(event)
self._bus.publish(OrderPlaced(
order_id=order.id, customer_id=customer_id,
total=order.total, items=items
))
return order9. Ejemplo de uso completo
# --- Setup ---
customer_repo = InMemoryCustomerRepository()
product_repo = InMemoryProductRepository()
order_repo = InMemoryOrderRepository()
bus = EventBus()
pricing = PricingService()
fraud = FraudDetectionService()
place_order = PlaceOrderHandler(order_repo, product_repo, customer_repo,
pricing, fraud, bus)
# --- Create a customer ---
customer = Customer(
CustomerName("Alice", "Johnson"),
Email("alice@example.com")
)
customer.register()
customer_repo.save(customer)
# --- Create products ---
widget = Product(Sku("WDG-001"), "Widget", Money(Decimal("29.99"), "USD"), 100)
gadget = Product(Sku("GDG-001"), "Gadget", Money(Decimal("49.99"), "USD"), 50)
product_repo.save(widget)
product_repo.save(gadget)
# --- Place an order ---
address = Address("123 Main St", "Portland", "OR", "97201", "US")
order = place_order.handle(customer.id, [
{"product_id": widget.id, "quantity": 2},
{"product_id": gadget.id, "quantity": 1},
], address)
print(f"Order placed: {order.id}")
print(f"Status: {order.status.value}")
print(f"Total: {order.total}")
print(f"Items: {order.item_count}")Este modelo de dominio completo de comercio electrónico demuestra todos los patrones tácticos de DDD trabajando en conjunto. La capa de dominio es Python puro sin dependencias de frameworks, testeable de forma aislada y expresiva del lenguaje de negocio.
Pruebas del modelo completo
import pytest
from decimal import Decimal
class TestEcommerceDomain:
def test_place_order_flow(self):
cust_repo = InMemoryCustomerRepository()
prod_repo = InMemoryProductRepository()
bus = EventBus()
customer = Customer(CustomerName("Bob", "Smith"), Email("bob@test.com"))
customer.register()
cust_repo.save(customer)
product = Product(Sku("TEST-001"), "Test Product",
Money(Decimal("10.00"), "USD"), 50)
prod_repo.save(product)
pricing = PricingService()
fraud = FraudDetectionService()
handler = PlaceOrderHandler(
InMemoryOrderRepository(), prod_repo, cust_repo,
pricing, fraud, bus
)
address = Address("1 Test St", "TestCity", "TS", "00000", "US")
order = handler.handle(customer.id, [
{"product_id": product.id, "quantity": 3}
], address)
assert order.status == OrderStatus.CONFIRMED
assert order.total == Money(Decimal("30.00"), "USD")
assert order.item_count == 1
# Stock was reserved
assert product.available_stock == 47
def test_cannot_place_order_inactive_customer(self):
cust_repo = InMemoryCustomerRepository()
prod_repo = InMemoryProductRepository()
bus = EventBus()
customer = Customer(CustomerName("Bad", "User"), Email("bad@test.com"))
customer.deactivate()
cust_repo.save(customer)
handler = PlaceOrderHandler(
InMemoryOrderRepository(), prod_repo, cust_repo,
PricingService(), FraudDetectionService(), bus
)
with pytest.raises(ValueError, match="inactive"):
handler.handle(customer.id, [], Address("x", "y", "z", "0", "US"))
def test_order_lifecycle(self):
order = Order("CUST-001", Address("A", "B", "C", "1", "US"))
order.add_product("P1", "Widget", "WDG", 2,
Money(Decimal("10.00"), "USD"))
assert order.status == OrderStatus.PENDING
order.confirm()
assert order.status == OrderStatus.CONFIRMED
order.mark_paid()
assert order.status == OrderStatus.PAID
order.ship()
assert order.status == OrderStatus.SHIPPED
order.mark_delivered()
assert order.status == OrderStatus.DELIVERED
def test_gold_customer_discount(self):
pricing = PricingService()
customer = Customer(CustomerName("Gold", "Member"),
Email("gold@test.com"))
customer.add_loyalty_points(1000)
order = Order(customer.id, Address("1", "2", "3", "4", "US"))
order.add_product("P1", "Item", "ITM", 1,
Money(Decimal("100.00"), "USD"))
total = pricing.calculate_total(order, customer)
expected = Money(Decimal("91.80"), "USD")
assert total == expectedEjercicios de Práctica
-
Añade un nuevo agregado: diseña e implementa un agregado
Returnpara manejar devoluciones de productos. Incluye objetos de valor paraReturnReason,RefundAmounty los estados de ciclo de vida apropiados. -
Implementa una saga: crea un
ReturnProcessingSagaque coordine los agregadosReturn,OrderyProductcuando se inicia una devolución. -
Añade un evento de dominio: cuando un cliente alcance el nivel gold, publica un evento
CustomerTierUpgraded. Crea un manejador que envíe una notificación de felicitación. -
Extiende el modelo de lectura: crea una
CustomerOrderHistoryProjectionque escuche los eventosOrderPlacedy construya un modelo de lectura desnormalizado para consultas rápidas del historial del cliente. -
Implementa el patrón de especificación: añade una
ReturnPolicySpecificationque defina si un producto es elegible para devolución según: el tiempo desde la compra (debe ser < 30 días), el estado del producto (no debe estar dañado) y el nivel del cliente (los miembros gold obtienen devoluciones extendidas). -
Manejo de concurrencia: añade concurrencia optimista al agregado
Productusando un campo de versión. Muestra cómoreserve_stockdebe fallar si el producto fue modificado por otra transacción. -
Añade un Servicio de Dominio para envíos: crea un
ShippingCostServiceque calcule los costos de envío según: peso, zona de distancia, velocidad de envío (estándar/exprés/nocturno) y nivel del cliente. -
Prueba de integración completa: escribe una prueba de integración que cree un cliente, añada productos al catálogo, haga un pedido, lo confirme, lo marque como pagado, lo envíe y verifique que todos los eventos se publicaron correctamente.
Has completado la Lección 8 y todo el curso de DDD y Arquitectura de Software. Ahora tienes un modelo de dominio de comercio electrónico funcional que demuestra cada patrón táctico de DDD. Aplica estos patrones a tus propios dominios, empezando siempre por el Lenguaje Ubicuo y los Contextos Delimitados, y dejando que los patrones tácticos emergan del modelo.