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.
| Type | Event naam | Oordeel |
|---|---|---|
| Feit (verleden tijd) | user.deactivated | Correct |
| Feit (verleden tijd) | task.created | Correct |
| Commando (gebiedende wijs) | send.notification | Fout |
| Commando (gebiedende wijs) | cancel.tasks | Fout |
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.