buarki

buarki

buarki

Follow

Site Reliability Engineer, Software Engineer, coffee addicted, traveler

Hexagonal Architecture/Ports And Adapters: Clarifying Key Concepts Using Go

March 11, 2024

4417 views

Share it

Disclaimer

Before saying any words on such topic I must highlight some points: (1) The intent of such writing is providing a concrete, easy to understand and practical example of Hexagonal Architecture, due to that, the example is really simple to avoid a big cognitive load of peripheral topics. It's really straight to the topic; (2) For sure there are several ways to approach the example shown bellow but this is just one simple and minimalist example with the intention of only pass the idea, you are more than welcome to share your thoughts as well :)

Motivation To Write this

A few weeks ago, while chatting with a college newcomer in software development, I noticed she was struggling with some of the same difficulties I encountered when trying to understand Hexagonal Architecture. Latter on, looking for some discussions on reddit it seems this is something everyone faces, like this one:

Print from reddit discussion

Due to that I decided to write down my 2 cents in this topic sharing what I know, what I've used and my experience in projects I've been involved in.

What Is Hexagonal Architecture? Where It Comes From? And Where Does the Hexagon Fit In?

Being straightforward, Hexagonal archictecture has nothing to do with hexagons. A better name for it is the one that the author, Alistair Cockburn, gave to it in his blog post: Ports and Adapters. The name "Hexagonal Architecture" stuck probably due to the visual representation of the system's structure that is usually used. So, as it has really nothing to do with hexagons, from now on let's refer to the topic of this article by its proper name: Ports and Adapters :)

Historically, Ports and Adapters was born in the context where Dependency Inversion Principle (DIP) was getting hot, back in the beginning of the 2000's. DIP was getting more present on development day to day, and an example of a framework that was a pioneer in such topic is Google Guice.

We can say that one of the first attempts to define a standard for software organization was N-Layered architecture. The basic idea is: group things related together. In practical terms it usually ends up with three layers: user interface, business logic and data access. The great leap N-Layared Architecture gave us was the separation from UI and business logic.

The basis shape of N-Layared Architecture

It's also important pointing that in 2003, two years before Alistair publishes his article, Eric Evans published his famous book Domain-Driven Design: Tackling Complexity in the Heart of Software introducing DDD to the world. A core concept of DDD, which can be explored here in another article, is focusing on the business, and we can refer to it using the concept of domain. Again, DDD is a huge topic and I won't be a fool to give the details of it only in one paragraph, but the outcomes it brings in terms of layers is the domain being the heart of system and a few layers besides it: the presentation layer, in charge of interacting with the client of the system (a person, another system etc); the application layer, which coordinates what needs to be done; and the infrastructure layer, which is in charge of DBs, notifications etc. Bellow image might help to illustrate the idea.

The basic idea of DDD

A practical description of the image above is as follows: the presentation uses the application, the application uses the domain, and the domain uses the infrastructure. In the past (around the early 2000s), the DIP wasn't as obvious as it is today (2024), as result, the layers used to reference the next one downstream. This means that, for instance, the domain layer could have references to the database details (I'm not pointing it here as something good or bad).

The idea of Ports and Adapters was introduced to address this because it enforces DIP to isolate the domain from details not directly involved in the business, like the application and infrastructure layers. This is done by reversing the dependency relation of the three layers shown bellow where the domain defines a contract of how it needs things to work and the peripheral layers will take care of following this contract. This contract is named Port, and an implementation of a Port is named Adapter. And other important name to mention here is that the "business part" is named Core.

That way, we kind of get rid of application and infrastructure layers per se and now we are relying on contracts defined by the core, which could be an interface in the world of Object Oriented Programming (OOO), and adapters of the surrounding layers implementing such contracts, like classes in OOO. As you can imagine, there will be adapters that will trigger actions or use the core, and these ones are called primary adapters. And the adapters called/triggered by the core are called secondary adapters.

To help you visualize it, rather than just show an image, image an application that has a web API and an AMQP event listener that can dispatch business processes, and based on some requirements it must persist something on DB and send email to user. On such hypothetical app, we could say that the core of the application would need to provide four ports: one for the web API, one for the AMQP listener, one to define how to send emails and one to define how to interact with DB. Assuming that the web API will use REST, the AMQP listener will be based on RabbitMQ, the emails will be sent using SendGrid and the DB will be Mongo, we could have the following configuration:

