← Todos los artículos EN

Arquitectura Hexagonal en TypeScript + React: una guía práctica

Cómo aplicar Domain-Driven Design y Arquitectura Hexagonal en un proyecto real con React y TypeScript — puertos, adaptadores, casos de uso y límites claros.

Después de años construyendo aplicaciones React a gran escala, he visto el mismo patrón repetirse: el proyecto arranca limpio, se acumulan features, y antes de que te des cuenta el codebase es un laberinto de llamadas a la API dentro de componentes, lógica de negocio dispersa entre hooks, y preocupaciones de UI mezcladas con el fetching de datos. La aplicación funciona, pero es frágil — un refactor en cualquier punto arriesga romper algo en otro lugar.

La Arquitectura Hexagonal (también llamada Puertos y Adaptadores) combinada con Domain-Driven Design (DDD) es el enfoque al que recurro cuando un proyecto necesita sobrevivir a su propio crecimiento. Esta guía es la versión práctica — no teoría, sino código real que puedes usar hoy.

¿Qué problema estamos resolviendo?

Antes de la solución, el problema. Un codebase React típico puede verse así:

// UserProfile.tsx — haciendo demasiado
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        // Lógica de negocio mezclada con la UI
        if (data.role === 'admin' && data.plan === 'free') {
          data.permissions = ['read'];
        }
        setUser(data);
      });
  }, [userId]);

  return <div>{user?.name}</div>;
}

Este componente conoce de HTTP, JSON parsing, reglas de permisos y renderizado — cuatro responsabilidades distintas en un solo lugar. Testear cualquiera de ellas requiere mockear todas las demás.

La idea central: separar responsabilidades en capas

La Arquitectura Hexagonal define tres anillos:

  1. Dominio — lógica de negocio pura, sin dependencias de frameworks
  2. Aplicación — casos de uso que orquestan el dominio
  3. Infraestructura — el mundo exterior: HTTP, localStorage, APIs, el propio React

La regla es simple: las dependencias solo apuntan hacia adentro. Infraestructura depende de Aplicación. Aplicación depende de Dominio. Dominio no depende de nada.

Estructura de carpetas

src/
├── domain/
│   ├── user/
│   │   ├── User.ts          ← entidad
│   │   ├── UserRepository.ts ← puerto (interfaz)
│   │   └── Permission.ts    ← value object
├── application/
│   └── user/
│       └── GetUserProfile.ts ← caso de uso
├── infrastructure/
│   ├── http/
│   │   └── HttpUserRepository.ts ← adaptador
│   └── storage/
│       └── LocalStorageUserRepository.ts ← adaptador
└── ui/
    └── UserProfile.tsx      ← componente React

Paso 1: Definir el Dominio

El dominio contiene las reglas de negocio y nada más. Sin React, sin fetch, sin axios.

// domain/user/User.ts
export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  plan: 'free' | 'pro' | 'enterprise';
}

// domain/user/Permission.ts
export type Permission = 'read' | 'write' | 'delete' | 'admin';

export function resolvePermissions(user: User): Permission[] {
  if (user.role === 'admin' && user.plan === 'enterprise') {
    return ['read', 'write', 'delete', 'admin'];
  }
  if (user.role === 'admin') {
    return ['read', 'write', 'delete'];
  }
  if (user.role === 'editor') {
    return ['read', 'write'];
  }
  return ['read'];
}

Esta lógica de permisos es TypeScript puro. Puedes testearla con una simple llamada a función — sin mocks.

Paso 2: Definir el Puerto

Un puerto es una interfaz que describe qué necesita la aplicación del mundo exterior. Vive en la capa de dominio o aplicación.

// domain/user/UserRepository.ts
import type { User } from './User';

export interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

La capa de aplicación no sabe si los datos vienen de una API REST, GraphQL, o un JSON local. Solo llama a la interfaz.

Paso 3: Escribir el Caso de Uso

Un caso de uso orquesta el dominio para satisfacer una necesidad específica. Recibe el repositorio como argumento del constructor (inyección de dependencias).

