← Back to blog Post

Hexagonal Architecture on the Frontend

Hosted here as an English translation. Originally published on Software Crafters.

There are many definitions of the term architecture, depending on the context and the area of development from which one comes. Reaching a consensus and a single definition that applies to every case is therefore very difficult. Narrowing the discussion to frontend software development, and from my personal point of view, the definition is as follows:

Developers use the term architecture for a set of development patterns that establish guidelines and constraints for our software. It is the guide we should follow to organize our code and make the different parts of the application communicate with one another.

There are many options when choosing one architecture over another. Each has its own advantages and disadvantages. Even after choosing the one that best suits our case, it does not have to be implemented in the same way across different projects.

However, although the range of patterns is almost infinite, most share common quality attributes, such as scalability, single responsibility, decoupling, and maintainability. In general, it is therefore vital to understand the concepts and why a solution was adopted, rather than just the theory itself.

One of the most widely used software architecture design patterns is Hexagonal Architecture, also known as Ports and Adapters, introduced by Alistair Cockburn.

The main purpose of this pattern is to divide our application into distinct layers, allowing them to evolve independently and assigning each entity a single responsibility.

Why is it called hexagonal?

The idea of representing this architecture with a hexagon comes from how easily the visual concept conveys the theoretical one: our core code, called the domain, sits inside the hexagon, and each of its sides represents an interaction with an external service, such as third-party http services, databases, messaging services, or rendering.

!hexagon

Communication between the domain and the other actors takes place through a layer called infrastructure, which contains the specific implementation for each of these technologies.

!hexagon - infrastructure

One of the questions most frequently asked by professionals encountering this architecture for the first time is: “Why the hexagonal shape?” The hexagon is merely a theoretical representation. The number of services we can integrate with is infinite, and there can be as many as we need. At the other extreme is the simplest case, with only two interactions with the application domain; obviously, that situation cannot be represented by a polygon.

Same concept, different names

As mentioned earlier, this pattern is also called Ports and Adapters. The name comes from a separation within the infrastructure layer, where there are two sublayers:

  • Port: The interface that different variants of our code must implement to abstract away the technology. It defines the signatures of the methods that will exist.
  • Adapter: The implementation of the interface, where the specific code for using a particular technology is generated. It is never used directly in the application beyond its declaration, since it is accessed through the port type.

Thus, our domain makes calls to the sublayer corresponding to the port, remaining decoupled from the technology, while the port in turn uses the adapter.

This way, if the technology changes, only the outer layer (the adapter) is affected.

Since Ports and Adapters is closely tied to object-oriented programming and therefore to the use of interfaces, implementing this pattern in functional programming languages may differ slightly from the original concept.

In fact, multiple patterns have emerged that “iterate” on it, such as Onion Architecture and Clean Architecture. However, the premise is the same for all of them: divide our application into layers, separating the domain from the infrastructure.

How does it affect maintainability?

Having our code divided into layers, each with a single responsibility, helps each one evolve differently without affecting the others.

This segmentation also gives us greater cohesion, with each layer having a clearly defined, unique responsibility within the context of our software.

Finally, we should remember that people joining a team for the first time may not know these terms or be familiar with these concepts. It is therefore the team’s responsibility to be sufficiently generic and define a sufficiently robust structure so that this lack of knowledge does not become an additional burden on development.

How does it affect the frontend?

Today there are shortcomings in the methodologies used to create applications. The ease and speed provided by current tools have led us to set aside the work of analyzing and implementing well-known, thoroughly tested architectures.

Nevertheless, although these architectures may seem more suited to earlier times, when programming languages did not evolve at today’s dizzying pace, they have been designed—and in some cases adapted—to continue providing the scalability today’s applications need.

Historical background

About two decades ago, desktop applications were the primary software tool. Most of an application’s code was installed in libraries on the machine itself, and the view and its behavior were highly coupled.

To keep scaling applications and achieve software with greater maintainability and centralized databases (rather than local ones), many of these operations moved to the server. As a result, desktop applications were relegated to simple user interactions that did not require access to remote data or its persistence. When they did, applications were responsible for making those calls over the network to services deployed on external servers. This is where we began to see the first distinction between frontend and backend.

