Software Crafters® 2025 | Creado con 🖤 para elevar el nivel de la conversación sobre programación en español | Legal
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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:
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.
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) }
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 }) }
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> ) }
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:
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.