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
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()
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
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))
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
UserServicewithout 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)
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
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)