In the following years, the web boom began. Many desktop applications moved into the browser, where technological limitations were significant and html publishing was either static or had to be generated dynamically on the server. Over time, however, JavaScript gave browsers more capabilities.

Today

The visual layer had always been limited to representing data and had never needed much more functionality until now. With today’s needs, frontend applications have more requirements than they did years ago; for example, they manage application state, security, asynchrony, interaction-based animations, and integrations with third-party services.

This growth is why we need to start applying patterns to these applications, which have become entirely detached from their original context.

Consequence

As we have said, the frontend is mostly intended to display data. Despite this perception, it is not our application’s domain; it belongs to the outer layers of the implemented architecture.

The application’s use cases do belong to the domain, and they are not about how things should be displayed. For example: “In a shopping cart, we cannot add more than 5 products of the same type.”

Fetching data from the backend belongs to the infrastructure layer, since it falls outside our application’s responsibility, even if we manage that backend (it is another application, and its architectural needs are therefore independent). For example, the data schema in the backend may change at any time, and we do not want those changes to propagate throughout our frontend application.

On the other hand, managing session data (local, session, cookies) is another example of code belonging to the infrastructure layer. Although we have to deal with it, it is not part of our application’s domain.

And what about frontend libraries?

Today there are a huge number of rendering libraries: Angular, React, Vue, Stencil, Polymer, Svelte, Ember, and so on. We must understand their purpose and, therefore, THEY MUST NOT enter our application’s domain; they should instead be relegated to the infrastructure.

All these tools tend to evolve rapidly over time, so we must make our application as resilient as possible. How can we do that? One of the most common but least-known strategies is to wrap these libraries in functionality created specifically for that purpose. This strategy is commonly known as wrapping, and its main goal is to isolate our code from the side effects third-party libraries might have.

Wrapping is good practice, but when we use it, it should be consumed through an adapter, as mentioned earlier, to reduce coupling. Nevertheless—and this is a personal opinion—wrapping these tools in an implementation will lead to over-engineering, increasing maintenance and imposing a time penalty that, in most cases, the team cannot afford. We should therefore assess when to apply this technique efficiently.

As an argument against wrapping these kinds of libraries, we can say that communication between the view (infrastructure) and the domain is unidirectional: they are an entry point into our application for the user, but they are never consumed by the domain.

Once we understand this, we must accept that tools extremely coupled to these frontend libraries—for example, Redux—should be managed together at the infrastructure level.

Example

Next, we will look at a use case that aims to illustrate all these concepts with a shopping cart. First, we will break down the entities involved, which we will need to retrieve from a third-party service via http:

  • Product
  • Cart

On the other hand, these entities must be shown to the user so they can interact with them—for example, view products and add them to the cart.

Domain

In this layer we will have the models for our entities:

// Product.ts
export interface Product {
    id: string
    name: string
    description: string
    price: number
    image: string
}
// ProductRow.ts
import { Product } from './Product'

export interface ProductRow {
    product: Product
    quantity: number
}
// Cart.ts
import { ProductRow } from './ProductRow'

export interface Cart {
    id: string
    lines: ProductRow[]
}

We will also have the repositories (interfaces) and use cases for our application.

// Repository.ts
import { Product } from '../models/Product'
import { Cart } from '../models/Cart'

export interface Repository {
    getProducts: () => Promise<Product[]>
    getCart: (idCart: string) => Promise<Cart>
    addItemToCart: (idCart: string, idProduct: string) => Promise<void>
}
// usecase.ts
import { Repository } from '../repositories/Repository'
import { Product } from '../models/Product'
import { Cart } from '../models/Cart'

export const getProducts = (repository: Repository): Promise<Product[]> => {
  return repository.getProducts()
}

export const getCart = (repository: Repository, idCart: string): Promise<Cart> => {
  return repository.getCart(idCart)
}

export const addItemToCart = (repository: Repository, idCart: string, idProduct: string): Promise<void> => {
  return repository.addItemToCart(idCart, idProduct)
}

Infrastructure

Adapter

Once we have implemented the domain, we must now move on to our application’s adapters. To do this, we must implement the repositories. In this case, we have opted for an HttpRepository:

