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