Modern Python Engineering
Chapitre 10
10 - Architecture Python
> **Durée :** 3 semaines > **Objectif :** Maîtriser les architectures logicielles en Python : Clean Architecture, DDD, Hexagonal.
Cours 10 : Architecture Logicielle en Python
1. Clean Architecture
1.1 Principes fondamentaux
La Clean Architecture (Robert C. Martin) organise le code en cercles concentriques :
┌─────────────────────────────────────┐
│ Frameworks & Drivers │
│ ┌───────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ Application Layer │ │ │
│ │ │ ┌───────────────────┐ │ │ │
│ │ │ │ Domain Layer │ │ │ │
│ │ │ │ (Entities) │ │ │ │
│ │ │ └───────────────────┘ │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Règle d'or : Les dépendances vont de l'exterieur vers l'interieur. Le code métier (domaine) ne dépend de rien d'autre que du langage lui-même.
1.2 Entities (Domaine)
from dataclasses import dataclass
from uuid import UUID, uuid4
from abc import ABC, abstractmethod
@dataclass(frozen=True)
class Email:
address: str
def __post_init__(self):
if "@" not in self.address:
raise ValueError(f"Invalid email: {self.address}")
class User:
def __init__(self, id: UUID, email: Email, name: str):
self.id = id
self.email = email
self.name = name
def change_email(self, email: str) -> None:
self.email = Email(email)
1.3 Use Cases (Application)
class CreateUserUseCase:
def __init__(self, repo: "UserRepository"):
self.repo = repo
def execute(self, name: str, email: str) -> User:
user = User(id=uuid4(), email=Email(email), name=name)
self.repo.save(user)
return user
class UserRepository(ABC):
@abstractmethod
def save(self, user: User) -> None: ...
@abstractmethod
def find_by_id(self, id: UUID) -> User: ...
1.4 Infrastructure (Gateways)
from sqlalchemy.orm import Session
class PostgresUserRepository(UserRepository):
def __init__(self, session: Session):
self.session = session
def save(self, user: User) -> None:
model = UserModel(id=str(user.id), name=user.name, email=user.email.address)
self.session.add(model)
self.session.commit()
def find_by_id(self, id: UUID) -> User:
model = self.session.query(UserModel).filter_by(id=str(id)).first()
return User(id=UUID(model.id), email=Email(model.email), name=model.name)
1.5 Interfaces (API)
from fastapi import FastAPI, Depends
app = FastAPI()
def get_user_repo() -> UserRepository:
return PostgresUserRepository(get_session())
@app.post("/users")
def create_user(name: str, email: str, repo: UserRepository = Depends(get_user_repo)):
use_case = CreateUserUseCase(repo)
return use_case.execute(name, email)
2. Domain-Driven Design (DDD)
2.1 Aggregates
Un Aggregate est un cluster d'objets du domaine traité comme une unité cohérente :
from abc import ABC
from dataclasses import dataclass, field
from uuid import UUID, uuid4
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class OrderLine:
product_id: UUID
quantity: int
price: float
class Order:
def __init__(self, customer_id: UUID):
self.id = uuid4()
self.customer_id = customer_id
self.status = OrderStatus.PENDING
self.items: list[OrderLine] = []
self._events: list[DomainEvent] = []
def add_item(self, product_id: UUID, quantity: int, price: float) -> None:
self.items.append(OrderLine(product_id, quantity, price))
self._events.append(ItemAddedToOrder(self.id, product_id, quantity))
def pay(self) -> None:
if self.status != OrderStatus.PENDING:
raise DomainException("Order already processed")
self.status = OrderStatus.PAID
self._events.append(OrderPaid(self.id))
def pop_events(self) -> list["DomainEvent"]:
events = self._events.copy()
self._events.clear()
return events
2.2 Domain Events
from dataclasses import dataclass
from datetime import datetime
class DomainEvent(ABC):
occurred_at: datetime
@dataclass
class ItemAddedToOrder(DomainEvent):
order_id: UUID
product_id: UUID
quantity: int
occurred_at: datetime = field(default_factory=datetime.utcnow)
@dataclass
class OrderPaid(DomainEvent):
order_id: UUID
occurred_at: datetime = field(default_factory=datetime.utcnow)
class DomainException(Exception):
pass
2.3 Repositories
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None: ...
@abstractmethod
def find_by_id(self, id: UUID) -> Order: ...
@abstractmethod
def delete(self, id: UUID) -> None: ...
2.4 Value Objects
@dataclass(frozen=True)
class Address:
street: str
city: str
zip_code: str
country: str
def __post_init__(self):
if len(self.zip_code) != 5 or not self.zip_code.isdigit():
raise ValueError(f"Invalid zip code: {self.zip_code}")
@dataclass(frozen=True)
class Money:
amount: float
currency: str = "EUR"
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
3. Hexagonal Architecture (Ports & Adapters)
3.1 Ports (Interfaces)
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: Money, token: str) -> str: ...
@abstractmethod
def refund(self, transaction_id: str) -> bool: ...
class EmailService(ABC):
@abstractmethod
def send(self, to: str, subject: str, body: str) -> None: ...
3.2 Adapters (Implémentations)
import stripe
class StripePaymentGateway(PaymentGateway):
def __init__(self, api_key: str):
stripe.api_key = api_key
def charge(self, amount: Money, token: str) -> str:
charge = stripe.Charge.create(
amount=int(amount.amount * 100),
currency=amount.currency.lower(),
source=token,
)
return charge.id
def refund(self, transaction_id: str) -> bool:
refund = stripe.Refund.create(charge=transaction_id)
return refund.status == "succeeded"
class SendGridEmailService(EmailService):
def __init__(self, api_key: str):
self.client = sendgrid.SendGridAPIClient(api_key)
def send(self, to: str, subject: str, body: str) -> None:
message = Mail(from_email="noreply@example.com", to_emails=to, subject=subject, html_content=body)
self.client.send(message)
3.3 Wiring avec DI
from dependency_injector import containers, providers
class ApplicationContainer(containers.DeclarativeContainer):
config = providers.Configuration()
# Adapters
payment_gateway = providers.Singleton(
StripePaymentGateway,
api_key=config.stripe.api_key,
)
email_service = providers.Singleton(
SendGridEmailService,
api_key=config.sendgrid.api_key,
)
# Repositories
order_repo = providers.Singleton(PostgresOrderRepository, session=...)
# Use Cases
create_order_uc = providers.Factory(
CreateOrderUseCase,
order_repo=order_repo,
payment_gateway=payment_gateway,
)
4. Dependency Injection
4.1 FastAPI DI
from fastapi import FastAPI, Depends
app = FastAPI()
# Dépendance
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()
# Service
class UserService:
def __init__(self, db: Session):
self.db = db
def get_user_service(db: Session = Depends(get_db)) -> UserService:
return UserService(db)
# Route
@app.get("/users/{id}")
def get_user(id: int, service: UserService = Depends(get_user_service)):
return service.get_user(id)
4.2 dependency-injector
from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
db = providers.Singleton(Database, url=config.db.url)
user_repo = providers.Factory(UserRepository, db=db)
user_service = providers.Factory(UserService, repo=user_repo)
@inject
def create_user(name: str, email: str, service: UserService = Provide[Container.user_service]):
return service.create_user(name, email)
container = Container()
container.config.db.url.from_env("DATABASE_URL")
container.wire(modules=[__name__])
5. Project Structure
project/
├── src/
│ ├── domain/
│ │ ├── __init__.py
│ │ ├── entities.py
│ │ ├── value_objects.py
│ │ └── events.py
│ ├── application/
│ │ ├── __init__.py
│ │ ├── ports/
│ │ │ ├── __init__.py
│ │ │ └── repositories.py
│ │ └── use_cases/
│ │ ├── __init__.py
│ │ └── create_user.py
│ ├── infrastructure/
│ │ ├── __init__.py
│ │ ├── persistence/
│ │ ├── messaging/
│ │ └── external/
│ └── interfaces/
│ ├── __init__.py
│ ├── api/
│ └── cli/
├── tests/
├── docker-compose.yml
└── pyproject.toml
6. Microservices Patterns
Diagramme en cours de génération...
7. Event-Driven avec FastStream
from faststream import FastStream
from faststream.kafka import KafkaBroker
broker = KafkaBroker("localhost:9092")
app = FastStream(broker)
@publisher("order-confirmed")
@broker.subscriber("order-created")
async def on_order_created(msg: OrderCreated):
process_order(msg)
return OrderConfirmed(order_id=msg.order_id)
@broker.subscriber("payment-processed")
async def on_payment_processed(msg: PaymentProcessed):
await send_notification(msg.user_id, f"Payment of {msg.amount} confirmed")
8. Tests d'architecture
import pytest
from pytest_arch import rule
def test_domain_does_not_import_infrastructure():
rule("domain").should_not_depend_on("infrastructure").check("src/")
def test_use_cases_only_depend_on_domain():
rule("application").should_only_depend_on("domain", "typing").check("src/")
def test_interfaces_can_depend_on_application():
rule("interfaces").may_depend_on("application", "infrastructure").check("src/")
9. Diagramme : Architecture globale
Diagramme en cours de génération...
10. Bonnes pratiques
- Toujours commencer par le domaine : Les entités et use cases avant toute considération technique
- Interfaces explicites : Tous les ports sont des ABC ou Protocols
- DI partout : Pas de new() dans les use cases
- Tests par couche : Tester chaque couche isolément avec des mocks
- Events pour le couplage faible : Communication inter-service asynchrone
- Bounded Contexts : Délimiter clairement les contextes DDD