AboutOpinionesBlogLa Blockletter

Software Crafters® 2025 | Creado con 🖤 para elevar el nivel de la conversación sobre programación en español | Legal

Home » react » Arquitectura Hexagonal en el FrontEnd
Arquitectura Hexagonal en el FrontEnd

Arquitectura Hexagonal en el FrontEnd

Los desarrolladores llamamos arquitectura al conjunto de patrones de desarrollo que permiten definir unas pautas a seguir en nuestro software en cuanto a límites y restricciones.

Adrián Ferrera · Seguir13 min read ·

Al suscribirte a la newsletter comparto contigo los 10 libros más importantes de programación. Los que sin duda todo dev debería leer al menos una vez...

Existen multiples definiciones para el término arquitectura dependiendo del contexto en el que se trate y de la vertiente del desarrollo de la que se provenga, por lo que es muy complicado llegar a un consenso y a una definición única que sea válida para todos los casos. Así pues, ciñéndonos al desarrollo de software en frontend, bajo un punto de vista personal la definición es la siguiente:

Los desarrolladores llamamos arquitectura al conjunto de patrones de desarrollo que permiten definir unas pautas a seguir en nuestro software en cuanto a límites y restricciones. Es la guía que debemos seguir con la finalidad de ordenar nuestro código y hacer que las distintas partes de la aplicación se comuniquen entre sí.

Existe una gran cantidad de opciones a la hora de decantarnos por una arquitectura u otra. Cada una de ellas tendrá sus propias ventajas e inconvenientes. Incluso una vez escojamos cuál es la mejor que se adapta a nuestro caso, no tiene por qué implementarse de igual forma en los distintos proyectos.

Sin embargo, aunque el abanico de patrones es casi infinito, la gran mayoría mantienen atributos de calidad comunes, tales como: escalabilidad, responsabilidad única, desacoplamiento, mantenibilidad, etc.; por lo que, de manera general, es de vital importancia entender los conceptos y por qué se ha adoptado dicha solución, más que la teoría en sí.

Uno de los patrones de diseño de arquitectura de software más utilizados es el de la Arquitectura Hexagonal (Hexagonal Architecture), también conocida como arquitectura de Puertos y Adaptadores (Ports and Adapters), dada a conocer por Alistair Cockburn.

La finalidad principal de este patrón es dividir nuestra aplicación en distintas capas, permitiendo su evolución de manera aislada y responsabilizando a cada entidad de una funcionalidad única.

¿Por qué se le llama hexagonal?

La idea de representar esta arquitectura con un hexágono es debido a la facilidad que presenta el asociar el concepto teórico con el concepto visual, puesto que dentro de dicho hexágono es donde se encuentra nuestro código base, llamado dominio, y cada uno de sus laterales es una interacción hacia un servicio externo, por ejemplo: servicios http de terceros, bases de datos, servicio de mensajería o renderización.

hexagon

La comunicación del dominio con el resto de actores se realiza en una capa denominada infraestructura, donde se encuentra la implementación específica para cada una de estas tecnologías.

hexagon - infrastructure

Una de las preguntas más frecuentes entre los profesionales que ven por primera vez esta arquitectura, es: "¿a qué se debe la figura hexagonal?" El uso del hexágono no es más que una mera representación teórica. El número de servicios con los que podemos integrarnos es infinito y pueden ser tantos como necesitemos. En el lado opuesto tenemos el caso más simple donde sólo hay dos interacciones con el dominio de la aplicación y, obviamente, esta situación no puede representarse con una figura poligonal.

Mismo concepto distintos nombres

Como hemos comentado previamente, este patrón recibe también el nombre de puertos y adaptadores (Ports and Adapters). Este nombre viene de una separación dentro de la capa de infraestructura, donde tendremos dos subcapas:

  • Puerto: Es la interfaz que deberán implementar las distintas variantes de nuestro código para abstraerse de la tecnología. En ella se ha de definir la firma de los métodos que existirán.
  • Adaptador: Es la implementación de la interfaz, en ella se generará el código específico para consumir una tecnología en concreto. Esta nunca se usará de forma directa en la aplicación, más allá de la declaración, ya que su uso se realizará a través del tipo del puerto.

Así pues, nuestro dominio realizará llamadas a la subcapa que se corresponde con el puerto, quedando desacoplado de la tecnología, mientras que éste, a su vez, consumirá el adaptador.

De esta manera, en caso de realizar un cambio tecnológico, sólo se verá afectada la capa externa (adaptador).

Dado que el concepto de puertos y adaptadores está muy ligado a la programación orientada a objetos y por lo tanto al uso de interfaces, es probable que la implementación de este patrón en lenguajes de programación funcional difiera ligeramente del concepto inicial.

