Guide pratique : schémas d'événements de domaine
Un schéma pour séparer les effets de bord après l'exécution d'un UseCase (envoi d'email, journalisation, notifications) en utilisant des événements de domaine et BackgroundTasks.
1. Schéma de base : exécuter des événements de façon asynchrone avec BackgroundTasks
Exécutez du travail après la réponse avec les BackgroundTasks de FastAPI.
python
from fastapi import BackgroundTasks, FastAPI
from fastapi.responses import JSONResponse
def send_welcome_email(email: str) -> None:
# envoi d'email (lent)
...
@app.post("/users", status_code=201)
def create_user(body: CreateUserBody, background_tasks: BackgroundTasks) -> JSONResponse:
user = create_user_use_case(body.name, body.email)
background_tasks.add_task(send_welcome_email, user.email)
return JSONResponse({"user_id": user.user_id}, status_code=201)2. Schéma EventBus : publier des événements de domaine depuis le UseCase
Un schéma où le UseCase publie des événements et des handlers s'abonnent. Le UseCase n'a aucune connaissance HTTP (pas de BackgroundTasks).
python
from dataclasses import dataclass
from typing import Any, Callable
# définition de l'événement
@dataclass(frozen=True, slots=True)
class UserCreatedEvent:
user_id: int
email: str
# EventBus
type EventHandler = Callable[[Any], None]
class EventBus:
def __init__(self) -> None:
self._handlers: dict[type, list[EventHandler]] = {}
def subscribe(self, event_type: type, handler: EventHandler) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def publish(self, event: object) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)
event_bus = EventBus()
# UseCase : indépendant de HTTP
class CreateUserUseCase:
def __init__(self, event_bus: EventBus) -> None:
self._event_bus = event_bus
def execute(self, name: str, email: str) -> User:
user = User(...)
self._event_bus.publish(UserCreatedEvent(user.user_id, user.email))
return user
# enregistrer le handler
def on_user_created(event: UserCreatedEvent) -> None:
send_welcome_email(event.email)
event_bus.subscribe(UserCreatedEvent, on_user_created)3. Combiner BackgroundTasks et EventBus
Utilisez BackgroundTasks dans le handler HTTP pour exécuter les handlers de l'EventBus en arrière-plan.
python
@app.post("/users", status_code=201)
def create_user(
body: CreateUserBody,
background_tasks: BackgroundTasks,
use_case: CreateUserUseCase = Depends(get_create_user_use_case),
) -> JSONResponse:
# le UseCase publie l'événement de façon synchrone (le met en file sur l'EventBus)
user = use_case.execute(body.name, body.email)
# exécuter les handlers d'événements après la réponse via BackgroundTasks
for handler_call in collected_events:
background_tasks.add_task(handler_call)
return JSONResponse({"user_id": user.user_id}, status_code=201)4. Vérifier les événements dans les tests
Dans les tests, la complétion n'attend pas BackgroundTasks — sous TestClient, il s'exécute de façon synchrone et immédiate.
python
# sous TestClient, BackgroundTasks s'exécute de façon synchrone avant que la réponse ne soit retournée
executed = []
event_bus.subscribe(UserCreatedEvent, lambda e: executed.append(e))
with TestClient(app) as client:
r = client.post("/users", json={"name": "Alice", "email": "alice@example.com"})
assert len(executed) == 1 # BackgroundTasks déjà terminé
assert executed[0].email == "alice@example.com"Mises en garde
EventBustend à devenir une variable globale au niveau du module. Si des handlers s'accumulent à travers les tests, réinitialisez-le avec une fixtureautouse.- Lors de la publication d'événements dans un UseCase, passez
EventBuscomme argument de constructeur (injection de constructeur) — évitez une référence globale.
5. Héritage de dataclass : champs requis après champs avec valeur par défaut
Si une classe de base a un champ default_factory, ajouter un champ requis dans une sous-classe échoue selon les règles Python pour les dataclasses. kw_only=True (Python 3.10+) résout le problème.
python
# ❌ erreur : 'order_id' est un champ requis venant après 'occurred_at'
@dataclass(frozen=True)
class DomainEvent:
occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@dataclass(frozen=True)
class OrderPlaced(DomainEvent):
order_id: str # TypeError: non-default argument follows default argument
# ✅ résolu avec kw_only=True (Python 3.10+)
@dataclass(frozen=True)
class DomainEvent:
occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC), kw_only=True)
@dataclass(frozen=True)
class OrderPlaced(DomainEvent):
order_id: str # OK — les champs kw_only ne sont pas soumis à la contrainte d'ordre MRO
total_amount: intAvec kw_only=True, le champ devient keyword-only dans __init__. Les arguments requis d'une sous-classe peuvent être définis comme arguments positionnels ordinaires, et occurred_at est traité comme optionnel.