An event-driven approach to process business use cases in Ruby
Promoting open/close principle and loose coupling between components
I’m a big fun of event-driven architecture and how different components in a system react to different state changes in a domain. Mostly because it promotes loose coupling between components and services. Sometimes it's also a good paradigm to pipe and filter architecture since, for instance, the consumer of an event might just have the responsibility to filter, transform and forward the event to another component of the system. Moreover coming from distributed environments and architectures, an event-driven approach extends horizontal scalability making them more resilient to failures promoting high availability and eventual consistency [6, 7].
The initial scope of the article was to avoid ActiveRecord Callbacks mostly because of its unwanted side effects  and hard to remove afterward. Other drawbacks would be violating the single responsibility principle and slowing down our test suite. Leaving AR callbacks on the side the next thought would be to decouple a service object from directly calling other services to fulfill a use case as we will see below. The advantage of the last point is that we promote the open-close principle and have a better view of the purpose of a given use case and what are its side-effects.
For our use case, we will be having a polling app with the following requirements:
- A user can create a poll.
- Once the poll is created a moderator in the backend needs to approve its content and eligibility to be published to the platform.
- Once the moderator approves the poll it should be published and be visible to the rest of the users.
- The moderator can reject the poll that a user suggested.
- The initial creator will be notified by different means on each state of the poll.
- Other services /components need to be notified about the changes to the poll like a notification service in a distributed environment or an analytics service.
First initial implementation
Let’s start from the controllers to see our use cases. Below we see some controllers one to process a new poll creation request, one to publish or reject a poll from being published to the platform and the last one to vote on a poll.
The corresponding services implementation can be found below.
In all cases we have to perform some of the following actions once we process a request:
- Notify the author once the poll is created/published.
- Notify the moderator once a poll is created.
- Update some view models once a poll is updated.
- Update some view models once a poll is voted.
Let’s see some of the pitfalls with the current approach. First of all, each service has to directly call another object to fulfill the use case. This increases the coupling between the components [2, 3]. Each service is not closed for modification. If we need to add or remove a side effect we need to update each action as well. Although the complexity is not big enough in the above code samples, we could also group the related side effects in a proxy object to handle each case: one when a poll is created, published or voted. This would be a simpler approach from the one that we will define below which will add more complexity in our application’s abstraction.
Towards an event-driven design
In the diagram below we re-present the final design that we want to achieve in our system. One of the most important use cases, a domain event should be used to propagate state changes and that the event handlers are implemented in our application layer since it is an application concern.
Below we will refactor our action services to use an event-driven design instead and raise a domain event once one business action has occurred . This will allow us to decouple each component from knowing what services to call and allow each interested component to subscribe itself to a specific domain event that is raised. We will also add the related supporting code to handle the synchronous or asynchronous processing of a domain event when one occurs.
Below we see a new version of the above code that raises a domain event instead.
Each service is using the
raise static method on the
EventDispatcher to raise an event. The actual implementation will be shown below but the actual point here is how we decoupled each component. We also added more semantics to our domain by raising an event that tells us what happened in the system. This also opens the possibility to go towards an event driven architecture in a distributed environment which is usually a better architecture in more scalable and highly available systems where we extract different services in self-autonomous deployable units.
The event definition and the event handlers can are shown in the code below:
We could also define an event handler and subscribe directly like so:
include EventDispatcher on Events::PollCreated, async: self def self.call(event)
# process event
The actual event dispatcher implementation is a modified version of Kickstarter's event sourcing demo app .
In the example above we added our own custom code to implement the event dispatching and handling. We could also use some 3rd party libraries to achieve the results like using wisper gem [5, 8]
We showed above an approach to process business use cases by decoupling each component and leveraging the publish-subscribe pattern to move towards an event-driven design in our application. Some of the benefits that we gained are by promoting the single responsibility principle, loose coupling between the components and scalability. We also increased the semantics in our application though the domain events which is also the preferred way to trigger side effects across multiple aggregates within a domain or multiple ones. Introducing domain events we now explicitly implement side effects of changes. The key point is the open number of actions to be executed when a domain event occurs. Eventually, the actions and rules in the domain and application will grow. The complexity or number of side-effect actions when something happens will grow, but if we had components coupled together, then every time we need to add a new action you would also need to change working and tested code which will yield to bugs if not changed correctly.
The biggest drawback though is the increased complexity in our application and the related supporting code that we need to add or implement.
Last but not least, using the design above could be beneficial in more complex use cases where domain driven design is used since the domain events are the preferred way to trigger side effects and communication between multiple root aggregates.
In a future article, we will present a demo app that will use the above concepts in order to achieve an Event-Driven Architecture for our Rails application. We will also follow a component-based architecture in order to simulate the different bounded contexts in our application.