Decouple Your Go Components by Leveraging Mediator and Event Aggregator Patterns

Written by erni27 | Published 2022/10/10
Tech Story Tags: golang | cqrs | design-patterns | clean-code | clean-architecture | go-components | decouple-your-go-components | mediator-and-event-aggregator

TLDRMediator and Event Aggregator are powerful concepts which make the complex dependency management easy. Learn how to use them to solve complex dependency management in your Golang application by introducing a single communication point.via the TL;DR App

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

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

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.

Decouple your components using mob

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

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

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.

Get your hands dirty

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 a request handler
  • Register the request handler to mob
  • Use mob to send a request and get a response

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:

  • Create an event handler
  • Register the event handler to mob
  • Use mob to notify all event handlers if an event occurs

Conclusion

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.


Written by erni27 | Open-source enthusiast. Crazy about distributed systems. I write code understandable not only for machines.
Published by HackerNoon on 2022/10/10