A system with lots of components where those components depend on others is common in real-world applications. It usually points to an application based on the layered architecture where each layer consists of fine-grained pieces following the single responsibility rule. It’s desired to distribute behaviour among components. It increases reusability and improves the understanding of the domain logic. But there is the other side of the coin. Working with the code can become extremely hard when there are a lot of interconnections. Even with a good abstraction, code becomes harder to reuse and changing a specific functionality requires changing multiple components. Complex systems yield complex designs but if the coupling between components obscures the domain, it’s a clear sign to refactor. One possible solution to reduce the interconnections between different parts of the system is to create a good abstraction and introduce a single point of communication. There are two useful, well-known approaches that can be used to achieve this goal – Mediator and Event Aggregator patterns.
Mediator is a behavioural design pattern that reduces dependencies and encapsulates the communication between components inside a component called the Mediator. It centralises control, simplifies protocols, decouples different parts of the system, and abstracts how components interact.
The Mediator manages dependencies. It contains references to all components. Different parts of the system use the Mediator to communicate indirectly with each other.
Event Aggregator is mix of another behavioural design pattern called Observer and Mediator itself. In its simplest the component called the Event Aggregator is a single source of events for many components. The consumers are registered to the Event Aggregator and on the other end the producers publish their events using the Event Aggregator.
The Event Aggregator decouples subjects and observers – in that sense it acts as the Mediator.
mob is a simple, open-source, generic-based Mediator / Event Aggregator library. It solves complex dependency management by introducing a single communication point. mob handles both request-response and event-based communication.
Request-response communication is the simplest way of communication between components in which the first component sends a request to the second one and the second component sends a response to the received request. One request is handled by one component. To use mob as the Mediator for your request-response communication, handlers must satisfy the following interface.
type RequestHandler[T any, U any] interface {
Handle(context.Context, T) (U, error)
}
mob permits to use any type for both requests and responses. To make the RequestHandler
interface easier to implement, mob allows the use of ordinary functions as request handlers.
Event-based communication occurs when the first component dispatches an event, and this event is handled by any number of components. To use mob as the Event Aggregator for your event-based communication, handlers must satisfy the following interface.
type EventHandler[T any] interface {
Handle(context.Context, T) error
}
Similarly to request-response communication, mob accepts events of any type and allows the use of ordinary functions as event handlers.
To install mob, execute the following.
go get github.com/erni27/mob
Create your first request handler
type UserDTO struct {
// User data.
}
type UserQuery struct {
// Necessary dependencies.
}
func (q UserQuery) Get(context.Context, int) (UserDTO, error) {
// Your code.
}
Remember, your handler implementation doesn’t have to satisfy the RequestHandler
interface directly, you can use a struct method or an ordinary function as a request handler as long as it has the signature func(context.Context, any) (any, error)
.
Register your handler. Since we use the struct’s method as a request handler, we need to convert it to the RequestHandlerFunc
. It’s possible to register only one handler per request / response type.
// Somewhere in initialisation code.
uq := query.NewUserQuery(/* parameters */)
err := mob.RegisterRequestHandler[int, query.UserDTO](
mob.RequestHandlerFunc[int, query.UserDTO](uq.Get),
)
Use mob as the Mediator.
func GetUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
}
res, err := mob.Send[int, UserDTO](req.Context(), id)
if err != nil {
// Err handling.
}
w.Header().Set("content-type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
// Err handling.
}
}
The preceding example presents how to decouple HTTP handlers from other components which make them more portable and much easier to test. It covers how to:
Create your first event handler
Create an event.
type OrderCreated struct {
// Event data.
}
Then create an event handler.
type DeliveryService struct {
// Necessary dependencies.
}
func (s DeliveryService) PrepareDelivery(ctx context.Context, event event.OrderCreated) error {
// Your logic.
}
Register your handler. Again, we want to use a struct’s method as an event handler so the conversion to the EventHandlerFunc
is necessary. Unlike request handlers, multiple event handlers can be registered per one event type. All of them are executed concurrently if an event occurs.
// Somewhere in initialisation code.
ds := delivery.NewService(/* parameters */)
err := mob.RegisterEventHandler[event.OrderCreated](
mob.EventHandlerFunc[event.OrderCreated](ds.PrepareDelivery),
)
Use mob as the Event Aggregator.
type OrderService struct {
// Necessary dependencies.
}
func (s OrderService) CreateOrder(ctx context.Context, cmd command.CreateOrder) error {
// Your logic. #1
err := mob.Notify[event.OrderCreated](ctx, event.OrderCreated{ /* init event */ })
// Your logic. #2
}
The preceding example presents how to decouple your domain services and how to use in-process events to explicitly implement side effects of changes within your domain. It covers how to:
Mediator and EventAggregator patterns are powerful concepts that make complex dependency management easy. Tools built on top of them are convenient when applying more advanced patterns like CQRS. Although useful, they should be used carefully. Apply them only where needed. If a centralised point of communication within your domain obscures it and makes it harder to understand, you should consider sticking to the direct, more traditional way of communication.