De hecho han surgido múltiples patrones que "iteran" sobre éste, como la Arquitectura Cebolla (Onion Architecture) o la Arquitectura Limpia (Clean Architecture). Sin embargo, la premisa es la misma para todas ellas: dividir nuestra aplicación en capas, separando el dominio de la infraestructura.

¿Cómo afecta en la mantenibilidad?

El hecho de tener nuestro código dividido en capas, donde cada una de ellas tienen una responsabilidad única, ayuda a que evolucione de forma distinta, sin repercusión en las demás.

Por otra parte, con esta segmentación también conseguimos una mayor cohesión, donde cada capa tendrá una responsabilidad bien definida y única dentro del contexto de nuestro software.

Finalmente, debemos tener en cuenta que no todas las personas que se incorporan por primera vez a un equipo conocen estos términos o están familiarizadas con dichos conceptos, por lo que es responsabilidad de los equipos ser lo suficientemente genéricos y definir una estructura lo suficientemente robusta para que la carencia de dichos conocimientos no suponga una carga adicional al desarrollo.

¿Cómo afecta en el frontend?

En la actualidad hay una serie de carencias respecto al uso de metodologías a la hora de crear aplicaciones. La facilidad y la velocidad proporcionadas por las actuales herramientas han hecho que dejemos a un lado el trabajo de análisis e implementación de arquitecturas conocidas y sobradamente contrastadas.

No obstante, aunque estas arquitecturas puedan parecer más propias de otros tiempos donde los lenguajes de programación no evolucionaban a un ritmo tan vertiginoso como hoy en día, dichas arquitecturas han sido planteadas, y en algunos casos adaptadas, para que sigan proporcionando la escalabilidad necesaria para las aplicaciones actuales.

Marco histórico

Hace aproximadamente dos décadas las aplicaciones de escritorio eran la herramienta principal en cuanto a software. En ellas, el grueso del código de la aplicación se encontraba instalado en librerías, dentro de la propia máquina, y el nivel de acoplamiento era elevado entre la vista y comportamiento de la misma.

Con la finalidad de continuar escalando las aplicaciones y llegar a un software con mayor mantenibilidad y unas base de datos centralizadas (no en un entorno local), muchas de estas operaciones se llevaron al servidor. Esto ocasionó que las aplicaciones de escritorio quedasen relegadas a meras interacciones de usuario que no requerían de acceso, persistencia o datos remotos. De necesitarlo, las aplicaciones tendrían la responsabilidad de hacer estas llamadas a través de la red a los servicios desplegados en servidores externos. Es aquí donde empezamos a ver la primera distinción entre frontend y backend.

En los siguientes años surgió el boom de la web. Muchas de las aplicaciones de escritorio saltaron al marco del navegador, donde las limitaciones tecnológicas eran notorias y la publicación del

html
bien era estática o tenía que generarse de forma dinámica en el servidor. Sin embargo, con el paso del tiempo
JavaScript
comenzó a dotar de mayores posibilidades a los navegadores.

Actualidad

La parte visual siempre se había limitado a la representación de datos y nunca había necesitado mayor funcionalidad hasta hoy. Con las necesidades actuales, las aplicaciones frontend tienen mayores requisitos que los existentes hace años, por ejemplo: gestionan el estado de la aplicación, seguridad, asincronía, animaciones basadas en interacciones, integraciones con servicios de terceros, etc.

Es por este crecimiento que nos vemos en la necesidad de comenzar a aplicar patrones en estas aplicaciones, que se han desvinculado en su totalidad del contexto inicial.

Consecuencia

Como bien hemos dicho, la finalidad del frontend es en su mayoría visualizar datos. A pesar de esta percepción, no es el dominio de nuestra aplicación, sino que pertenece a las capas exteriores de la arquitectura implementada.

Los casos de uso de la aplicación si pertenecen al dominio y no son relativos a cómo se deben visualizar. Por ejemplo: "Dentro de una cesta de la compra que no podemos añadir más de 5 productos de un mismo tipo."

La petición de datos al backend pertenece a la capa de infraestructura, ya que es algo que escapa de la responsabilidad de nuestra aplicación, aunque seamos nosotros quienes gestionemos el backend (esta es otra aplicación y por lo tanto las necesidades de arquitectura serán independientes). Por ejemplo el esquema de datos en el backend puede cambiar en cualquier momento y no queremos propagar esos cambios por toda nuestra aplicación frontend.

Por otro lado, la gestión de los datos de la sesión (local, session, cookies) es otro ejemplo de código que pertenece a la capa de infraestructura, porque si bien tenemos que lidiar con ella, no pertenece al dominio de nuestra aplicación.

