DEV Community

Cover image for Arquitectura Hexagonal con React y TypeScript: guía paso a paso (enfoque pragmático)
John Serrano (DEV)
John Serrano (DEV)

Posted on • Originally published at johnserrano.co

Arquitectura Hexagonal con React y TypeScript: guía paso a paso (enfoque pragmático)

Inspirado por enfoques pragmáticos: menos capas, más claridad. Aquí no seguimos el “purismo” hexagonal; tomamos lo que aporta valor en UI y evitamos sobre-ingeniería.

Objetivo: que alguien que está aprendiendo pueda aplicarlo en 1 tarde.

Historia de usuario

“Como usuario quiero entrar a la Home y ver un listado de productos con:

  • nombre
  • imagen
  • precio

Tenemos una API que expone /products.”

Pasemos a código.

Capas (lo justo y necesario)

  • Dominio: tipos y reglas (casi siempre son simples en UI).
  • Aplicación: casos de uso (orquestan llamadas).
  • Infraestructura: HTTP/SDKs, errores, auth.
  • UI: componentes React.

Nota importante:
Si el proyecto crece o necesitas intercambiar fuentes de datos, añades un repositorio como puerto. Más abajo verás un ejemplo de cómo sería el funcionamiento y por qué conviene hacerlo en ese momento (no antes).

Dominio: ¿tipo o clase?

Regla práctica: empieza con tipos. Si luego necesitas comportamiento/validación compleja, refactorizas a clase.

// src/domain/Product.ts
export type Product = {
  name: string;
  image: string;
  price: number;
};
Enter fullscreen mode Exit fullscreen mode

Infra: un cliente HTTP pequeño (adapter)

Aísla fetch/axios en un único punto. Centralizas errores y auth. Si cambias fetch por axios, solo tocas aquí.

// src/infrastructure/APIClient.ts
export class APIClient {
  constructor(private readonly baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {}

  async get<T>(path: string): Promise<T> {
    const res = await this.fetchImpl(`${this.baseUrl}${path}`);
    if (!res.ok) {
      // Manejo de errores muy simple para el ejemplo
      const msg = `HTTP ${res.status} al GET ${path}`;
      throw new Error(msg);
    }
    return res.json() as Promise<T>;
  }
}
Enter fullscreen mode Exit fullscreen mode

Aplicación: caso de uso mínimo

Empieza simple: sin repositorio. Orquesta la llamada y devuelve dominio directamente.

// src/application/ListProducts.ts
import type { Product } from "../domain/Product";
import { APIClient } from "../infrastructure/APIClient";

export class ListProducts {
  constructor(private readonly api: APIClient) {}

  async execute(): Promise<Product[]> {
    // Aquí podrías mapear/filtrar/ordenar si hace falta
    return this.api.get<Product[]>("/products");
  }
}
Enter fullscreen mode Exit fullscreen mode

¿Qué es un DTO y cuándo usarlo?

DTO (Data Transfer Object) es un objeto “de transferencia de datos”. Su objetivo es:

  • Formatear o adaptar datos para una capa específica (p. ej., la UI).
  • Ocultar detalles del dominio que no quieres exponer.
  • Combinar/dividir datos que vienen de distintas fuentes.

Cuándo NO usar DTO:

  • Si la UI necesita exactamente los mismos campos del dominio, añadir DTO genera ruido y “capas por capas”.

Cuándo SÍ usar DTO:

  • Si necesitas cambiar nombres/formatos (p. ej., price en centavos → cadena “$12.99”).
  • Si quieres estabilizar la UI frente a cambios del dominio o de la API.
  • Si debes ocultar campos sensibles.

Ejemplo con DTO y mapeo en el caso de uso:

// src/application/ProductDTO.ts
export type ProductDTO = {
  name: string;
  imageUrl: string;   // renombrado para la UI
  displayPrice: string; // precio formateado
};
Enter fullscreen mode Exit fullscreen mode
// src/application/ListProductsWithDTO.ts
import type { Product } from "../domain/Product";
import type { ProductDTO } from "./ProductDTO";
import { APIClient } from "../infrastructure/APIClient";

export class ListProductsWithDTO {
  constructor(private readonly api: APIClient) {}

