DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

The software you build is only as secure as the code that powers it. Learn how malicious code creeps into your software supply chain.

Apache Cassandra combines the benefits of major NoSQL databases to support data management needs not covered by traditional RDBMS vendors.

Generative AI has transformed nearly every industry. How can you leverage GenAI to improve your productivity and efficiency?

Modernize your data layer. Learn how to design cloud-native database architectures to meet the evolving demands of AI and GenAI workloads.

Related

  • Medallion Architecture: Why You Need It and How To Implement It With ClickHouse
  • Build a Scalable E-commerce Platform: System Design Overview
  • Modern ETL Architecture: dbt on Snowflake With Airflow
  • System Design of an Audio Streaming Service

Trending

  • Integrating Model Context Protocol (MCP) With Microsoft Copilot Studio AI Agents
  • The Full-Stack Developer's Blind Spot: Why Data Cleansing Shouldn't Be an Afterthought
  • Metrics at a Glance for Production Clusters
  • Data Quality: A Novel Perspective for 2025
  1. DZone
  2. Software Design and Architecture
  3. Microservices
  4. Hexagonal Architecture in the Frontend: A Real Case

Hexagonal Architecture in the Frontend: A Real Case

Discover why hexagonal architecture, decoupling, and dependency injection can be very useful in the front end.

By 
Sergio Carracedo user avatar
Sergio Carracedo
·
May. 22, 24 · Tutorial
Likes (3)
Comment
Save
Tweet
Share
5.5K Views

Join the DZone community and get the full member experience.

Join For Free

Hexagonal architecture is a software design pattern based on the separation of responsibilities. The goal is to decouple the business logic (domain) and the application from other external interfaces.

Simplifying, in hexagonal architecture we communicate the core of the app (domain + application) with the external elements using ports and adapters. A port lives in the core; it is the interface any external code must use to interact with the core (or the core with the external code). The adapter is the external piece of code that follows the port interface and executes the tasks, gets the data, etc.

You can imagine the port is a space reserved only for an exact type of vessel. The vessel can only enter the port and dock if the load/unload doors are of an expected size and are in the correct position. Multiple vessels can fit in a port and vessels can be replaced, but ports are unique and can not be moved.

Hexagonal architecture

A key concept is that the core doesn’t know anything about the external components. The port defines the vessel door positions but doesn’t care about how the load is stored in the vessel.

In this case, we will also use the repository pattern (that fits very well with hexagonal, as it defines a centralized and abstract way of accessing data and it is a very common pattern), and the dependency injection principle that allows us to create decoupled (or loosely coupled) software. Simplifying again, it allows us to replace an adapter with another one that follows the same port interface.

Let’s see it in action with a small (and typical) example:

Your domain (core) needs to get a list of users with a name, so you define the port that is a repository. The port defined a method to do that: getUsersByName(name: string): User[]. In English, it defines that the adapter must provide a method called getUsersByName that gets a name and should return the list of the users that match that name.

A Real Case

The Initial Context

We have a single web application (frontend) that works for different clients (tenants), and that application uses a backend that provides the menu data. The backend returns something like this:

JSON
 
{
  "title": "Main Menu",
  "id": "main",
  "is_staff": false,
  "items": [
    {
      "title": "Home",
      "icon": "",
      "url": "/",
      "is_staff": false
    },
    {
      "title": "Dashboards",
      "icon": "dashboards",
      "id": "dashboards",
      "is_staff": false,
      "items": [
        {
          "title": "Home",
          "icon": "dashboards-home",
          "url": "/dashboards",
          "is_staff": false
        },
        {
          "title": "Config (X)",
          "icon": "dashboards-config",
          "url": "/dashboards-config",
          "is_staff": true
        },
        {
          "title": "Advanced Reports",
          "icon": "",
          "id": "advanced_reports",
          "is_staff": false,
          "items": [
            {
              "title": "Sales Analysis",
              "icon": "",
              "url": "/sales_analysis",
              "is_staff": false
            },
            ...
          ]
        }
      ]
    }
  ]
}


The front end partially implements the repository pattern as it just returns the data the backend provides without more manipulation than removing the first level in the tree (the main menu item). The view executes the repository call using a service: that again just returns the same information it gets from the repository.
Frontend and backend

The Issues

