In the Events, event, events article I outlined three possible meanings of using the word "event" in the context of software architecture. Today I would like to dive a bit deeper into the third one – using events as a way to organize the code. We will introduce an inline event dispatcher to make the code using this kind of events even better.
The idea of such an inline dispatcher was in my mind for quite some time. However, it usually was met with a laugh or at least raised eyebrows. You can imagine my surprise when I discovered that other people are also promoting it. It is even included in Vaughn Vernons Implementing Domain-driven Design book (they are called "ligthweight subscribers" there).
We will start with the code from the last time:
def finalize(order_id)
transaction do
order = Orders::OrderFinalization.call(order_id)
Inventory::DecreaseAvailability.call(order)
Loyalty::AddPoints.call(order)
end
end
To remind, I proposed Orders::OrderFinalization
to return a specialized even object which is then passed to other contexts instead of Order
entity, which should be private to Orders
context.
This code is not bad, but has a fundamental flaw: it has to sit somewhere. You need to have some kind of "upper layer" that coordinates data flow between many contexts. And while there's nothing wrong with that, it bears certain risks. In my experience inevitably this kind of layer will grow and will start to contain its own logic instead of just being a coordinator. We can avoid that by making this coordination part a bit more rigid.
Emitting and handling events
The first step in removing the coordination layer would be to change Orders::OrderFinalization
class.
class Orders::OrderFinalization
def call(order_id)
Orders::Order.find(order_id)
# do the ordery things
event = OrderFinalized.new(order_id: order.id, ...)
EventDispatcher.dispatch(event)
end
end
Here we create the event as in the example from the previous article, but instead of returning it, we pass it to a mysterious EventDispatcher
. We will see what's inside in a moment, but first, let's see the other end. We used the event to trigger something in the Inventory
and Loyalty
contexts. Let's take the former and see how we can adapt to the new code architecture.
class Inventory::OrderFinalizedHandler
def handle(event)
event[:items].each do |item|
next unless item[:product_id] # might be shipping or something
# do something decreasing product availability in the warehouse
end
end
end
We know that the event will include an items
array and we know how it will look - this is our internal contract for communication between contexts. Based on that, we iterate over this array and decrease the availability in a way that's only known to the Inventory
area. We would do something similar in the Loyalty
area to add loyalty points based on order's... something (total price? presence of special items?).
Now, how do we connect these two?
The dispatcher itself
An inline dispatcher is a really simple module (or a class, but I prefer module), which connects events to their handlers. It could look like this:
module EventDispatcher
def self.dispatch(event)
handlers(event.class).each do |handler|
handler.new.handle(event)
end
end
def self.handlers(event_class)
case event_class
when Orders::OrderFinalized
[Inventory::OrderFinalizedHandler, Loyalty::OrderFinalizedHandler]
# ...
end
end
end
If you want, you could use some magic to auto-infer the name of the handler from the name of the event, but to be honest, I like the explicity here. It acts like a "router of dependencies" between different areas of your application.
What did we gain?
If you are not convinced, this style might not be for you. But let me just quickly list why I think this code architecture is better:
- It creates a clear distinction between "core" business logic (in our example: finalizing the order) and side-effects (adding loyalty points, adjusting values in the warehouse). If we managed to separate it this way it usually means that the side-effects could be handled asynchronously in the future, we'd just have to prepare the interface and infrastructure for that.
- We got rid of the shady "coordination layer", which very often feels unnatural to me.
- Emitting events is done in the domain code itself. In the previous implementation the domain code created an event object but returned it to the coordination layer. It was not clear why to return an event in the first place.
- We can still wrap everything in a transaction and we keep the full transactionality of a monolith, while at the same time we are one step closer to more independent modules (context). They are only bound together by a single
EventDispatcher
and its handlers list. - If, at some point in the future, your project starts to call for splitting it up into multiple services, you have almost-ready foundation for it. You know the dependencies, you just need to figure out transactionality (good luck!) or realize that with a few extra steps you don't need the wrapping transaction at all.
Few extra notes
You should never rely on the return value of the handler or on the order of execution of the handlers. If you do, you are still too coupled. In fact, you can enforce it with calling each handler in a separate thread and just watching if no thread is throwing exceptions.
This is not exactly legal in terms of Domain-Driven Design. DDD principles are quite clear that business logic in different contexts (aggregates) should not be executed under common transaction, instead it should be asynchronous. Good news is that nobody said we are doing proper DDD - this pattern can be used without it. Or if you want to be closer to the principles, you could simply not write a spanning transaction.
That's it, that's the whole idea behind the "inline event dispatcher". I hope I showed you how this can make your codebase less coupled and stating dependencies more clear.
Top comments (0)