7 min read

The GOTH Chronicles: Introducing Hexagonal Architecture

Discover how Hexagonal Architecture (AKA Ports and Adapters) helps you to write cleaner code. With diagrams and code examples in GO.
The GOTH Chronicles: Introducing Hexagonal Architecture

Software applications last on average 6-8 years. During that period, the application will evolve and the world around these applications will change as well. So how do you organize your code to follow along? Reliably and cost-efficiently?

Hexagonal Architecture (aka Ports & Adapters) provides part of the answer, giving you a way to separate your business logic from the code that interacts with the outside world 💪 Interested? Let's dive into it 😄

Any (serious) software application will be connected to a myriad of (external) components & systems, both internal to the organization and operated by 3rd parties. Think about databases, filesystems, µ-services, message queues, and mail servers operated by your organization. Or third-party APIs to fetch data, execute payments, or calculate credit rates... offered by (commercial) partner organizations.

Similarly, users of the application will enter through various entry points: using a browser or a mobile app, or talking to your application indirectly, where an intermediate system uses your API.

On the INcoming side, the initiative to start the conversation lies with the external party. A user visits a URL through a browser, an external system sends a message to your asynchronous API, ... On the OUTgoing side, the initiative comes from the application. The application decides to store data in a database, fetch data from a data service, or publish a message to a message bus.

Each of these interactions may use different protocols, will come with their security and performance requirements, have specific data format requirements, ...

Moreover, these requirements will change over time. Databases may have version upgrades or even change altogether (eg. from MySQL to PostgreSQL). An external system may provide a SOAP endpoint today and move to a JSON/HTTP API tomorrow. Authentication schemes may change from username/password to certificate-based authentication, and so forth.

None of these changes should have a direct impact on the business logic of your application (or should be limited to functional changes, not the technical details).

So when looking at these interactions with the "outside world", there are several non-functional requirements to take into account:

  • Maintainability - easily fix bugs or evolutions in the code
  • Testability - run a good test set and build high-quality code
  • Flexibility & Adaptability - extend and expand when new needs arise
  • Performance and scalability - deal with large volumes (as soon as they are needed, not earlier)

Hexagonal Architectures provide a mental model to organize your code in such a way that these requirements are met, even if you have plenty of incoming & outgoing flows.

⚠️ As a disclaimer: there are different names for concepts that address (roughly) the same problem: clean architecture, onion architecture, and many more ... I am never biblical about these names or the subtle differences that come with them: I cherry-pick and recombine what resonates to me & adapt to the context of the team & organization. So please, use the term that works for you, your team, or your organization. My main goal is to document my learnings, hoping this will help & inspire you on your learning path.

The (sample) application: Ride Registrations

As an illustration application, we go for something simple. 🎯

As a user, I want to register, consult, update, and delete rides, where the user inputs the from- & to- address, as well as the date. The distance between the 2 addresses is calculated automatically through a 3rd party service (when the ride is created or updated). The screen would look something like this. This use case is part of a larger application, which remains out of scope for this post.

One can easily imagine similar use cases: registering a payment, or generating a usage report for a client, ...

The Target Set-up

To implement this use case, we have 1 incoming flow (a user coming through the browser) and 2 outgoing flows, one to the repository, and one to the distance calculator.

When a user requests a web page (1), the web adapter will be called. The initiative is inside the incoming or driving adapter to call the business logic, which will implement the incoming port. This setup allows for the actual business logic to be replaced by a stubbed/mocked implementation, supporting fast automated tests.

On the outgoing side, the initiative comes from the business logic, calling the adapter implementations through the interfaces of the outgoing (or driven) ports. This is shown for the repository - PgSQL implementation (2) and the distance calculation - Real implementation with the Geo Service (3). Here as well, replacing the real implementation with a dummy adapter (file-based repo or dummy calculator) is by design part of the possibilities.

An important aspect of this set-up work is the direction of the dependencies: the business logic does not know through which INcoming adapter it is called and does not know which OUTgoing adapter implements the external functionality.

An interesting consequence (and an indicator of whether you keep the architecture clean) is that ORM frameworks, JSON serialization libraries, and many other frameworks, will only reside in the adapters. Indeed, the HTTP routing info should only be found in the web adapter, and the SQL statements for PostgreSQL reside only in the PostgreSQL repository. So no annotations on the domain objects! This would create a tight coupling between the business logic and the external system.