// application/user/GetUserProfile.ts
import type { UserRepository } from '../../domain/user/UserRepository';
import type { User } from '../../domain/user/User';
import type { Permission } from '../../domain/user/Permission';
import { resolvePermissions } from '../../domain/user/Permission';

export interface UserProfileResult {
  user: User;
  permissions: Permission[];
}

export class GetUserProfile {
  constructor(private readonly userRepository: UserRepository) {}

  async execute(userId: string): Promise<UserProfileResult | null> {
    const user = await this.userRepository.findById(userId);
    if (!user) return null;

    const permissions = resolvePermissions(user);
    return { user, permissions };
  }
}

El caso de uso también es trivialmente testeable. Pasas un repositorio falso:

const fakeRepo = {
  findById: async (id: string) => ({
    id, name: 'Esteban', email: 'e@test.com', role: 'admin' as const, plan: 'free' as const
  }),
  save: async () => {},
};

test('retorna el usuario con permisos resueltos', async () => {
  const useCase = new GetUserProfile(fakeRepo);
  const result = await useCase.execute('1');

  expect(result?.user.name).toBe('Esteban');
  expect(result?.permissions).toContain('write');
  expect(result?.permissions).not.toContain('admin');
});

Paso 4: Implementar el Adaptador

El adaptador es la implementación concreta de un puerto. Aquí viven fetch, axios, o cualquier API externa.

// infrastructure/http/HttpUserRepository.ts
import type { UserRepository } from '../../domain/user/UserRepository';
import type { User } from '../../domain/user/User';

export class HttpUserRepository implements UserRepository {
  constructor(private readonly baseUrl: string) {}

  async findById(id: string): Promise<User | null> {
    const response = await fetch(`${this.baseUrl}/users/${id}`);
    if (!response.ok) return null;
    return response.json() as Promise<User>;
  }

  async save(user: User): Promise<void> {
    await fetch(`${this.baseUrl}/users/${user.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user),
    });
  }
}

Intercambiar implementaciones es un cambio de una línea — el resto de la aplicación nunca se entera.

Paso 5: Conectar todo con un custom hook de React

La capa UI también es un adaptador. Un custom hook conecta el ciclo de vida de React con tu caso de uso.

// ui/hooks/useUserProfile.ts
import { useState, useEffect } from 'react';
import { GetUserProfile } from '../../application/user/GetUserProfile';
import { HttpUserRepository } from '../../infrastructure/http/HttpUserRepository';

const userRepository = new HttpUserRepository('https://api.esaraviam.dev');
const getUserProfile = new GetUserProfile(userRepository);

export function useUserProfile(userId: string) {
  const [result, setResult] = useState<UserProfileResult | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    getUserProfile.execute(userId)
      .then(setResult)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  return { result, loading, error };
}

El componente queda puramente presentacional:

export function UserProfile({ userId }: { userId: string }) {
  const { result, loading, error } = useUserProfile(userId);

  if (loading) return <div>Cargando...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!result) return <div>Usuario no encontrado</div>;

  return (
    <div>
      <h2>{result.user.name}</h2>
      <ul>{result.permissions.map(p => <li key={p}>{p}</li>)}</ul>
    </div>
  );
}

Conclusiones

  • Dominio = TypeScript puro. Sin React, sin fetch. Solo funciones y tipos.
  • Puertos = interfaces. Describen qué necesitas, no cómo se hace.
  • Adaptadores = implementaciones. HTTP, localStorage, en memoria — todos implementan el mismo puerto.
  • Casos de uso = orquestación. Coordinan la lógica del dominio para resolver una necesidad de negocio.
  • React = el adaptador más externo. Los componentes y hooks consumen casos de uso, nunca repositorios directamente.

Este patrón es el que describo en mi artículo más leído en dev.to. Después de años aplicándolo en producción — desde sistemas bancarios en Scotiabank CCAU hasta proyectos mineros para BHP — es el patrón en el que más confío cuando el codebase necesita escalar con el equipo.