This “architecture” works, but has some issues that can create serious problems in the future:

  • The data structure is coupled to the backend data: All the data flows from the backend to the view using the same interfaces. If the backend changes just the name of a property, we need to follow the data flow in our code until the view changes it in all the places.
  • The title string includes an emoji to allow users to visualize when a menu item is only for staff users: That information is also provided in the is_staff property. If we want to expose a menu item to regular users, we need to change it in 2 places, and that is never a good idea.
  • Visuals are defined in the backend: The name of the icon to use is defined in the backend. Unless the icon would be an app (backend + frontend) global concept, it is not a good idea to pass that value front the backend.
  • No domain: there is no domain, or at least no explicit one. Logic is applied in the view (that it is not bad per se, but if the logic is related to the business rules, it must live in the domain).

The Problem

Because of different reasons, the company decided to create a new version of the backend. This new backend (called v2) will not be retro-compatible with the legacy one, but it will represent semantically the same entities.

The menu endpoint will return the same menu (it will provide more features) but the new endpoint response structure is completely different:

JSON
 
[
    {
        "menuStateId": 3,
        "menuPosition": 1,
        "menuName": "Dashboards",
        "menuItemId": 9,
        "menuItemTitle": "Home",
        "menuItemPosition": 1,
        "menuItemLink": "/dashboards",
        "menuItemStateId": 3,
        "menuInternalName": "dashboard",
        "menuId": 12,
        "menuParentId": 1,
        "menuItemInternalName": "dashboard.home"
    },
    {
        "menuStateId": 3,
        "menuPosition": 1,
        "menuName": "Dashboards",
        "menuItemId": 9,
        "menuItemTitle": "Sales analysis",
        "menuItemPosition": 1,
        "menuItemLink": "/sales_analysis",
        "menuItemStateId": 3,
        "menuInternalName": "dashboard",
        "menuId": 12,
        "menuParentId": 1,
        "menuItemInternalName": "dashboard.sales_analysis"
    },
    {
        "menuStateId": 3,
        "menuPosition": 1,
        "menuName": "Dashboards",
        "menuItemId": 9,
        "menuItemTitle": "Config",
        "menuItemPosition": 1,
        "menuItemLink": "/dashboards-config",
        "menuItemStateId": 1,
        "menuInternalName": "dashboard",
        "menuId": 12,
        "menuParentId": 1,
        "menuItemInternalName": "dashboard.sales_analysis"
    },
    {
        "menuStateId": 3,
        "menuPosition": 1,
        "menuName": "Dashboards",
        "menuItemId": 9,
        "menuItemTitle": "Sales analysis",
        "menuItemPosition": 1,
        "menuItemLink": "/sales_analysis",
        "menuItemStateId": 3,
        "menuInternalName": "dashboard",
        "menuId": 12,
        "menuParentId": 1,
        "menuItemInternalName": "dashboard.sales_analysis"
    },
    {
        "menuStateId": 3,
        "menuPosition": 1,
        "menuName": "Main",
        "menuItemId": 11,
        "menuItemTitle": "Home",
        "menuItemPosition": 5,
        "menuItemLink": "/",
        "menuItemStateId": 3,
        "menuInternalName": "home",
        "menuId": 1,
        "menuParentId": 1,
        "menuItemInternalName": "home"
    },
    ...
]


The new backend endpoint returns the menu items and its parent menu data in the same row. The structure is flat (no nested items). Another difference is the is_staff is still there, but it’s a specific value for the menuItemStateId property. There is no icon name, but now we have an internalId as a semantic unique ID.

Things Can Become Harder

The new backend will not replace the legacy one, at least not in the next months. Clients will be migrated slowly to the new backend. So some clients will use the legacy backend and others will use the new one. That means we will have both backends working at the same time for months.

As the data returned by both backends is very different, it seems tough to use the same frontend code to render the menu for all the clients, right? (Not really, as we will see later.)

A possible solution is to create different menu-related components, code, etc. depending on the backend version adapting our application to them. This can work, but it means we will need to duplicate a lot of code; for example, the views, the services, etc., making the maintenance harder.

Decoupling Us From the Backend

Let’s forget for a while how the backend(s) data returns, and think about what we want to represent from the point of view of our application.

We want to represent a menu that can have items with children items (and no link), and items with links and no children. Then let’s create a model (models, in our case) in our domain as entities that will represent exactly that:

TypeScript
 
type State = 'disabled' | 'only_for_staff' | 'open'
class Menu {
  readonly id: number = 0
  readonly internalName: string = ''
  readonly title: string = ''
  readonly icon: string = ''
  readonly image: URL | undefined
  readonly state: State = 'open'
  readonly description: string = ''
  readonly position: number = 0
  readonly children: (Menu | MenuItem)[] = []

  constructor(values: MenuDto) {
    this.id = values.id
    this.internalName = values.internalName
    //...
    this.children = values.children
  }

  get onlyForStaff(): boolean {
    return this.state === 'only_for_staff'
  }
}

