392 lines
15 KiB
Markdown
392 lines
15 KiB
Markdown
# Advanced Architecture Patterns — Reference
|
|
|
|
Deep-dive implementation examples for DDD bounded contexts, Onion Architecture, Anti-Corruption Layers, and full project structures. Referenced from SKILL.md.
|
|
|
|
---
|
|
|
|
## Full Multi-Service Project Structure
|
|
|
|
A realistic e-commerce system organised by bounded context, each context is a deployable service:
|
|
|
|
```
|
|
ecommerce/
|
|
├── services/
|
|
│ ├── identity/ # Bounded context: users & auth
|
|
│ │ ├── identity/
|
|
│ │ │ ├── domain/
|
|
│ │ │ │ ├── entities/
|
|
│ │ │ │ │ └── user.py
|
|
│ │ │ │ ├── value_objects/
|
|
│ │ │ │ │ ├── email.py
|
|
│ │ │ │ │ └── password_hash.py
|
|
│ │ │ │ └── interfaces/
|
|
│ │ │ │ └── user_repository.py
|
|
│ │ │ ├── use_cases/
|
|
│ │ │ │ ├── register_user.py
|
|
│ │ │ │ └── authenticate_user.py
|
|
│ │ │ ├── adapters/
|
|
│ │ │ │ ├── repositories/
|
|
│ │ │ │ │ └── postgres_user_repository.py
|
|
│ │ │ │ └── controllers/
|
|
│ │ │ │ └── auth_controller.py
|
|
│ │ │ └── infrastructure/
|
|
│ │ │ └── jwt_service.py
|
|
│ │ └── tests/
|
|
│ │ ├── unit/
|
|
│ │ └── integration/
|
|
│ │
|
|
│ ├── catalog/ # Bounded context: products
|
|
│ │ ├── catalog/
|
|
│ │ │ ├── domain/
|
|
│ │ │ │ ├── entities/
|
|
│ │ │ │ │ └── product.py
|
|
│ │ │ │ └── value_objects/
|
|
│ │ │ │ ├── sku.py
|
|
│ │ │ │ └── price.py
|
|
│ │ │ └── use_cases/
|
|
│ │ │ ├── create_product.py
|
|
│ │ │ └── update_inventory.py
|
|
│ │ └── tests/
|
|
│ │
|
|
│ └── ordering/ # Bounded context: orders
|
|
│ ├── ordering/
|
|
│ │ ├── domain/
|
|
│ │ │ ├── entities/
|
|
│ │ │ │ └── order.py
|
|
│ │ │ ├── value_objects/
|
|
│ │ │ │ ├── customer_id.py # NOT imported from identity!
|
|
│ │ │ │ └── money.py
|
|
│ │ │ └── interfaces/
|
|
│ │ │ ├── order_repository.py
|
|
│ │ │ └── catalog_client.py # ACL port to catalog context
|
|
│ │ ├── use_cases/
|
|
│ │ │ ├── place_order.py
|
|
│ │ │ └── cancel_order.py
|
|
│ │ └── adapters/
|
|
│ │ ├── acl/
|
|
│ │ │ └── catalog_http_client.py # ACL adapter
|
|
│ │ └── repositories/
|
|
│ │ └── postgres_order_repository.py
|
|
│ └── tests/
|
|
│
|
|
├── shared/ # Shared kernel (use sparingly)
|
|
│ └── domain_events/
|
|
│ └── base_event.py
|
|
└── docker-compose.yml
|
|
```
|
|
|
|
---
|
|
|
|
## Onion Architecture vs. Clean Architecture
|
|
|
|
Both enforce inward-pointing dependencies. The difference is terminology and layering granularity:
|
|
|
|
| Concern | Clean Architecture | Onion Architecture |
|
|
|---|---|---|
|
|
| Innermost ring | Entities | Domain Model |
|
|
| Second ring | Use Cases | Domain Services |
|
|
| Third ring | Interface Adapters | Application Services |
|
|
| Outermost ring | Frameworks & Drivers | Infrastructure / UI / Tests |
|
|
| Key insight | Controller is an adapter | Application Services = Use Cases |
|
|
|
|
Onion Architecture makes the Domain Services layer explicit — it hosts pure domain logic that spans multiple entities but has no I/O:
|
|
|
|
```python
|
|
# onion/domain/services/pricing_service.py
|
|
from domain.entities.product import Product
|
|
from domain.value_objects.money import Money
|
|
from domain.value_objects.discount import Discount
|
|
|
|
class PricingService:
|
|
"""
|
|
Domain service: logic that doesn't belong to a single entity.
|
|
No ports or adapters here — purely domain computation.
|
|
"""
|
|
|
|
def apply_bulk_discount(self, product: Product, quantity: int) -> Money:
|
|
if quantity >= 100:
|
|
discount = Discount(percentage=20)
|
|
elif quantity >= 50:
|
|
discount = Discount(percentage=10)
|
|
else:
|
|
discount = Discount(percentage=0)
|
|
return product.price.apply_discount(discount)
|
|
|
|
def calculate_order_total(self, items: list[tuple[Product, int]]) -> Money:
|
|
subtotals = [self.apply_bulk_discount(p, q) for p, q in items]
|
|
return sum(subtotals[1:], subtotals[0]) if subtotals else Money(0, "USD")
|
|
```
|
|
|
|
---
|
|
|
|
## Anti-Corruption Layer (ACL)
|
|
|
|
When the `Ordering` context must fetch product data from the `Catalog` context, it should never use `Catalog`'s domain model directly. An ACL translates between the two models:
|
|
|
|
```python
|
|
# ordering/domain/interfaces/catalog_client.py
|
|
from abc import ABC, abstractmethod
|
|
from ordering.domain.value_objects.product_snapshot import ProductSnapshot
|
|
|
|
class CatalogClientPort(ABC):
|
|
"""
|
|
Ordering's view of product data. Uses Ordering's own value object,
|
|
not Catalog's Product entity.
|
|
"""
|
|
|
|
@abstractmethod
|
|
async def get_product_snapshot(self, sku: str) -> ProductSnapshot: ...
|
|
|
|
|
|
# ordering/domain/value_objects/product_snapshot.py
|
|
from dataclasses import dataclass
|
|
from ordering.domain.value_objects.money import Money
|
|
|
|
@dataclass(frozen=True)
|
|
class ProductSnapshot:
|
|
"""Ordering's local representation of a product at order time."""
|
|
sku: str
|
|
name: str
|
|
unit_price: Money
|
|
available: bool
|
|
|
|
|
|
# ordering/adapters/acl/catalog_http_client.py
|
|
import httpx
|
|
from ordering.domain.interfaces.catalog_client import CatalogClientPort
|
|
from ordering.domain.value_objects.product_snapshot import ProductSnapshot
|
|
from ordering.domain.value_objects.money import Money
|
|
|
|
class CatalogHttpClient(CatalogClientPort):
|
|
"""
|
|
ACL adapter: calls Catalog's HTTP API and translates
|
|
Catalog's response schema into Ordering's ProductSnapshot.
|
|
"""
|
|
|
|
def __init__(self, base_url: str, http_client: httpx.AsyncClient):
|
|
self._base_url = base_url
|
|
self._http = http_client
|
|
|
|
async def get_product_snapshot(self, sku: str) -> ProductSnapshot:
|
|
response = await self._http.get(f"{self._base_url}/products/{sku}")
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
# Translation: Catalog speaks "price_cents" + "currency_code";
|
|
# Ordering speaks Money(amount, currency).
|
|
return ProductSnapshot(
|
|
sku=data["sku"],
|
|
name=data["title"], # field name differs between contexts
|
|
unit_price=Money(
|
|
amount=data["price_cents"],
|
|
currency=data["currency_code"],
|
|
),
|
|
available=data["stock_count"] > 0,
|
|
)
|
|
|
|
|
|
# Test ACL with a stub — no HTTP required
|
|
class StubCatalogClient(CatalogClientPort):
|
|
def __init__(self, products: dict[str, ProductSnapshot]):
|
|
self._products = products
|
|
|
|
async def get_product_snapshot(self, sku: str) -> ProductSnapshot:
|
|
if sku not in self._products:
|
|
raise ValueError(f"Unknown SKU: {sku}")
|
|
return self._products[sku]
|
|
```
|
|
|
|
---
|
|
|
|
## Context Map — Relationships Between Bounded Contexts
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ E-Commerce System │
|
|
│ │
|
|
│ ┌─────────────┐ Open Host ┌─────────────────────────┐ │
|
|
│ │ Identity │──────────────▶│ Ordering │ │
|
|
│ │ Context │ │ (uses CustomerId VO, │ │
|
|
│ │ │ │ not User entity) │ │
|
|
│ └─────────────┘ └─────────────────────────┘ │
|
|
│ │ ACL │
|
|
│ ▼ │
|
|
│ ┌─────────────────┐ │
|
|
│ ┌─────────────┐ Shared │ Catalog │ │
|
|
│ │ Payments │ Kernel │ Context │ │
|
|
│ │ Context │◀─────────────▶│ │ │
|
|
│ │ │ (Money VO) └─────────────────┘ │
|
|
│ └─────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
|
|
Relationship types:
|
|
Open Host Service — upstream provides a stable API for many downstream contexts
|
|
ACL (Anti-Corruption Layer) — downstream translates upstream model to its own
|
|
Shared Kernel — two contexts share a small, explicitly governed sub-model
|
|
Conformist — downstream adopts upstream model as-is (last resort)
|
|
```
|
|
|
|
---
|
|
|
|
## Dependency Injection Wiring — Infrastructure Layer
|
|
|
|
All the abstract interfaces are wired to concrete implementations in the infrastructure layer (or a DI container). Nothing else in the codebase knows which concrete class is used:
|
|
|
|
```python
|
|
# infrastructure/container.py
|
|
from functools import lru_cache
|
|
import asyncpg
|
|
from adapters.repositories.postgres_user_repository import PostgresUserRepository
|
|
from adapters.gateways.stripe_payment_gateway import StripePaymentAdapter
|
|
from use_cases.create_user import CreateUserUseCase
|
|
from infrastructure.config import Settings
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
return Settings()
|
|
|
|
async def get_db_pool() -> asyncpg.Pool:
|
|
settings = get_settings()
|
|
return await asyncpg.create_pool(settings.database_url)
|
|
|
|
async def get_create_user_use_case() -> CreateUserUseCase:
|
|
pool = await get_db_pool()
|
|
repo = PostgresUserRepository(pool=pool)
|
|
return CreateUserUseCase(user_repository=repo)
|
|
|
|
# In tests, replace get_create_user_use_case with a version
|
|
# that injects InMemoryUserRepository — no other code changes needed.
|
|
```
|
|
|
|
---
|
|
|
|
## Aggregate Design Heuristics
|
|
|
|
Use these rules when deciding aggregate boundaries:
|
|
|
|
| Question | Guidance |
|
|
|---|---|
|
|
| Should these two objects always be consistent together? | Put them in the same aggregate. |
|
|
| Can they be eventually consistent? | Put them in separate aggregates; use domain events to sync. |
|
|
| Is one object the "owner" that controls access? | That object is the aggregate root. |
|
|
| Does removing the root make the child meaningless? | Child belongs inside the aggregate. |
|
|
| Are you loading thousands of objects to change one? | Aggregate is too large — split it. |
|
|
|
|
**Practical example — Order vs. Customer:**
|
|
|
|
```python
|
|
# Bad: Customer aggregate holds full Order objects
|
|
class Customer:
|
|
def __init__(self):
|
|
self._orders: list[Order] = [] # loads all orders every time
|
|
|
|
# Good: Customer holds Order IDs only; Order is its own aggregate
|
|
class Customer:
|
|
def __init__(self):
|
|
self._order_ids: list[str] = [] # lightweight reference
|
|
|
|
class Order:
|
|
def __init__(self, id: str, customer_id: str):
|
|
self.id = id
|
|
self.customer_id = customer_id # reference back, not the full object
|
|
```
|
|
|
|
---
|
|
|
|
## Domain Events — Publishing and Handling
|
|
|
|
Domain events decouple aggregates that need to react to each other's state changes:
|
|
|
|
```python
|
|
# domain/events/order_events.py
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
@dataclass
|
|
class DomainEvent:
|
|
occurred_at: datetime = field(default_factory=datetime.utcnow)
|
|
|
|
@dataclass
|
|
class OrderSubmittedEvent(DomainEvent):
|
|
order_id: str = ""
|
|
customer_id: str = ""
|
|
total_cents: int = 0
|
|
currency: str = "USD"
|
|
|
|
|
|
# adapters/event_publisher/postgres_outbox.py
|
|
# Transactional outbox pattern: write events to the same DB transaction as state
|
|
import json
|
|
|
|
class PostgresOutboxPublisher:
|
|
"""
|
|
Writes domain events to an outbox table in the same transaction
|
|
as the aggregate state. A separate relay process reads and publishes
|
|
to the message broker. Guarantees at-least-once delivery.
|
|
"""
|
|
|
|
async def publish(self, conn, events: list[DomainEvent]):
|
|
for event in events:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO outbox (event_type, payload, published_at)
|
|
VALUES ($1, $2, NULL)
|
|
""",
|
|
type(event).__name__,
|
|
json.dumps(event.__dict__, default=str),
|
|
)
|
|
|
|
|
|
# use_cases/place_order.py — aggregate saves, events are extracted and stored
|
|
class PlaceOrderUseCase:
|
|
def __init__(self, order_repo: OrderRepository, event_publisher: PostgresOutboxPublisher):
|
|
self.orders = order_repo
|
|
self.publisher = event_publisher
|
|
|
|
async def execute(self, request: PlaceOrderRequest) -> PlaceOrderResponse:
|
|
order = Order(id=str(uuid.uuid4()), customer_id=request.customer_id)
|
|
for item in request.items:
|
|
order.add_item(product=item.product, quantity=item.quantity)
|
|
order.submit()
|
|
|
|
async with self.db.transaction() as conn:
|
|
await self.orders.save(order, conn)
|
|
await self.publisher.publish(conn, order.pop_events())
|
|
|
|
return PlaceOrderResponse(order_id=order.id, success=True)
|
|
```
|
|
|
|
---
|
|
|
|
## Detecting and Breaking Dependency Cycles
|
|
|
|
Common symptoms and their structural fixes:
|
|
|
|
```
|
|
Symptom: use_cases/create_order.py imports from adapters/email_sender.py
|
|
Fix: Create domain/interfaces/notification_service.py (abstract port).
|
|
use_cases imports the port. adapters implements it.
|
|
DI container wires them together.
|
|
|
|
Symptom: domain/entities/user.py imports from infrastructure/config.py
|
|
Fix: Pass config values as constructor arguments or environment at
|
|
the infrastructure boundary. Domain entities must not read config.
|
|
|
|
Symptom: Two aggregates import each other
|
|
Fix: Introduce a domain event. Aggregate A emits OrderPlaced.
|
|
Aggregate B's use case subscribes and reacts. They never import
|
|
each other.
|
|
|
|
Symptom: Repository imports a use case to "do extra work" after saving
|
|
Fix: Extract the extra work into a separate domain service or use case.
|
|
Repositories persist state only; they do not orchestrate behaviour.
|
|
```
|
|
|
|
Visual dependency check — run this and look for any arrow pointing outward:
|
|
|
|
```bash
|
|
# Install: pip install pydeps
|
|
pydeps app --max-bacon=4 --cluster --rankdir=BT
|
|
# Expected: domain has no outgoing edges to adapters or infrastructure
|
|
```
|