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

Reduce coupling and dependencies between communicating objects. An event-driven approach.

Design 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.

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.")

end private def rules_applied?

# ...

end end

Quite 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 mediator The 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

end def call

request_policy.call

end def 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

end

end

Okay 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.

when it based on the 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].

are an important aspect of the [4] and 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 and try to understand what a && b means rather than reading do this. It immediately gives you the intention and how you react to that. It’s more especially if you follow and try to apply them in your code based on the [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.

and 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 Colleague UnknownRequestState = Class.new(StandardError) attr_reader :request def initialize(request)

@request = request

end def call

if request.empty? && request.acknowledged?

notify(EmptyRequestAcknowledged)

elsif request.empty?

notify(EmptyRequest, data: { reason: empty request" })

elsif request.completed?

notify(RequestReadyToBeProcessed)

else

raise UnknownRequestState

end

end

Pretty simple. We removed the validation and precondition logic from the initial service to this component. We make sure before we start processing that the preconditions are met and we also check to see some logic regarding some acknowledgment in the request. Base on the request’s state, different events are emitted.

We have also included a helper module in the above component called Colleague(the name is bad but will stick with it for our examples) in order to provide some helper methods like notify. Let’s see the module definition below:

module Colleague

def self.included(base)

base.class_eval do

attr_reader :mediator

end super

end def mediator=(mediator)

@mediator = mediator

end def notify(type, data: {})

mediator.notify(event(type, data: data))

end private def event(type, data: {})

Events::Base.new(type, data: data)

end

end

Simple enough it provides a setter method to set the mediator and some helper methods to notify and the creation logic for an event.

Let’s see another component, RequestService that is responsible to fetch the request details. Note below the exceptions are no more delegated to the mediator but rather the communication is done via events.

class RequestService

include Colleague def initialize(client: HttpClient; @client = client; end def get_details(id)

client.get!(path, id: id) # imaginary call rescue SomeExternalException

notify(GetRequestDataError, data: { reason: "get request details failed"}) end end

The above component logic is simple, we may have components with more complex logic like the one below:

class DataBuilder

include Colleague



include OtheRelatedModule def null; {}; end def build(data: , attachment: nil)

assert_required_attibutes!(data)

assert_optional_attibutes!(data) user_data = create_domain_data(data) # "complex logic" notify(UserDataBuildCompleted, data: { user_data: user_data})



rescue InternalErrorA, InternalErrorB => ex

notify(BuildDataError, data: { reason: ex.message }) end end

In a similar way, all other components are defined as the ones above with each one having their own implementation and sending an event to their mediator.

Now imagine the case where you need to create a new component with its own logic. It would only take a new initialization in the mediator and listen to the correct event in order to call the new component. The component itself would either terminate the processing logic (no more emitted events) or could emit an already existing or a new event in order for the correct component to be invoked based on the scenario. A simple example above would be a notification component that would send a notification message when the data is sent to the user service or when the request is deleted. The key here is the domain event.

Cons

Reducing the complexity between the components increases the complexity of the mediator itself, especially if we have logic in the mediator.

Increased complexity in the mediator is the same as having a design without one.

Similar patterns:

Conclusion

We took a different approach on how to fulfill a use case by using an event-driven architecture and introducing a mediator object in order to encapsulate how a set of objects interact. We gained a lot of benefits with this approach as described with the most important one having better tests and more transparent domain logic. Introducing domain events to our code base made the code more readable and easier to reason about. Cross communication with stakeholders and product owners is improved by applying domain events and takes us one step further to apply DDD and other architectural patterns like Clean Architecture.

References

[1] https://en.wikipedia.org/wiki/Software_design_pattern

[2] https://en.wikipedia.org/wiki/Mediator_pattern

[3] https://refactoring.guru/design-patterns/mediator

[4] https://en.wikipedia.org/wiki/Domain-driven_design