class MenuItem {
  readonly id: number = 0
  readonly internalName: string = ''
  readonly title: string = ''
  readonly icon: string = ''
  readonly url: string = ''
  readonly state: State = 'open'
  readonly position: number = 0
  readonly menuId: number = 0

  private constructor(values: MenuItemDto) {
    // hydrate the entity
    this.id = values.id
    //....
  }

  get isStaff(): boolean {
    return this.state === 'hidden'
  }

  public get external(): boolean {
    return (this.url.includes('http://')
  }
}


This is a simplified version of the entities, but you can see the idea. We have a Menu entity that can have children that can be Menu or MenuItem entities. The MenuItem entity has a url property that can be used to know if the item is a link or not.

We modeled the domain, and our application layer (and views) can access it.

The key is this: we modeled our menu independently of our backends’ data structures. We can use any backend that represents that entity to get the data independently of the structure.

The Port

We should create the port that will allow us to get the menu’s data from the backend(s).

TypeScript
 
interface MenuRepo {
  getMainMenu(states: State[]): Promise<(MenuItem | Menu)[]>
}


The port defines how the repository should look. In this case, we want a method that will return the main menu, filtered by state ('disabled' | 'only_for_staff' | 'open').

The Adapter: The Repositories Will Do the Magic

We need to create the adapters that will get the data from the backend and transform it to our domain entities. We need an adapter, also called repository implementation, for each backend (we could have even more for mocked data, stubs for testing, etc).

Remember, the repository implementation (adapter) is the one that knows the “external to the core” internals:

  • How to get the data at the infrastructure level: REST, GraphQL, local storage, etc.
  • How to request the data: For example, for an XHR request: headers, query params, URL, etc.
  • The returned data structure and how to transform it to the domain entities
  • How to handle errors, retries, etc.
  • How to cache the data

But again, the domain NEVER should not know about that.

For example, the domain must not know that to get items available only for staff users, we need to pass the menuItemStateId param with the value 1.

menuItemStateId is an implementation detail. it only makes sense in repository implementation, not in the domain. The domain should know about the onlyForStaff meaning, and the adapter should know how to get that information from the backend.

In this case (for backend v2), we need to pass a query param called menuItemStateId with the value 1 to get the staff-only items, but that is different for the legacy backend, or for another backend that can use a different value for that filter, but the argument that represents what we want is still the same: onlyForStaff.

From the point of view of the layers on the right side - the port’s line in the workflow (image above) - it does not matter how the data is retrieved. The only thing that matters is the data is returned as a domain entity. That is our contract.

TypeScript
 
// menu.legacy.repo.ts
type Response = {
  // This type defines the shape of the data the backend returns. I do not include it here to put the focus on the data transformation into entities
}
class LegacyMenuRepo implements MenuRepo {
  async getMainMenu(states: State[]): Promise<(MenuItem | Menu)[]> { // [1] 
    const data = await fetch<Response>('tenant.company.com/get_menu')
    const backendMenu = await data.json()
  
    return backendMenu.map(item => responseToEntity(backendMenu))  
  }
  
  private responseToEntity(response: Response): (MenuItem | Menu) {
    // transform the response to the domain entities
    if ('items' in response) {
      return new Menu({
        id: response.id,
        internalName: response.id,
        title: response.title,
        icon: mapIcon(response.id), // [2]
        image: mapImage(response.image),  // [2]
        state: mapState(response.state),  // [3]
        children: response.items.map(item => responseToEntity(item))
      })
    } else {
        return new MenuItem({
            id: response.id,
            internalName: response.id,
            title: response.title,
            icon: mapIcon(response.id), // [2]
            url: response.url,
            state: mapState(response.state),  // [3]
            menuId: response.menuId
        })
    }
  }
}


Things to focus on:

  • [1]: The method that receives the state's argument is not used in the code. This is because the backend does not accept any filter. The legacy backend does the filtering using the backend context, but it ensures will only return the items the user can have access to.
  • [2]: Those map functions are in charge of providing the correct icon and image. Now the backend does not provide that information, so our repository implementation should provide it. Remember: the repository implementation (adapter) is the one that knows all the external internals and for the images, the adapter knows that if the id is “x,” it should return the image “y” and the icon “z”.
  • [3]: The mapState function behavior is similar to [2], but in this case, the backend returns a number that represents the state, the adapter should know how to map that number to the domain state, and that function can be reversed to know that the state should be sent to the backend.

We need to implement the adapter for the “new” backend (v2):

TypeScript
 
// menu.v2.repo.ts
type Response = {
  // This type defines the shape of the data the backend v2 returns. I do not include it here to put the focus on the data transformation into entities
}

const stateMappings: Record<number, State> = {
  0: 'disabled',
  1: 'only-for-staff',
  2: 'open'
}

const stateMappingsReverse: Record<number, State> = {
  'disabled': 0,
  'only-for-staff': 1,
  'open': 2
}

class V2MenuRepo implements MenuRepo {
  async getMainMenu(states: State[]): Promise<(MenuItem | Menu)[]> {
    const data = await fetch<Response>('menu.company.com/company/get', { // [1]
      params: {
        menuItemStateId: states.map(state => stateMappingsReverse[state]) // [2]
      }
    })
    const backendMenu = await data.json()
  
    return backendMenu.map(item => responseToEntity(backendMenu))  
  }
  
