Events, events, events

Photo by airfocus on Unsplash

Events, events, events

When talking about software architecture in a web development context most of the time it will come up to discuss using events. In fact, I think it is the most commonly suggested "upgrade" from naive CRUD architecture to something better grasping the complexity of business logic.

However, I often witnessed people discussing introducing events to their codebase were actually talking about different things. And even worse, they did not realize that. For each party the meaning of "events architecture" was obvious, but there were different meanings. I've been there too.

With time, I started to realize that there are at least three meanings of the concept of events that can come up. They are not mutually exclusive, but every one of them can also be introduced on its own, without the others. I would recommend that every discussion about adding events to the project started with aligning on "what events are we talking about?".

Events as a way to organize communication

The first meaning of the word "event" is something that is sent asynchronously using some kind of message broker (like RabbitMQ) or a stream (Kafka), or some Pub/Sub solution. They are widely used in any kind of service-oriented architecture, where one independently deployed application has to communicate something to another.

If used on their own, these events often mutate into asynchronous commands.

In the upstream application (the one that sends the event) there is nothing fancy to talk about. Similarly to sending analytics events the application does something and then just sends something to the message queue and forgets about it. The downstream application needs to implement some kind of handler, which will listen to the message and react to it.

Events as a way to organize data

In short: event sourcing. The idea that instead of directly mutating the data in the database you just capture the change and then let some kind of aggregator create the current state of the record from multiple events.

Introducing event sourcing is a big change to every traditionally built system because it's a fundamental change in how we think about data. The benefits of this approach often talk about solving race conditions (such as two people trying to book the same seat in the cinema at the same time) and having an audit log for free.

Events as a way to organize code

This is perhaps the most underrated application of events and is really rarely used on its own. However, at the same time, it might be the most useful one. Unlike the two above, which introduce events at the infrastructure level (persistence or transport), this one is on a domain (business logic) level.

Such an event is something that is used to organize communication between different contexts, without coupling their APIs with one another. Consider a pretty standard example of finalizing the order in e-commerce:

def finalize(order_id)
  transaction do
    order = Orders::OrderFinalization.call(order_id)
    Inventory::DecreaseAvailability.call(order)
    Loyalty::AddPoints.call(order)
  end
end

There are three contexts in play here and they are very coupled:

  • Inventory needs to know how order looks like - to know which product to decrease and by how much

  • Loyalty also needs to know how the order looks like to make sure the user is eligible for the loyalty programme and to calculate how many points they should get

The result of this approach is that whenever you change the order API, you have to make sure that Inventory and Loyalty still can work with it. And if not, you have to make the adjustments there.

What would be an alternative? Pretty much the same, but instead of passing an Order object around, we pass an event, which could look something like this:

OrderFinalized.new(
  order: {
    id: 1000,
    finalized_at: DateTime.now
  },
  user: {
    id: 15,
    in_loyalty_programme: true
  },
  items: [
    {product_id: 156, amount: 4, unit_price: BigDecimal.new("12.99")},
    {product_id: 205, amount: 1, unit_price: BigDecimal.new("6.49")}
  ]
)

One might as what is the difference and why add the level of indirection. The answer is that when you change something to order's internals you only need to make sure that generated event stays the same. You can change columns and method names as you like and only the order's context has to adhere to them, while the other contexts remain indifferent to this change. This makes Order much more private and prevents the avalanche effect - when a change in one part of the system triggers a bug in a completely different one.

Of course, if you change the structure of the event you need to make sure that event consumers are ready for this change.

But there's an added bonus here - even though we introduce events, we still work in a fully synchronous setup: we can wrap everything in a transaction and we don't have to worry about backward compatibility of the events. Sure, at some in the future we may want to go async, but it's one change at a time - much less scary than re-architecting the code and introducing asynchrony on the infrastructure level at the same time.

To sum up

When discussing introducing events to your system, make sure you know what you actually want or need to introduce. If unsure, I suggest starting with the last variant as it carries considerably less mental switch burden than the others and is a great starting point to go even more into events in the future. The last part - only if really needed.