Why I Like Hexagonal Architecture

June 20, 2022 12 minute read

I haven’t written that much clean code in the last nine years. I’ve created features and entire products on my own. But looking at my code I didn’t get the satisfaction that an artist feels when they’re admiring their work.

I wasn’t writing the clean code you read about in books. I didn’t utilize design patterns. I didn’t think about architecture, structure and readability. My only concern was whether it produced the desired output on the client’s screen. When I think about it this was a normal consequence of the environment I was working in.

Code is Not Equal

In the beginning of my career I worked for small companies and startups. In these years I learned to build with confidence, knowing that I can shell out entire features if I had to. But the code you write in such places will rarely live more than a few years.

Either because the startup fails or the application gets rewritten with a different stack.

It was only when I joined a large corporation that I understood the benefits of spending time on structure and design. Everything you write there has the chance to live for more than a decade. Throughout that time, these tools need to be extended, modified, and maintained.

Without proper design, this won’t be an easy feat. So investing in code quality has an incredible return on investment. It will save a lot of engineering effort in the future.

Clean code creates a positive impact on both the development experience and the business’ needs. The engineers are productive and the company delivers more quickly. But every engineer understands the term clean code differently.

Some focus on readability and naming. Others favor testability above everything else. There are engineers who won’t let a PR through unless the author takes advantage of every design pattern possible.

Clean Code is Subjective

And that’s my biggest grudge with the term clean code - it’s too subjective. It’s a combination of many qualities with no strict priority and varying interpretations across languages and technologies. You can spend days polishing your implementation and it may still be lacking certain qualities.

But in an imperfect world where we have limited time, I find modularity to be most important.

The biggest problem that prohibits us from coming up with good software designs is the frequency of changes. Unstable requirements are the biggest cause of code quality degradation. The days where you could get a spec and work on it for half a year are long gone.

Nowadays we move in a much more tactical manner, making small decisions and building features and systems iteratively. Unfortunately, every minor addition can be in conflict with all the technical assumptions we’ve made so far.

Every simple change can echo throughout your codebase, leading to numerous modifications. So the design you made a month ago can be rendered obsolete as new business requirements come in.

Modularity & Layers

That’s why modularity is crucial. The domain logic of any application should be grouped in logical structures with clear boundaries between them. In fact, this has been one of the main points in both of my books on architecture.

Every module should represent a part of your domain. The back-end application of an ecommerce website can be split into user, product, and checkout modules, for example. Each one handling a part of the business and communicating via public interfaces like functions.

The front-end may be split similarly, based on the pages. With a clean separation, if we need to make a change related to the checkout, it won’t require restructuring in the other modules.

Modularity

If we have to refactor some of the logic, it will be contained inside a single module. This reduces the coupling of our applications and gives us more confidence when we inevitably have to refactor or redesign something.

But boundaries are not enough. When the changes are small, they can affect single modules. But when we start making large ones, the lines between modules become blurry. We may not be able to come up with a clear distinction between them.

Imagine that we have to change a fundamental data type. Even with strict boundaries, it will have to be reflected everywhere. A change in the signature of a module’s public method will have to be changed in every place it’s used.

If we zoom out a little bit, we’ll see that applications have other responsibilities. Most back-ends need to communicate with other services or clients via HTTP and work with data persisted in a database or a file. Single-page applications need to render components in the browser.

So on an abstract level, we can outline two main layers - the core domain logic and everything else that needs to use it.

Hexagonal Architecture

If we can put boundaries between them, we guarantee that changes in one layer won’t impact the other. These boundaries won’t change.

A REST API can seamlessly transition to message-based communication without any changes to the business logic. You can even run the same core in a CLI environment and if it’s properly encapsulated it won’t require any modifications.

Hexagonal Architecture

The idea to encapsulate the business logic isn’t something new and revolutionary. It was conceived back in 2005 with hexagonal architecture and it’s gone through multiple iterations like onion architecture and clean architecture.

There’s nothing in particular related to the number six, though. It’s just that the hexagon is a great visual representation of an application’s layers and responsibilities.