  private responseToEntity(response: Response): (MenuItem | Menu) {
    //Here the transformations from flat to nested are more complex (require more code lines) so I'm going to ignore it in the example. Let's imagine it is done after this line
    // transform the response to the domain entities
    if ('items' in response) {
      return new Menu({
        id: response.id,
        internalName: response.internalName,
        title: response.title,
        icon: mapIcon(response.internalName), // [3]
        image: mapImage(response.internalName),  // [3]
        state: stateMappings[response.menuStateId], // [4]
        children: response.items.map(item => responseToEntity(item))
      })
    } else {
        return new MenuItem({
            id: response.id,
            internalName: response.id,
            title: response.title,
            icon: mapIcon(response.internalName), // [3]
            url: response.url,
            state: stateMappings[response.menuItemStateId], // [4]
            menuId: response.menuId
        })
    }
  }
}


Things to focus on in the backend v2 repo implementation:

  • [1]: The endpoint (even the domain) is different from the other repo. That is expected as it is a different backend.
  • [2]: You need to convert the meaning of the filters to the backend meaning. The adapter knows that the backend expects a query param called menuItemStateId with the values 0, 1, or 2 to get the items with the state disabled, only-for-staff, or open.
  • [3]: We have mapping functions for the icons and images, but this function is different from the legacy one.
  • [4]: We convert the menuItemStateId and menuStateId to the domain state using the mappings.

After the changes, the architecture looks like this:

Changed architecture

Now we have 2 different adapters (one per backend) for the same port. Those adapters follow the contract and convert the backend data to the domain entities.

The rest of the flow is the same: the domain does not know how the data is retrieved, it only knows how to use it. This gives us a lot of flexibility. We can change the backend without changing the domain the application, or the views.

The Dependency Injection

The last piece of the “puzzle” is the dependency injection, which allows us, to replace a repository implementation with another one that follows the same port interface, but instead of importing it from the code that will call the repository, we inject it from outside allows us to change it easily.

Let’s suppose we have a use case (or application service) that will use the repository to get the menu:

TypeScript
 
class GetMainMenuUseCase {
    constructor(private menuRepo: MenuRepo) {}
    
    async execute(states: State[]): Promise<(MenuItemDto | MenuDto)[]> {
        return this.menuRepo.getMainMenu(states).map(entity => entity.toDto())
    }
}


We can use a factory to create the repository implementation:

TypeScript
 
function createMenuRepo(clientId: string): MenuRepo {
  if (['client123', 'client34'].includes(clientId)) {
    return new V2MenuRepo()
  } else {
    return new LegacyMenuRepo()
  }
}

const useCase = new GetMainMenuUseCase(createMenuRepo('client123'))

usecase.execute(['open', 'only-for-staff']) 


Summarizing

The hexagonal architecture, the repository pattern, and the dependency injection are very powerful tools that allow us to create decoupled software that works in independent pieces loosely coupled that can be easily changed and make the maintenance simpler.

Those pieces should define a contract for the actions (execute a method) and for the returned data, and should not be used in other places. For example, it is a bad practice to pass the filters directly to the repository implementation and use them as is in the HTTP request because you are coupling your application code to the backend, as we see in the example when we map the filter values.

As you can see in the example, we can change the backend at any moment: it’s just in order to change the repo implementation, we inject it into the use case without changing anything else.

This will work only if all the different backend returns the same business concepts. If not, we are talking about different domain models and we need to create different ports and adapters for each one.

Achieving that can require time and knowledge of the domain and the business rules, but the benefits are worth it.

Architecture Dependency injection Data (computing) entity Icon

Opinions expressed by DZone contributors are their own.

Related

  • Medallion Architecture: Why You Need It and How To Implement It With ClickHouse
  • Build a Scalable E-commerce Platform: System Design Overview
  • Modern ETL Architecture: dbt on Snowflake With Airflow
  • System Design of an Audio Streaming Service

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!