Here's a function from a real codebase. Names changed, pain preserved:
class UserService:
def __init__(self, db, notification_svc, audit_svc,
subscription_svc, scheduling_svc, analytics_svc,
cache_svc, permission_svc):
# 8 dependencies. Good luck testing this. Every time you deactivate a user, eight services need to know. So your UserService calls them all directly. Need to add analytics tracking? Change UserService. Need to send a goodbye email? Change UserService. Testing deactivation? Mock eight things.
This is command-based architecture. Every new subscriber requires changing the publisher. It's the software equivalent of having to rewire your house every time you plug in a new lamp.
The event-driven alternative
class UserService:
def __init__(self, db: AsyncSession, event_bus: EventBus):
# 2 dependencies. Done.
async def deactivate(self, user_id: UUID):
# Publish a fact. Don't care who listens.
await self.event_bus.publish(
UserDeactivatedEvent(user_id=user_id, reason="requested")
) UserService doesn't know or care who's listening. Notification, subscription, analytics: they each subscribe to the event and handle their own logic. Adding a ninth subscriber? Zero changes to UserService. Testing? Mock one EventBus.
Building a simple event bus
You don't need Kafka. You don't need RabbitMQ. For most applications, a simple in-process event bus is enough:
@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 run concurrently. If the notification handler fails, subscription service still processes. Failures are isolated. One broken subscriber doesn't take down the entire chain.
The one rule you can't break
Events describe what happened. Past tense. They are facts, not commands.
| Type | Event name | Verdict |
|---|---|---|
| Fact (past tense) | user.deactivated | Correct |
| Fact (past tense) | task.created | Correct |
| Command (imperative) | send.notification | Wrong |
| Command (imperative) | cancel.tasks | Wrong |
Why does this matter? Facts can have multiple subscribers. Commands imply a single handler. When you name events as facts, the architecture naturally supports extension. When you name them as commands, you're mentally coupling the publisher to a specific subscriber.
When to use events vs. direct calls
- Use events when: Multiple domains need to react to the same thing, or you want to add reactions without changing the source
- Use direct calls when: You need a return value, the operation is synchronous, or there's only one consumer and that won't change