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:
- Dominio — lógica de negocio pura, sin dependencias de frameworks
- Aplicación — casos de uso que orquestan el dominio
- 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.