Ga naar inhoud
Deel 2: Het fundament 9 min leestijd

Les 06

Events boven calls

Vervang 8 dependencies door 1 regel

Dit komt uit een echte codebase. Namen veranderd, pijn bewaard:

class UserService:
    def __init__(self, db, notification_svc, audit_svc,
                 subscription_svc, scheduling_svc, analytics_svc,
                 cache_svc, permission_svc):
        # 8 dependencies. Succes met testen.

Je deactiveert een gebruiker. Acht services moeten het weten. Dus je UserService belt ze stuk voor stuk. Analytics tracking erbij? UserService aanpassen. Afscheidsmail sturen? UserService aanpassen. Deactivatie testen? Acht dingen mocken.

Dit is command-based architectuur. Nieuwe subscriber? Publisher aanpassen. Het software-equivalent van je hele huis opnieuw bedraden omdat je een lamp wilt ophangen.

Het event-driven alternatief

class UserService:
    def __init__(self, db: AsyncSession, event_bus: EventBus):
        # 2 dependencies. Klaar.

    async def deactivate(self, user_id: UUID):
        # Publiceer een feit. Maakt niet uit wie luistert.
        await self.event_bus.publish(
            UserDeactivatedEvent(user_id=user_id, reason="requested")
        )

UserService weet niet, en het maakt ook niet uit, wie er luistert. Notification, subscription, analytics: ze subscriben elk op het event en handelen hun eigen logica af. Een negende subscriber toevoegen? Nul wijzigingen aan UserService. Testen? Mock één EventBus.

Een simpele event bus bouwen

Je hebt geen Kafka nodig. Geen RabbitMQ. Voor de meeste applicaties is een simpele in-process event bus genoeg:

@dataclass(frozen=True)
class BaseEvent:
    event_id: UUID = field(default_factory=uuid4)
    occurred_at: datetime = field(default_factory=utcnow)

@dataclass(frozen=True)
class UserDeactivatedEvent(BaseEvent):
    user_id: UUID
    workspace_id: UUID
    reason: str
class EventBus:
    def subscribe(self, event_type, handler):
        self._handlers[event_type].append(handler)

    async def publish(self, event):
        handlers = self._handlers.get(type(event), [])
        await asyncio.gather(
            *(self._safe_handle(h, event) for h in handlers),
            return_exceptions=True
        )

Handlers draaien concurrent. Notification handler faalt? Subscription service draait gewoon door. Failures zijn geïsoleerd. Eén kapotte subscriber haalt niet de hele keten neer.

De ene regel die je niet mag breken

Events beschrijven wat er is gebeurd. Verleden tijd. Het zijn feiten, geen commando's.

TypeEvent naamOordeel
Feit (verleden tijd)user.deactivatedCorrect
Feit (verleden tijd)task.createdCorrect
Commando (gebiedende wijs)send.notificationFout
Commando (gebiedende wijs)cancel.tasksFout

Waarom maakt dit uit? Feiten kunnen meerdere subscribers hebben. Commando's suggereren er maar één. Benoem events als feiten en je architectuur ondersteunt vanzelf uitbreiding. Benoem ze als commando's en je koppelt in je hoofd de publisher aan een specifieke subscriber.

Wanneer events gebruiken vs. directe calls

  • Gebruik events wanneer: meerdere domeinen op hetzelfde moeten reageren. Of je reacties wilt toevoegen zonder de bron aan te raken.
  • Gebruik directe calls wanneer: je een returnwaarde nodig hebt. De operatie synchroon is. Of er één consumer is die niet gaat veranderen.