This constraint will also help you to decide if this architecture is a good fit for you: if the business logic is a simple CRUD exposed as base REST API and simple database storage, this may be overhead not to take (yet) - as long as you can keep your tests fast. The more integrations you have, the bigger the advantage of this pattern.

Some Go Code: Connecting to a Distance Calculator

As an example, please find the interface definition (~ outgoing or driven port) for the distance calculator.

type RideDistanceCalculator interface {
	Calculate(from Address, to Address) (distance decimal.Decimal, err error)
}

The 1st implementation is a dummy one, returning a fixed distance, optionally after a delay (set through the constructor, omitted in this code sample).

type DummyDistanceCalculator struct {
	delay int
}

// Waits the set delay (0 if not set) and returns 100
func (ddc *DummyDistanceCalculator) Calculate(from ride.Address, to ride.Address) (distance decimal.Decimal, err error) {
	time.Sleep(time.Duration(ddc.delay) * time.Second)
	return decimal.NewFromInt(100), nil
}

The main usage of this implementation is in tests, where this implementation is injected into the service and provides fast, predictable results.

func TestRideService(t *testing.T) {
  // ...
  distanceCalculator, _ := geo.NewDummyDistanceCalculator()
  rideService, _ := ride.NewRideService(repository, logger, distanceCalculator)
  //...
}

The 2nd implementation connects to the distance calculator from the open route service. Security requirements of that 3rd party (in this case using an API token), are cleanly encapsulated in this implementation.

type openrouteDistanceCalculator struct {
	baseUrl string
	token   string
}

func (odc *openrouteDistanceCalculator) Calculate(from ride.Address, to ride.Address) (distance decimal.Decimal, err error) {
  // implementation details :) 
}

This implementation is injected into the business logic (called a service in this case) when booting the app. Logically, it requires different parameters than the dummy calculator like the API token for security.

func initRideSvc(ctx context.Context, logger *slog.Logger) ride.RideService {
  // ...
  token := "xxxxxxxxxxxxxxxxxx"
  distanceCalculator, err := geo.NewOpenrouteDistanceCalculator(token)
  if err != nil {
	logger.Error("unable to create distance calculator")
	os.Exit(1)
  }

  ridesvc, err := ride.NewRideService(rideRepo, logger, distanceCalculator)
  if err != nil {
	logger.Error("unable to create service")
	os.Exit(1)
  }

  return ridesvc
}

The service does not know which implementation will be injected (and does not care). It only knows that there will be a distance calculator available and that it will implement a Calculate method that returns a decimal.

func (r *rideServiceImpl) UpdateDistance(ctx context.Context, ride *Ride) (*Ride, error) {

	dist, err := r.distanceCalculator.Calculate(ride.From, ride.To)
	if err != nil {
		return nil, err
	}

	ride.Distance = dist
	return ride, nil
}

Adding a new Adapter (= Implementation of the interface)

From the examples above, it becomes clear (hopefully 🤞) that connecting to another implementation of the distance calculator, e.g. based on Google Maps, is a matter of implementing the interface and injecting the new adapter into the business logic.

The Outcome

Using the approach of a hexagonal architecture with ports and adapters allows you to cleanly separate the code needed to interact with external systems from the business logic at the core of your application.

The separation of concerns and use of interfaces allows for quickly swapping one implementation with another, which raises testability and allows you to have fast tests.

Security measures and performance optimizations (eg. caching, but also throttling or circuit breaking) have a clear place in the implementation (adapter) and do not creep into the core of the application.

I hope this helps to clarify the underlying approach of the ports and adapters or hexagonal architecture, and may inspire your learning journey to master this (and more) good practices for cleaner code 😄 Success!

References

Hexagonal architecture
Create your application to work without either a UI or a database so you can run automated regression-tests against the application, work when the database becomes unavailable, and link applications together without any user involvement. Japanese translation of this article at http://blog.tai2.net/hexagonal_architexture.html Spanish translation of this article at http://academyfor.us/posts/arquitectura-hexagonal courtesy of Arthur
A Color Coded Guide to Ports and Adapters | 8th Light
Testing
DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together
In my last posts I’ve been writing about many of the concepts and principles that I’ve learned and a bit about how I reason about them. But I see these as just pieces of big a puzzle. …
Clean Architecture with GO
Introducing Clean architecture with Go.