This post is part of the series SOLID Wash Tunnel.
Definition
"Mediator is a behavioral design pattern that lets you reduce chaotic dependencies between objects. The pattern restricts direct communications between the objects and forces them to collaborate only via a mediator object."
- Refactoring Guru
The mediator pattern lets you extract all the relationships between components (classes, modules, libraries) into a separate coordinator component. By doing so, we isolate any changes to one specific part of the codebase. Individual components become unaware of each others existance, although they still communicate with each other, but only through the mediator.
The mediator pattern enables capabilities like:
- Promotes loose coupling between components.
- Facilitates reuse of components.
- Improves code readability and maintainability.
But it also comes with some drawbacks like:
- The mediator object contains references to all of the components that it coordinates. Which in turn couples the mediator to everything.
- Over time a mediator object can evolve into a God Object.
Our implementation of the mediator pattern overcomes the drawbacks though 😉. It does so by only handling the instantiation of a command handler for a given command.
I want to preface up front that I do not suggest building your own generic mediator library. There is an excellent implementation of it available for .NET called MediatR. But there are also many available for different languages. Our implementation is very basic and is for learning purposes only.
Implementation
ISignalTransmitter
We have already introduced the ISignalTransmitter
interface when we talked about the command message pattern. As we saw the motherboard was the component by which other (electronic) components like the UserPanel
communicated to IMemory
or to a specific ISignalHandler
, which would represent a small processing unit in a real world scenario like the wash tunnel.
The ISignalTransmitter
interface is analogous to Mediator
in the UML diagram. It is an abstraction of an object that invokes a command handler, and contains one method Transmit<T>
, where T
is any object which is an ISignal
.
public interface ISignalTransmitter
{
void Transmit<T>(T signal) where T : ISignal;
}
Motherboard
Motherboard
is analogous to ConcreteMediator
in the UML diagram. As the word implies, this is the mediator object. The implementation looks scary at first but it is quite simple.
For a given ISignal
we do the following:
- Get the executing assembly.
- Get all the available types in this assembly.
- Filter the available types to include only
ISignalHandler<T>
's whereT
is of type of the suppliedISignal
. We also make sure that the types can not be interfaces or abstract classes. This check is needed to ensure that we can create an instance of the type. - For each of the types that passed all checks, we use the injected
IContainer
to resolve an instance of the type. Wether we get a brand new instance or the same, dependes on how we have registered the compontent in the IoC container. - For each of the resolved handlers we pass in the
signal
and let the handler/s perform whatever they need to do.
public class Motherboard : ISignalTransmitter
{
private readonly IContainer _container;
public Motherboard(IContainer container)
{
_container = container;
}
public void Transmit<T>(T signal) where T : ISignal
{
var signalHandlers = GetSignalHandlers<T>();
foreach (var handler in signalHandlers)
{
handler.Handle(signal);
}
}
private IEnumerable<ISignalHandler<T>> GetSignalHandlers<T>() where T : ISignal
=> Assembly
.GetExecutingAssembly()
.GetTypes()
.Where(type => typeof(ISignalHandler<T>).IsAssignableFrom(type)
&& !type.IsInterface && !type.IsAbstract)
.Select(type => (ISignalHandler<T>)_container.GetService(type));
}
Notice ⚠️
If you did not notice by now, we are doing two things which are not considered best practices.
Multiple signal handers for the same signal
In software engineering, it is considerd a bad practise to have multiple command handers for the same command. The main reason is that if any of the handlers fails to handle the request, this will put the overall system in an inconsistent state. This is especially true in case of distributed systems because of the loose coupling there is not a good way to tell all other participants to roll back their transaction because you do not know who the receivers of the request are.
This is not the case for notifications though! It is perfectly valid to have multiple notification handlers that can listen to a specific notification that has happend.
Since our mediator does not target a specific type of request (command or notification) it is not a problem. An easy fix for this would be to sepparate the
ISignal
into two interfaces, anICommand
andINotification
.If the request is of type
ICommand
we can simply resolve the last handler registered to the IoC container in case of multiple handlers for the same request. If there is no handler at all we can throw exception to notify the developer.If the request is of type
INotification
we dispatch the signal to all notification handlers.Injecting
IContainer
and using it to resolve the signal handlersThis is something known as the Service Locator anti-pattern. We will elaborate it more in-depth in the next post.
Principles
Principle | Applied | Explanation |
---|---|---|
SRP | ✅ | Motherboard deals only with transmission of signals to their respective handlers. |
OCP | ✅ | Different signals and signal handlers can be added, yet the Motherboard does not have to be modified. |
LSP | ✅ | You can supply any implementation of ISignal into the Transmit<T>(T signal) method. |
ISP | ✅ | Motherboard (the client) makes use of all the methods of ISignalTransmitter . |
DIP | ✅ | High level modules like UserPanel , WashTunnel , SmartWashTunnel do not depend on the low level module Motherboard . They depended on the abstraction ISignalTransmitter . |
Continue the series on Service Locator.
If you found this article helpful please give it a share in your favorite forums 😉.
The solution project is available at GitHub.