Y con las librerías de frontend ¿qué sucede?

En la actualidad existe una cantidad ingente de librerías para renderización: Angular, React, Vue, Stencil, Polymer, Svelte, Ember, etc.; pero debemos comprender cuál es su finalidad, y que por tanto, NO DEBEN entrar en el dominio de nuestra aplicación sino que deben ser relegadas a la infraestructura.

Todas estas herramientas tienden a evolucionar rápidamente en el tiempo y por lo tanto debemos hacer que nuestra aplicación sea lo más resiliente posible. ¿Cómo podemos hacer esto? Una de las estrategias más usuales pero a la vez menos conocidas es la de envolver dichas librerías en funcionalidades creadas expresamente para tal fin. A esta estrategia se la conoce comúnmente como wrapping y su objetivo principal es aislar a nuestro código de los efectos secundarios que las librerías de terceros podrían tener.

El wrapping es una buena práctica pero cuando la utilicemos deberá ser consumida a través de un adaptador, como ya hemos dicho previamente, con la finalidad de reducir el acoplamiento. No obstante — y esto es una opinión personal — el hecho de envolver estas herramientas mencionadas en una implementación nos hará incurrir en una sobre-ingeniería que nos acarreará un mayor mantenimiento y una penalización de tiempo que, en la mayoría de los casos, el equipo no podrá afrontar, por lo que debemos valorar cuándo aplicar esta técnica de forma eficiente.

Como argumento al por qué no envolver este tipo de librerías, podemos afirmar que la comunicación entre la vista (infraestructura) y el dominio es unidireccional, es decir, son un punto de entrada a nuestra aplicación para el usuario, pero nunca serán consumidos por el dominio.

Una vez comprendido esto, tenemos que asumir que las herramientas que se acoplan de forma extrema a estas librerías de frontend, por ejemplo Redux, deben ser gestionadas de forma conjunta a nivel de infraestructura.

Ejemplo

A continuación veremos un caso de uso donde intentaremos plasmar todos estos conceptos sobre una cesta de la compra. Primero desglosaremos las entidades que entran en juego, las cuales tendremos que recuperar de un servicio de terceros vía http:

  • Producto
  • Cesta

Por otro lado estas entidades deberán mostrarse al usuario, de manera que pueda interactuar con ellas, por ejemplo: ver los productos y añadirlos a la cesta.

Dominio

En esta capa tendremos los modelos de nuestras entidades:

// 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[]
}

También tendremos los repositorios (interfaces) y casos de uso de nuestra aplicación.

// 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)
}

Infraestructura

Adaptador

Una vez tenemos implementado el dominio, ahora debemos pasar a los adaptadores de nuestra aplicación. Para ello, debemos implementar los repositorios. En este caso hemos optado por un 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
  })
}

Presentación

Por último, sólo nos queda la parte de la vista, donde representaremos los datos. Llegado este punto podríamos utilizar cualquier librería de renderización de nuestra elección.

En este caso hemos elegido React. A continuación mostraremos un sencillo ejemplo con dos componentes:

// 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>
  )
}

Conclusión

Esta arquitectura de software permite que el dominio de nuestra aplicación sea totalmente agnóstico en cuanto a cómo será la representación de los datos. Además, nos ofrece múltiples ventajas, como por ejemplo:

  • Una mayor separación de responsabilidades.
  • La capacidad de poder testear de manera independiente los distintos componentes de la misma.
  • Menor acoplamiento con las tecnologías usadas.
  • Menor propagación de cambios en caso de necesitar sustituir una tecnología.
  • Adaptación sencilla de la arquitectura a nuevas exigencias sin modificar en exceso el código existente.

Este paradigma también respeta los principios SOLID, especialmente el Single Responsibility (responsabilidad única) y el Dependency Inversion (Inversión de dependencias).

Espero que este artículo haya servido para aclarar conceptos y disipar dudas sobre cómo afecta el uso de esta arquitectura al desarrollo de frontend, más allá de la teoría.

Si quieres profundizar un poco más sobre el tema, te animo a revisar el siguiente repositorio de github, donde he preparado un ejemplo más completo: Hexagonal Architecture Frontend Example.

Referencias

  • Domain Driven Design
  • Hexagonal Architecture, Alistair Cockburn
  • Onion Architecture, Jeffrey Palermo
  • Clean Architecture, Robert C. Martin

Al suscribirte a la newsletter comparto contigo los 10 libros más importantes de programación. Los que sin duda todo dev debería leer al menos una vez...

Quizás te interese

Git Tips: Notas sobre el control de versiones

Git Tips: Notas sobre el control de versiones

Programación funcional en JavaScript. Introducción

Programación funcional en JavaScript. Introducción