Skip to content

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 ​

  • EventBus tend à devenir une variable globale au niveau du module. Si des handlers s'accumulent à travers les tests, réinitialisez-le avec une fixture autouse.
  • Lors de la publication d'événements dans un UseCase, passez EventBus comme 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: int

Avec 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.

Publié sous la licence MIT.