// HttpRepository.ts
import { Repository } from '../../domain/repositories/Repository'
import { Product } from '../../domain/models/Product'
import { Cart } from '../../domain/models/Cart'

export class HttpRepository implements Repository {
  private readonly baseUrl: string

  constructor (baseUrl: string) {
    this.baseUrl = baseUrl
  }

  getProducts = async (): Promise<Product[]> => {
    const response = await window.fetch(`${this.baseUrl}/products`)
    const responseJson = await response.json()
    return responseJson.map((product: any) => this.toProductModel(product))
  }

  getCart = async (idCart: string): Promise<Cart> => {
    const response = await window.fetch(`${this.baseUrl}/carts/${idCart}`)
    const responseJson = await response.json()

    const result = {
      id: responseJson.id,
      lines: responseJson.lines.map((line: any) => ({
        product: this.toProductModel(line.product),
        quantity: line.quantity
      }))
    }
    return result
  }

  addItemToCart = async (idCart: string, idProduct: string): Promise<void> => {
    await window.fetch(`${this.baseUrl}/carts/${idCart}/product/${idProduct}`, {
      method: 'POST'
    })
  }

  private toProductModel = (product: any): Product => ({
    id: product.id,
    name: product.name,
    description: product.description,
    price: product.price,
    image: product.image
  })
}

Presentation

Finally, all that remains is the view, where we will represent the data. At this point, we could use any rendering library of our choice.

In this case, we have chosen React. Below, we will show a simple example with two components:

// App.tsx
import * as React from 'react'
import { useEffect, useState } from 'react'
import { Product } from '../domain/models/Product'
import { getProducts, addItemToCart } from '../domain/usecases/usecases'
import { HttpRepository } from '../infraestructure/adapters/HttpRepository'
import './App.css'
import { ProductCard } from './components/ProductCard'

const repository = new HttpRepository('https://api-cart')

const App = () => {
  const [products, setProducts] = useState<Product[]>([])
  const CART_ID = '123456'

  useEffect(() => {
    const fetchProducts = async () => {
      const products = await getProducts(repository)
      setProducts(products)
    }
    fetchProducts()
  }, [])

  const addProductToCart = async (idProduct: string): Promise<void> => {
    await addItemToCart(repository, CART_ID, idProduct)
  }

  return (
    <div className='App'>
      <h1>Product List</h1>
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddProduct={addProductToCart}
        />
      ))}
    </div>
  )
}

export default App
// ProductCard.tsx
import * as React from 'react'
import { Product } from '../../domain/models/Product'
import './ProductCard.css'

interface ProductCardProps {
  product: Product
  onAddProduct: (id: string) => Promise<void>
}

export const ProductCard: React.FunctionComponent<ProductCardProps> = ({
  product,
  onAddProduct
}) => {
  const handleAddToCart = async () => {
    await onAddProduct(product.id)
  }

  return (
    <div className='Card'>
      <img className='CardImage' src={product.image} alt={product.name} />
      <div className='CardDescription'>
        <h3 className='CardTitle'>{product.name}</h3>
        <div className='CardPrice'>{product.price}€</div>
        <p className='CardText'>{product.description}</p>
      </div>
      <button onClick={handleAddToCart}>Add to cart</button>
    </div>
  )
}

Conclusion

This software architecture allows the domain of our application to be completely agnostic about how the data will be represented. It also offers us multiple advantages, such as:

  • Greater separation of responsibilities.
  • The ability to test the different components independently.
  • Less coupling to the technologies used.
  • Less propagation of changes when a technology needs to be replaced.
  • Easy adaptation of the architecture to new requirements without extensively modifying the existing code.

This paradigm also respects the SOLID principles, especially Single Responsibility and Dependency Inversion.

I hope this article has helped clarify these concepts and dispel doubts about how using this architecture affects frontend development, beyond the theory.

If you would like to explore the topic further, I encourage you to look at the following repository, where I have prepared a more complete example: Hexagonal Architecture Frontend Example.

References

  • Domain-Driven Design
  • Hexagonal Architecture, Alistair Cockburn
  • Onion Architecture, Jeffrey Palermo
  • Clean Architecture, Robert C. Martin
  • frontend
  • hexagonal-architecture
  • software-design