The basic idea of Ports and Adapters

As you can imagine, primary adapters usually take the form of REST controllers, event listeners (such as for Kafka or RabbitMQ), or Command Line Interfaces (CLIs). These are the components that trigger actions or use the core functionalities of the application.

On the other hand, secondary adapters typically include well-known components like repositories, SMTP clients, and services implementing storage management, such as an AWS S3 client. These adapters are the ones called or triggered by the code, serving as bridges between the application's core and external systems or data storage.

A Short But Effective Example

The following example will show a concrete example using Go.

Consider an hypothetical and isolated application feature which is: the user must opt-in for receiving news, maybe from a news website, and once it opts in it must receive an email confirming it. Easy and minimalist as is. Consider that the "service" that executes it, not compliant with Ports and Adapters yet, is this one and also that this is triggered from a POST request (let's abstract this part or the example will be too big):

package core
 
import (
	"errors"
	"fmt"
)
 
type NewsSubscriber struct {
	emailSender sendgrid.Mailer
	newsDB      db.Connection
}
 
func New() *NewsSubscriber {
	return &NewsSubscriber{
		emailSender: sendgrid.New(),
		newsDB:      postgres.NewConnection(),
	}
}
 
func (ns *NewsSubscriber) Subscribe(u User) error {
	sqlQuery := fmt.Sprintf(`
      UPDATE
        users 
      SET
        receive_news = 1
      WHERE
        id = %s`, u.ID)
	if err := ns.newsDB.Execute(sqlQuery); err != nil {
		return errors.New("failed to subscribe user to updates")
	}
	email := sendgrid.Email{
		Subject: "Subscription successfully done!",
		Body:    "Now you are subscribed to receive updates",
		To:      u.email,
	}
	if err := ns.emailSender.Send(email); err != nil {
		return errors.New("failed to send email to user")
	}
	return nil
}

Above code is kind of a crime, but take a deep breath and will get over it :)

In order to make it compliant with Ports and Adapter we can start pointing what is not compliant. If we take a close look at the New() function we see it is creating the instances of the email service and postgres connection client inline. So the first thing we can highlight is that we have a strong coupling between this service and the provider to send emails and the postgres database.

Other point is that the method Subscribe is directly creating the SQL statement to mark the user's row as "I want to receive news", again a feature of system's core is knowing too much about an implementation detail.

With above points we can see how hard it is to unit test this feature is. We also see that if, for instance, the company needs to replace Sendgrid with Mailgun, the feature must be directly modified to achieve it. Now imagine that the database also needs to be changed for MongoDB, another gigantic refactoring would be needed. Again, this is just an hypothetical and drastic scenario to illustrate the idea, for sure such examples don't happen quite often in real life, keep calm :)

In order to make it compliant with Ports and Adapters we could introduce two Ports: EmailSender and NewsSubscriptionRegister. Both them are interfaces describing what our service NewsSubscriber needs, and the "how" it is done does not matter. They are:

type EmailParams struct {
	Subject string
	Body    string
	To      string
}
 
// Port defining WHAT the email sender should do
// and not HOW.
type EmailSender interface {
	Send(e EmailParams) error
}

And:

 
// Port defining WHAT the process of saving on
// DB that user wants to receive news should do
// and not HOW
type NewsSubscriptionRegister interface {
	Register(u User) error
}

We can now adjust the NewsSubscriber to depends of such Ports:

type NewsSubscriber struct {
	emailSender               EmailSender
	newsSubscriptionRegister  NewsSubscriptionRegister
}

By doing so we applied the Dependency Inversion Principle (DIP) and now the service is depending on a contract that fits its needs, without any worries about how it works. Not directly related to it, but we could go even further enhancing flexibility and modularity by applying the Inversion Of Control (IOC) principle in such scenario by using dependency injection in the New constructor:

func New(
  emailSender EmailSender,
  newsSubscriptionRegister NewsSubscriptionRegister,
) *NewsSubscriber {
	return &NewsSubscriber{
		emailSender:               emailSender,
		newsSubscriptionRegister:  newsSubscriptionRegister,
	}
}

