Skip to content
Part 2: The Foundation 9 min read

Lesson 06

Events Over Calls

Replace 8 Dependencies With 1 Line

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.

TypeEvent nameVerdict
Fact (past tense)user.deactivatedCorrect
Fact (past tense)task.createdCorrect
Command (imperative)send.notificationWrong
Command (imperative)cancel.tasksWrong

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