In its purest form, this architecture is implemented with two abstractions called ports and adapters. Every adapter represents an external responsibility and the ports are an extra abstraction to decouple them from the core.

This is a handler in a lambda function. It’s only responsible for extracting the required data from the event and passing it to the function which handles the domain logic.

const handler = async (event) => {
  const stockID = event.pathParameters.StockID
  const response = await retrieveStockValues(stockID)
  return response
}

This function needs to interact with other external sources of information to retrieve additional data. It doesn’t do it directly, though. Instead, it delegates that to other adapters which are called through a port.

// Domain
import Currency from '../ports/CurrenciesService'
import Repository from '../ports/Repository'

const CURRENCIES = ['USD', 'CAD', 'AUD']

const retrieveStockValues = async (stockID) => {
  const stockValue = await Repository.getStockData(stockID)
  const currencyList = await Currency.getCurrenciesData(CURRENCIES)

  const stockWithCurrencies = {
    stock: stockValue.STOCK_ID,
    values: {
      EUR: stockValue.VALUE,
    },
  }

  for (const currency in currencyList.rates) {
    stockWithCurrencies.values[currency] = (
      stockValue.VALUE * currencyList.rates[currency]
    ).toFixed(2)
  }

  return stockWithCurrencies
}

The ports are nothing more than a facade that abstracts away the port. Their aim is to prevent a tighter coupling between the domain layer and the adapters.

// Repository Port
import getStockValue from '../adapters/StocksDB'

const getStockData = async (stockID) => {
  try {
    const data = await getStockValue(stockID)
    return data.Item
  } catch (err) {
    return err
  }
}

export default getStockData

And this is the adapter itself. It pulls the data from DynamoDB but the rest of the application is left completely unaware of the details of the storage.

// Repository Adapter
const getStockValue = async (stockID) => {
  let params = {
    TableName: DB_TABLE,
    Key: {
      STOCK_ID: stockID,
    },
  }

  const stockData = await documentClient.get(params).promise()
  return stockData
}

export default getStockValue

I’ve never applied these architectures to the letter, though. Following existing practices dogmatically will prevent you from applying them in other environments. But the main idea that domain logic should be the core of your software and it should be abstracted away, has been an inspiration to me in recent years.

const handler = async (req, res) => {
  const { name, email } = req.body

  try {
    const user = userService.register(name, email)
    return res.status(httpStatus.CREATED).send(user)
  } catch (err) {
    return res.status(httpStatus.INTERNAL_SERVER_ERROR).send()
  }
}

This is the approach without the additional abstractions. It’s more understandable for engineers who are not aware of ports and adapters and it gives the same benefits at the cost of a tighter coupling.

The beauty of this architecture is that it’s widely applicable. We can use it even in front-end development where architecture and design are still overlooked. I wrote an entire article about react-query, a library that utilizes this approach very well.

export function useBaseQuery(options, Observer) {
  const queryClient = useQueryClient()
  const defaultedOptions =
    queryClient.defaultQueryObserverOptions(options)

  const [observer] = useState(
    () => new Observer(queryClient, defaultedOptions)
  )

  let result = observer.getOptimisticResult(defaultedOptions)

  return result
}

In React, we can use hooks to abstract away the domain logic and leave the framework-specific code to work as a transport layer. The core functionality remains agnostic of the environment in which it’s used.

This way we avoid one of the biggest problems in front-end development - the coupling of UI to domain logic. And if we have to split a component into smaller ones, this won’t impact the domain layer.

Summary

The main point of this architecture isn’t to insure ourselves against future database changes. Chances are we won’t be changing the storage or transport protocol once the app is running.

But the way we interact with the database will change, the parameters that we pull out of the request body will change. Our domain logic will evolve and we don’t want these two layers to be in constant conflict with each other

And most importantly, creating this abstraction gives us many of the other qualities we’re looking for in a piece of code. It will make our logic more decoupled, more extensible and easier to test.

Read More Articles