With above modification we removed the coupling that a Core feature had with the details of sending emails and database persistence by introducing contracts (Ports) that meets our needs and provides a clear separation of concerns. Additionally, we also are no longer in charge of the instantiation of those contracts implementations, the Adapters. Instead, we are delegating it the the client of the service NewsSubscriber. And the Register implementation might look like this:

func (ns *NewsSubscriber) Subscribe(u User) error {
	if err := ns.newsSubscriptionRegister.Register(u); err != nil {
		return err
	}
	email := EmailParams{
		Subject: "Subscription successfully done!",
		Body:    "Now you are subscribed to receive updates",
		To:      u.email,
	}
	if err := ns.emailSender.Send(email); err != nil {
		return err
	}
	return nil
}

In this implementation, the NewsSubscriber service is no longer directly responsible for the database registration. Instead, it delegates this responsibility to the newsSubscriptionRegister instance, providing a cleaner and more modular design. The Register method, representing the database registration action, encapsulates the specific logic related to news subscription registration.

About the adapters implementation we could have:

type SendGridEmailSender struct {
	client sendgrid.Mailer
}
 
func (s *SendGridEmailSender) Send(e core.EmailParams) error {
	if err := s.client.Send(e); err != nil {
      return fmt.Errorf("failed to send email to user, got %v", err)
	}
	return nil
}

And also:

type PostgresNewsSubscriptionRegister interface {
	connection postgres.Connection
}
 
func (p *PostgresNewsSubscriptionRegister) Register(u core.User) error {
	sqlQuery := fmt.Sprintf(`
      UPDATE
        users 
      SET
        receive_news = 1
      WHERE
        id = %s`, u.ID)
	if err := p.connection.Execute(sqlQuery); err != nil {
      return fmt.Errorf("failed to subscribe user to updates, got %v", err)
	}
	return nil
}

Outcomes Of Using Ports And Adapters

Note: I didn't use benefits or drawbacks as title and I'll elaborate the reason soon.

Adopting Ports and Adapters brings a considerable flexibility to replace components and providers. As an example, if the service you use to send emails gets expensive and the CTO of your company made a deal with a cheaper provider, the replacement should not be so hard as the main work would basically be, at least in theory, just implementing a new component respecting the Port contract.

Another trait of a code using Ports and Adapters is the high level of testability, because as the Core only deals with contracts, creating unit tests is a rather easy tasks using software doubles, like mocks.

Probably the most widely mentioned trait is completely isolation of frameworks and libraries. This can be achieved by integrating the framework or lib through adapters, ensuring that the Core remains completely agnostic to such points.

By using Ports and Adapters you also get reduced risk of vendor lock-in, as the first example given above has shown: if a provider is no longer suitable the Core of system will not be highly coupled to it, so a replacement should not be so complex.

Something really worth pointing out is the potential misuse and overhead of layers. The adoption of Ports and Adapters could potentially introduce the risk of developers getting excited on creating not necessary layers using as argument some of the above points. Personally, this is something I could see in the majority of systems I worked on using it, and a clear result of such layers misuse was the Pull Request size for a simple modification, like adding a new method to a Port, spanning across multiple files and directories.

And least, but not least, two things that must be mentioned are the learning curve and the initial development type. Trust me, do not neglect these topics, especially if the team that will be working with it is not familiar. It's necessary giving a time for the team get used to it to the team have gain traction.

I named above points as outcomes rather than "benefits and drawbacks" because they can be labeled as benefits or drawbacks only with context. Is having the core of the business 100% decoupled from the framework a must for your business? If so, then go for it. The point is: don't adopt Ports and Adapters just because other teams or companies are using it. Look for your needs, be straight to them and check if the intrinsic cost of effort of Ports and Adapters worth once compared to the outcomes of it to the project. I have worked in projects where Port and Adapters was a nice fit (and AFAIK the system is still in use after years), but I also have faced situations where a simple transaction script could do the job, but instead, it was done using a pile of layers in which the effective code, if placed in a single file, would not have more than +180 lines :)

Ports and Adapters is a valuable technique, not a strict doctrine. It is up to us to apply our skills, experience, and wisdom to make informed decisions on whether to embrace this architectural pattern based on our project's specific needs.

If you like this topic, I have also an article comparing Hexagonal Architecture with other famous software design buzz words, like Clean Architecture, you can read more about it here.