DEV Community

Tech Forge
Tech Forge

Posted on

Event-Driven Design in Small Steps

Start with a Single Event

Event-driven design sounds like a big architectural shift, but you can adopt it incrementally. You don't need a message broker or microservices to benefit from events. Start with a single event in your existing codebase.

The Problem with Direct Calls

Imagine you have a UserService that creates a user and then sends a welcome email, updates a CRM, and logs analytics. Direct calls couple these actions to user creation:

class UserService:
    def create_user(self, data):
        user = User.create(data)
        email_service.send_welcome(user.email)
        crm.sync(user)
        analytics.track('user_created', user.id)
        return user
Enter fullscreen mode Exit fullscreen mode

Every new requirement (like sending a push notification) means modifying create_user. This violates the Open/Closed Principle and makes testing cumbersome.

Introduce an Event Emitter

The simplest step: replace direct calls with an event emitter. You can use a library or write a minimal one.

class EventEmitter:
    def __init__(self):
        self.listeners = {}
    def on(self, event, callback):
        self.listeners.setdefault(event, []).append(callback)
    def emit(self, event, **kwargs):
        for callback in self.listeners.get(event, []):
            callback(**kwargs)

events = EventEmitter()
Enter fullscreen mode Exit fullscreen mode

Now UserService only emits an event:

class UserService:
    def create_user(self, data):
        user = User.create(data)
        events.emit('user.created', user=user)
        return user
Enter fullscreen mode Exit fullscreen mode

And each side effect becomes a listener:

events.on('user.created', lambda user: email_service.send_welcome(user.email))
events.on('user.created', lambda user: crm.sync(user))
events.on('user.created', lambda user: analytics.track('user_created', user.id))
Enter fullscreen mode Exit fullscreen mode

Now adding a new side effect is just adding a new listener. No changes to UserService.

Benefits You Get Immediately

  • Decoupling: The core service doesn't know about email, CRM, or analytics.
  • Testability: You can test UserService without mocking those dependencies; just emit events and check.
  • Flexibility: You can reorder or conditionally register listeners.

When to Use a Message Queue

In-process events work well for a monolith. But when you need multiple instances, or side effects are slow, or you want reliability, move to a message queue like RabbitMQ or Kafka. The event concept stays the same; you just change the emitter implementation.

For example, instead of events.emit, you publish to a queue:

broker.publish('user.created', user)
Enter fullscreen mode Exit fullscreen mode

And listeners become consumers. This transition is smooth because your domain logic already emits events.

Event Sourcing: A Bigger Step

Another extension is event sourcing: instead of storing current state, store a sequence of events. This is a larger change but can be introduced for one aggregate. For example, for an Order, you might store OrderPlaced, OrderPaid, OrderShipped events, and reconstruct the state by replaying them.

class Order:
    def __init__(self):
        self.events = []
    def place(self):
        self.events.append('OrderPlaced')
    def pay(self):
        self.events.append('OrderPaid')
    def ship(self):
        self.events.append('OrderShipped')
    def state(self):
        # replay events to compute state
        pass
Enter fullscreen mode Exit fullscreen mode

This gives you a full audit trail and the ability to rebuild state, but it adds complexity. Start with just emitting events, not storing them.

Practical Advice

  • Start with one event: Pick a domain action that has multiple side effects. Convert it to an event.
  • Use a simple emitter: Don't over-engineer. A dictionary of lists is enough.
  • Name events in past tense: user.created, order.placed. This reflects that the event already happened.
  • Keep events immutable: Pass data, not mutable objects, to avoid listeners modifying state.
  • Don't use events for everything: If a side effect is synchronous and must happen before returning, a direct call might be simpler. Events are for decoupling, not for every function call.

Conclusion

Event-driven design doesn't have to be an all-or-nothing transformation. By introducing a simple event emitter and converting one workflow, you get immediate benefits. As your system grows, you can evolve to message queues and event sourcing. The key is to start small and let the design emerge from real needs.

Try it: pick one place in your code where you call three things in sequence. Replace those calls with an event. You'll see the difference in flexibility and testability right away.

Top comments (0)