Decouple object interaction with a ‘Mediator’ like pattern in Ruby

Decouple object interaction with a ‘Mediator’ like pattern in RubyReduce coupling and dependencies between communicating objects.

An event-driven approach.

Laerti PapaBlockedUnblockFollowFollowingMay 17Design patterns are reusable solutions to a commonly occurring problem within a given context in software design [1].

Having design patterns in mind is a good habit in general, but it should be avoided to apply them from the beginning.

Thinking about good design is a good thing but applying it all the time can hurt your software, maintenance and the effort that someone needs to put in order to understand what a single class does.

Applying patterns all the time in my opinion especially for simple tasks will complicate things and we will end up in over-engineering solution and unnecessary complexity to our software.

In this article, I would like to introduce the mediator design pattern [2] and a practical example of how to use it in order to reduce communication complexity between multiple objects.

We will introduce a coordinator class (the mediator) that will handle all communication between different classes in order to support the easy maintenance of the code by promoting loose coupling.

A UML diagram of the mediator pattern is shown in the figure below.

Mediator design pattern UML diagram [3].

In a nutshell, we have the following:Component (colleague): Are various classes that contain some business logic.

Each component has a reference to a mediator but it doesn’t know which mediator is used and why.

This provides us better reusable components.

Mediator: The mediator defines a method that will be called from each component passing a context object.

Note that no coupling should occur here between the receiver and the sender (between the components).

From the above, each component should not be aware of the other components in the system.

The mediator will be responsible to handle the communication and interaction between different objects once a message is received.

Mediator in a high-level architecture and system design can be seen as a message broker or a service bus that encapsulates the communication between different components.

Each communication between a component can be done for example by passing events (event-driven architecture) and routing the invocation to different consumers.

The mediator may contain some logic (like pipe and filter) in order to fulfill a use case, or it can contain only initialization logic of the components involved and error handling.

I prefer the later and tend to keep mediators as thin as possible without much logic except some error handling if needed and act only as a router between the messages that are received from different components.

Having said the above, let’s see a hypothetical example below:class RequestCompletedService attr_reader :request def initialize(request) @request = request end def call if request.

empty?.&& request.

acknowledged?.return UserService.

put(DataBuilder.

null) end if !rules_applied?.return request.

error("Invalid request!") end request_data = RequestService.

get_details(request) if request_data.

empty?.return request.

error("Missing request data") end localized_request_data = LocalizedRequestFormatter.

call(request_data) if localized_request_data.

size > TOTAL_PAYLOAD_THREASHOLD @upload_url = RequestUploader.

call(localized_request_data) end some_other_service_payload = DataBuilder.

build( data: localized_request_data, attachment: @upload_url ) UserService.

put(some_other_service_payload) request.

complete!.rescue LocalizedRequestFormatter::Error request.

error!("Formatting error") rescue UserService::NotFoundError request.

destroy!.rescue UserService::BadRequestError request.

error!("Malformed request created.

") endprivatedef rules_applied?.# .

endendQuite some logic.

The initial intention of the above code was to keep the logic in one place.

We see some business rules (like `rules_applied?` or if the request is empty and acknowledged) and then some processing logic which involves getting some request details and building another request for a different service — UserService in our example).

What’s wrong with the above class?The obvious one is that it has too many responsibilities so it violates the single responsibility principle.

Ignoring Onion architecture and DDD principles, for now, it’s not clear what our domain should be focused on?.Should it be our request entity’s state and business use case?.Our domain rules or external service calls?Hard to test.

We use exceptions to handle control flow and make decisions.

Although each component in the above use case seems to have one and simple logic (format data, get data or build relevant data) the logic in RequestCompletedService is hard to test in isolation without mocking the implementation.

Can we do better?Let’s see how we can decouple the responsibilities in the above class by keeping RequestCompletedService as thin as possible and be only aware of how to route different messages between the involved components.

We will use RequestCompletedService like a mediator object in order to coordinate the requests between the different components into our system based on different events that occur.

Let see the changes that we need to make:Each component needs a reference to the mediatorThe mediator will initialize each component and define a `notify` method where each component will call in order to notify it about an event.

The mediator will “listen” for those events and take different actions based on the event that it raised.

The action would be to call the correct component that is responsible to process the event.

Let’s see a second version of the RequestCompletedService:class RequestCompletedService attr_reader :request, :request_policy, :request_service, :formatter, :uploader, :data_builder, :user_service def initialize(request) @request = request @request_policy = RequestPolicy.

new(request) @request_policy.

mediator = self @request_service = RequestService.

new @request_service.

mediator = self @formatter = LocalizedRequestFormatter.

new @formatter.

mediator = self @uploader = RequestUploader.

new @uploader.

mediator = self @data_builder = DataBuilder.

new @data_builder.

mediator = self @user_service = UserService.

new @user_service.

mediator = self enddef call request_policy.

call enddef notify(event) case event.

type when EmptyRequestAcknowledged user_service.

put(data_builder.

null) when EmptyRequest request.

error!(event.

data.

reason) when RequestReadyToBeProcessed request_service.

get_details(request.

id) when FetchRequestDataCompleted formatter.

call(event.

data.

request_details) when DataFormatted uploader.

call(event.

data.

formatted_data) when DataUploaded, DataUploadSkipped DataBuilder.

build( data: event.

data.

formatted_data, attachment: event.

data.

remote_url ) when UserDataBuildCompleted UserService.

put(event.

data.

user_data) when UserServiceNotFound request.

destroy when FormatDataError, UserServiceBadRequest, GetRequestDataError request.

error!(event.

data.

reason) end endendOkay great.

Looks a little bit like a Redux reducer — :), but let’s see what we achieved:We still have the processing logic of the business use case in one place which was our initial intention.

The RequestCompletedService now reacts only to events and it only routes and sends messages between the components.

Single source of our use case inside the service.

Components can be reused in different mediator implementations.

I think that the code is easier to understand now and to reason about since if you know at least something about the use case and the process behind it, looking at the events you can immediately see what happens and how you react to it.

Easier to test and better isolation.

Remember that calling the service in the previous implementation we might have different side effects on each call.

Now we only have 1 side effect when it APPLIES based on the event that is dispatched.

Domain events are an important aspect of the business [4] and modeling them in the code I think is a huge plus, especially when you speak with stakeholder and product owners.

For example, it’s different when you read a code that says if a && b and try to understand what a && b means rather than reading if CONTRACT_ENDED do this.

It immediately gives you the intention and how you react to that.

It’s more transparent to the stakeholders especially if you follow DDD principles and try to apply them in your code based on the ubiquitous language [4].

When `if else` statements get complex we can easily extract them to a strategy or state pattern but I think that would be overkill for now.

Promote loose coupling and high cohesion into this specific use case.

It could be probably better but I think we made a step to make it better.

Let’s see some of the components implementations below.

The first component as we saw above is called RequestPolicy and will be responsible to instantiate our processing logic by emitting the correct event.

It will also make sure that the request is in the correct state before we start processing it and will raise an error when we cannot process the request (note that this error should be raised since it is an unknown error in our domain.

It is an exception that we cannot handle).

# class RequestPolicy will be the component responsible to handle # # our `rules_applied?` validation logic.

# It is also our initial component that will be called first from the# mediator.

All other interactions will be based on the events that # are raised after the first call.

class RequestPolicy include ColleagueUnknownRequestState = Class.

new(StandardError) attr_reader :request def initialize(request) @request = request end def call if request.

empty?.&& request.

acknowledged?.notify(EmptyRequestAcknowledged) elsif request.

empty?. More details

Leave a Reply