  async execute(): Promise<ProductDTO[]> {
    const products = await this.api.get<Product[]>("/products");
    return products.map(p => ({
      name: p.name,
      imageUrl: p.image,
      displayPrice: `$${p.price.toFixed(2)}`,
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

Idea práctica:

  • Empieza sin DTO.
  • Si la UI empieza a necesitar transformaciones repetidas, crea un DTO y mueve la transformación al caso de uso.

UI: un componente que llama al caso de uso

Mantén la UI enfocada en presentar datos y gestionar estado/errores.

// src/ui/HomePage.tsx
import React, { useEffect, useState } from "react";
import type { Product } from "../domain/Product";
import { APIClient } from "../infrastructure/APIClient";
import { ListProducts } from "../application/ListProducts";
// Si prefieres DTO, importa ListProductsWithDTO y ProductDTO en su lugar.

const api = new APIClient(import.meta.env.VITE_API_URL ?? "https://api.example.com");
const listProducts = new ListProducts(api);

export const HomePage: React.FC = () => {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        const data = await listProducts.execute();
        if (mounted) setProducts(data);
      } catch (e: any) {
        if (mounted) setError(e?.message ?? "No se pudo cargar productos");
      } finally {
        if (mounted) setLoading(false);
      }
    })();
    return () => { mounted = false; };
  }, []);

  if (loading) return <p>Cargando…</p>;
  if (error) return <p style={{ color: "crimson" }}>{error}</p>;

  return (
    <div>
      <h1>Productos</h1>
      <ul>
        {products.map((p, index) => (
          <li key={p.name} data-testid={`product-id-${index}`}>
            <img src={p.image} alt={p.name} width={80} height={80} />
            <h3>{p.name}</h3>
            <p>Precio: ${p.price}</p>
          </li>
        ))}
      </ul>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

¿Cuándo añadir repositorios (puertos) “hexagonales”?

Añádelos cuando:

  • necesites más de una fuente de datos (REST, GraphQL, IndexedDB, memoria),
  • quieras desacoplarte fuertemente del backend,
  • o quieras tests de aplicación ultra rápidos sin MSW.

Recuerda la nota de arriba: “Si el proyecto crece o necesitas intercambiar fuentes de datos, añades un repositorio como puerto.” Aquí abajo verás un ejemplo de cómo sería el funcionamiento.

Puerto de repositorio (dominio)

// src/domain/ProductRepository.ts
import type { Product } from "./Product";

export interface ProductRepository {
  getProducts(): Promise<Product[]>;
}
Enter fullscreen mode Exit fullscreen mode

Implementación HTTP del repositorio (infra)

// src/infrastructure/HttpProductRepository.ts
import type { Product } from "../domain/Product";
import type { ProductRepository } from "../domain/ProductRepository";
import { APIClient } from "./APIClient";

export class HttpProductRepository implements ProductRepository {
  constructor(private readonly api: APIClient) {}
  getProducts(): Promise<Product[]> {
    return this.api.get<Product[]>("/products");
  }
}
Enter fullscreen mode Exit fullscreen mode

Caso de uso usando el puerto (aplicación)

// src/application/ListProductsWithRepo.ts
import type { Product } from "../domain/Product";
import type { ProductRepository } from "../domain/ProductRepository";

export class ListProductsWithRepo {
  constructor(private readonly repo: ProductRepository) {}
  execute(): Promise<Product[]> {
    // Si solo delega, cuestiónate si esta capa aporta valor (Middle Man).
    return this.repo.getProducts();
  }
}
Enter fullscreen mode Exit fullscreen mode

Composición (decides la fuente en un único lugar)

// src/app/composition.ts
import { APIClient } from "../infrastructure/APIClient";
import { HttpProductRepository } from "../infrastructure/HttpProductRepository";
import { ListProductsWithRepo } from "../application/ListProductsWithRepo";

const api = new APIClient(import.meta.env.VITE_API_URL ?? "https://api.example.com");
const repo = new HttpProductRepository(api);

// Si quisieras memoria, bastaría con cambiar la implementación del puerto:
// const repo = new InMemoryProductRepository([{ name: "A", image: "/a.png", price: 10 }]);

export const listProducts = new ListProductsWithRepo(repo);
Enter fullscreen mode Exit fullscreen mode

Cómo funciona en la práctica:

  • La UI usa listProducts.execute().
  • Si mañana cambias de REST a GraphQL o a IndexedDB, implementas otro repositorio y no tocas ni UI ni casos de uso (salvo wiring).

(Mis) Problemas con ejemplos

No hay suficiente complejidad de dominio

Si tu caso es casi-CRUD y el reto principal está en la UX/estado, no en reglas de negocio, muchas capas añaden fricción sin beneficio.

Ejemplo suficiente para empezar:

// src/application/ListProducts.ts
import type { Product } from "../domain/Product";
import { APIClient } from "../infrastructure/APIClient";

export class ListProducts {
  constructor(private readonly api: APIClient) {}
  execute(): Promise<Product[]> {
    return this.api.get<Product[]>("/products");
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Simple, testeable (stub de APIClient o MSW) y sin “capas por capas”.
  • Cuando aparezca complejidad real (paginación, filtros, cacheo, merge de fuentes), puedes introducir repositorios y casos de uso más ricos.

Interfaces inútiles

Crear interfaces “por si acaso” suele ser ruido. La regla práctica:

  • Crea una interfaz si y solo si:
    • tendrás 2+ implementaciones, o
    • buscas desacoplarte del backend/cliente HTTP.

Anti‑ejemplo con una interfaz sin valor:

// src/domain/UselessProductRepo.ts
import type { Product } from "./Product";

export interface ProductRepository {
  getProducts(): Promise<Product[]>;
}

// Única implementación y uso en un solo lugar -> poco valor
export class HttpProductRepository implements ProductRepository {
  getProducts(): Promise<Product[]> {
    return fetch("/products").then(r => r.json());
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternativa más simple:

// src/application/ListProductsSimple.ts
import type { Product } from "../domain/Product";
import { APIClient } from "../infrastructure/APIClient";

export class ListProductsSimple {
  constructor(private readonly api: APIClient) {}
  execute(): Promise<Product[]> {
    return this.api.get<Product[]>("/products");
  }
}
Enter fullscreen mode Exit fullscreen mode

Capas estrictas: tipos de retorno y “Middle Man”

  • DTO vs Dominio: si Product y ProductDTO tienen los mismos campos, el DTO no aporta valor.
  • “Middle Man”: un caso de uso que solo delega al repositorio sin añadir semántica es un code smell.

Anti‑ejemplo (Middle Man):

// src/application/ListProductsMiddleMan.ts
import type { Product } from "../domain/Product";
import type { ProductRepository } from "../domain/ProductRepository";

export class ListProductsMiddleMan {
  constructor(private readonly repo: ProductRepository) {}
  execute(): Promise<Product[]> {
    // Indirección sin abstracción: no aporta nada.
    return this.repo.getProducts();
  }
}
Enter fullscreen mode Exit fullscreen mode

Cómo aportar valor real en la capa de aplicación:

// src/application/ListProductsEnriched.ts
import type { Product } from "../domain/Product";
import type { ProductRepository } from "../domain/ProductRepository";

export class ListProductsEnriched {
  constructor(private readonly repo: ProductRepository) {}
  async execute(query?: { minPrice?: number; sort?: "asc" | "desc" }): Promise<Product[]> {
    let products = await this.repo.getProducts();

    if (query?.minPrice != null) {
      products = products.filter(p => p.price >= query.minPrice!);
    }
    if (query?.sort) {
      products = products.sort((a, b) => query.sort === "asc" ? a.price - b.price : b.price - a.price);
    }
    return products;
  }
}
Enter fullscreen mode Exit fullscreen mode

DTO solo cuando transforme algo:

// src/application/ListProductsToDTO.ts
import type { Product } from "../domain/Product";
import type { ProductDTO } from "./ProductDTO";
import type { ProductRepository } from "../domain/ProductRepository";

export class ListProductsToDTO {
  constructor(private readonly repo: ProductRepository) {}
  async execute(): Promise<ProductDTO[]> {
    const products = await this.repo.getProducts();
    return products.map(p => ({ name: p.name, imageUrl: p.image, displayPrice: `$${p.price.toFixed(2)}` }));
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros de “saltar capas”:

  • Menos indirection.
  • Código más fácil de seguir. Contras:
  • Requiere criterio para no perder consistencia.

Repositorios en frontend: cuándo sobran y cómo simplificar

Muchas veces:

  • hay una sola fuente (tu API),
  • queries simples,
  • y el repositorio se usa en un solo punto.

En esos casos, puedes ir directo con un APIClient y mantener testabilidad.

Anti‑ejemplo importando axios en la aplicación:

// src/application/ListProductsAxiosInline.ts
import type { Product } from "../domain/Product";
import axios from "axios";

export class ListProductsAxiosInline {
  async execute(): Promise<Product[]> {
    const res = await axios.get<Product[]>(`https://.../products`);
    return res.data;
  }
}
// Funciona, pero mezclas infraestructura (axios) en aplicación.
Enter fullscreen mode Exit fullscreen mode

Mejor: encapsula axios en un adapter y centraliza errores.

// src/infrastructure/APIClientAxios.ts
import axios, { AxiosError } from "axios";

export class NotFoundError extends Error {}
export class ApiError extends Error {}

export class APIClientAxios {
  constructor(private readonly baseUrl: string) {}

  async get<T>(path: string): Promise<T> {
    try {
      const res = await axios.get<T>(`${this.baseUrl}${path}`);
      return res.data;
    } catch (e) {
      const err = e as AxiosError;
      if (err.response?.status === 404) throw new NotFoundError("Recurso no encontrado");
      throw new ApiError(err.message);
    }
  }

  // more methods: post, put, delete...
}
Enter fullscreen mode Exit fullscreen mode

Caso de uso usando el adapter:

// src/application/ListProductsWithAPIClientAxios.ts
import type { Product } from "../domain/Product";
import { APIClientAxios } from "../infrastructure/APIClientAxios";

export class ListProductsWithAPIClientAxios {
  constructor(private readonly api: APIClientAxios) {}
  execute(): Promise<Product[]> {
    return this.api.get<Product[]>("/products");
  }
}
Enter fullscreen mode Exit fullscreen mode

Ir “al límite”: instanciar internamente el cliente.

// src/application/ProductListSelfContained.ts
import type { Product } from "../domain/Product";
import { APIClientAxios } from "../infrastructure/APIClientAxios";

export class ProductListSelfContained {
  private api = new APIClientAxios(import.meta.env.VITE_API_URL ?? "https://api.example.com");

  execute(): Promise<Product[]> {
    return this.api.get<Product[]>("/products");
  }
}
// Ojo: dependencia oculta -> más difícil de testear y de cambiar en composición.
Enter fullscreen mode Exit fullscreen mode

Testing cuando no hay repos:

  • Opción A (recomendada): tests sociables con MSW sobre la UI (sección “Testing” al final).
  • Opción B: stub del adapter en tests de aplicación.
// src/application/__tests__/ListProductsWithAPIClientAxios.spec.ts
import type { Product } from "../../domain/Product";
import { ListProductsWithAPIClientAxios } from "../ListProductsWithAPIClientAxios";

class FakeAPI {
  constructor(private readonly data: Product[]) {}
  async get<T>(_path: string): Promise<T> {
    return this.data as unknown as T;
  }
}

test("devuelve productos desde el adapter", async () => {
  const fake = new FakeAPI([{ name: "A", image: "/a.png", price: 10 }]);
  const usecase = new ListProductsWithAPIClientAxios(fake as any);
  await expect(usecase.execute()).resolves.toHaveLength(1);
});
Enter fullscreen mode Exit fullscreen mode

Si más adelante necesitas intercambiar fuentes (REST/IndexedDB/memoria), entonces sí: introduce el puerto ProductRepository y obtendrás tests de aplicación ultrarrápidos con una implementación en memoria.

Testing

Aplicando hexagonal junto al patrón repositorio te permite crear tests unitarios que cumplan con el acrónimo FIRST:

  • Fast: rápidos para obtener feedback veloz.
  • Isolated: aislados de DB/red.
  • Repeatable: mismo resultado siempre.
  • Self-validating: se validan solos (asserts claros).
  • Timely: idealmente antes de desarrollar (TDD).

Con el enfoque simplificado propuesto, así cumplo gran parte:

Tipos de tests (filosofía):

  • “Write tests. Not too many. Mostly integration” (Kent C. Dodds).
  • Yo lo adapto a: “Write tests, mostly sociable tests”.
    • En esta historia de usuario, prefiero tests sociables de UI con un servidor HTTP simulado que devuelva respuestas predefinidas.

Trade-off:

  • Al levantar un servidor HTTP simulado, quizá pierdas algo de “F” (velocidad). Lo asumo porque el test es más representativo.

Herramienta:

  • MSW (Mock Service Worker): mokea a nivel de red, no tu código.

Cómo testear esta funcionalidad:

  • Omito unitarios “puros” de aplicación si no hay lógica (evito tests que solo replican una llamada).
  • Escribo tests de UI que cubren el flujo principal y el de error, falseando la API con MSW.
  • Para mayor seguridad antes de desplegar: tests E2E happy path en pre con Playwright.

Ejemplo con MSW:

// src/ui/HomePage.test.tsx
import { render, screen } from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import React from "react";
import { HomePage } from "./HomePage";

const apiUrl = "https://api.example.com";
const server = setupServer(
  rest.get(`${apiUrl}/products`, (_req, res, ctx) =>
    res(ctx.json([
      { name: "Product Zero", image: "/img0.png", price: 10 },
      { name: "Product One", image: "/img1.png", price: 15 },
    ]))
  )
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Ajusta según tu runner (Vitest/Jest). Con Vitest podrías hacer:
// vi.stubEnv("VITE_API_URL", apiUrl);

test("muestra productos al cargar", async () => {
  render(<HomePage />);
  const items = await screen.findAllByTestId(/product-id-\d+/);
  expect(items).toHaveLength(2);
  expect(screen.getByText("Product Zero")).toBeInTheDocument();
  expect(screen.getByText("Product One")).toBeInTheDocument();
});

test("muestra error si la API falla", async () => {
  server.use(rest.get(`${apiUrl}/products`, (_req, res, ctx) => res(ctx.status(500))));
  render(<HomePage />);
  const error = await screen.findByText(/No se pudo cargar productos|HTTP 500/);
  expect(error).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

E2E con Playwright (idea, sin código extenso):

  • Crea un test “happy path” que:
    • visita la Home deployada en pre,
    • espera ver el listado real,
    • verifica al menos 1–2 productos.
  • Úsalo como smoke test en cada release.

Screaming Architecture (mini abrebocas)

Que el repo “grite” el dominio. En vez de “components/services/utils”, prefiere por feature:

  • src/domain/Product.ts
  • src/application/ListProducts.ts
  • src/infrastructure/APIClient.ts
  • src/ui/HomePage.tsx

Cuando la app crezca, separa por features (products, cart, auth) manteniendo esta idea.

En próximos artículos voy a traer uno sobre Screaming Architecture.

Checklist para aplicarlo hoy

  • Define tipos de dominio (Product).
  • Crea un APIClient pequeño para HTTP.
  • Escribe 1 caso de uso (ListProducts) que use el APIClient.
  • Implementa la HomePage que llama al caso de uso.
  • Solo si lo necesitas, añade un ProductRepository y su implementación HTTP (ver ejemplo de funcionamiento arriba).
  • Añade tests (lee la sección final “Testing” para decidir qué y cómo probar).

Conclusión

  • Ponemos en el centro el dominio (si lo hay), pero priorizamos simplicidad: no siempre necesitas capas intermedias entre lógica de aplicación, fuente de datos y UI.
  • Testea donde más valor aporta: UI sociable con MSW y un E2E de humo con Playwright. Si el dominio crece, añade repositorios y tests unitarios rápidos en memoria.

Glosario (términos clave)

Middle Man (code smell)

  • Qué es: un “intermediario” que solo pasa la llamada a otra capa/objeto sin añadir valor (sin validar, transformar, decidir, ni encapsular reglas). Es “indirección sin abstracción”.
  • Por qué es problema: añade complejidad y archivos extra, dificulta navegar el código y no aporta semántica.
  • Cómo detectarlo:
    • La clase/método solo delega 1:1 a otra llamada.
    • No hay lógica de negocio, ni transformación de datos, ni control de errores propio.
    • Si lo eliminas y llamas directamente a la dependencia, nada cambia (salvo menos líneas).
  • Ejemplo:
// src/application/ListProductsMiddleMan.ts
import type { Product } from "../domain/Product";
import type { ProductRepository } from "../domain/ProductRepository";

export class ListProductsMiddleMan {
  constructor(private readonly repo: ProductRepository) {}
  execute(): Promise<Product[]> {
    return this.repo.getProducts();
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Alternativas:
    • Eliminar la capa y usar directamente la dependencia.
    • O bien justificarla añadiendo valor (filtrados, orden, mapeos, políticas de reintento, validaciones, etc.).

E2E de humo (smoke test)

  • Qué es: un test end-to-end mínimo y rápido que verifica el camino feliz crítico (la app arranca y la funcionalidad principal no “echa humo” tras un deploy).
  • Objetivo: detectar roturas graves en producción o pre-producción con el menor coste.
  • Alcance típico:
    • Cargar la Home sin errores.
    • Ver que se lista al menos un producto.
    • Opcional: una interacción clave.
  • Cuándo ejecutarlo: en cada build de pre o antes de un deploy (pipeline de CD).
  • Relación con otros tests:
    • Complementa a los tests de UI con MSW (rápidos y representativos en local/CI).
    • No reemplaza tests unitarios donde haya lógica compleja: si el dominio crece, añádelos.

Te invito a visitar mi blog, donde encontrarás más contenido sobre JavaScript, React, CSS, IA, buenas prácticas y mucho más. 👉 johnserrano.co/blog ¡No te lo pierdas!

Gracias por leer. ❤️

Top comments (1)

Collapse
 
anik_sikder_313 profile image
Anik Sikder

This is one of the most pragmatic takes on hexagonal architecture I’ve seen applied to front-end development. The balance between structure and simplicity is spot-on especially the advice to avoid premature layering when the domain is still straightforward. I appreciate how the guide encourages starting with types, delaying DTOs until they add real value, and keeping the UI focused on presentation. It’s a great reminder that architecture should serve clarity and maintainability, not ceremony. Perfect for devs who want to build clean, scalable apps without falling into over-engineering.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.