Hasta ahora has escrito piezas: componentes, hooks, funciones, sus tests. Cuando un proyecto crece, aparece una pregunta nueva y sorprendentemente difícil: ¿dónde va cada cosa, y qué puede depender de qué? Eso es la arquitectura. No es ceremonia ni diagramas por gusto: es lo que permite que un código —y un equipo— crezcan sin colapsar. Este capítulo es un mapa de las ideas que más se usan en el frontend de 2026. Apóyate en lo que ya viste de clean code en el Nivel 4 (SOLID, y los patrones Factory, Singleton y Adapter): aquí seguimos por ahí.
Organizar por dominio, no por capa técnica#
La primera decisión es cómo agrupas los ficheros. Hay dos formas. Por capa técnica, un cajón por tipo de fichero:
src/
components/ ← TODOS los componentes de la app
hooks/ ← TODOS los hooks
services/ ← TODOS los servicios
types/ ← TODOS los tiposY por dominio (la idea detrás del DDD, Domain-Driven Design), una carpeta por funcionalidad del negocio, cada una con lo suyo dentro:
src/
equipos/ ← la feature "equipos", entera
EquipoCard.tsx
useEquipo.ts
equipo.service.ts
equipo.types.ts
heroes/ ← la feature "héroes", entera
HeroeCard.tsx
useHeroes.ts
heroe.repository.ts
heroe.types.ts
shared/ ← lo de verdad común (Botón, utilidades)¿Y qué más da, si los ficheros son los mismos? Da mucho cuando tocas algo. Con la organización por
capa, cambiar la feature “héroes” te obliga a saltar entre cuatro carpetas distintas y a bucear entre
los ficheros de TODAS las features para encontrar los de héroes. Con la organización por dominio,
abres heroes/ y está todo junto: el cambio queda contenido, y alguien nuevo encuentra las cosas
por lo que hacen, no por su extensión. A escala, esto es la diferencia entre un código navegable y
uno en el que nadie sabe dónde vive nada.
Arquitectura hexagonal: el dominio en el centro#
La segunda idea es sobre dependencias: quién conoce a quién. En una arquitectura hexagonal (también llamada puertos y adaptadores), el núcleo de dominio —tu lógica de negocio— no conoce el mundo exterior: ni React, ni la API, ni la base de datos. Habla con ellos solo a través de puertos (interfaces), y unos adaptadores implementan esos puertos.
┌─────────────── adaptadores (el exterior) ───────────────┐
│ React (UI) API REST localStorage │
└──────┬───────────────────┬──────────────────┬───────────┘
│ implementa │ implementa │ implementa
┌──▼───────────────────▼──────────────────▼──┐
│ PUERTOS (interfaces) │
│ ┌────────────────────────────────┐ │
│ │ NÚCLEO DE DOMINIO (la lógica) │ │
│ │ no sabe nada del exterior │ │
│ └────────────────────────────────┘ │
└────────────────────────────────────────────┘Las dependencias apuntan hacia adentro: el exterior conoce al dominio, nunca al revés. ¿La consecuencia? El dominio se puede probar y reutilizar sin arrastrar la UI ni la red. Es la D de SOLID (depende de abstracciones, no de detalles) llevada a la estructura de la app. Y el puerto más común es el del acceso a datos: el Repository.
Repository: aislar el acceso a datos#
El patrón Repository pone una interfaz entre tu lógica y el origen de los datos. El dominio
depende del contrato (obtenerTodos, guardar…), no de fetch ni de la base de datos.
Mira mejorDelRol: recibe un HeroeRepository y no tiene ni idea de si los datos vienen de memoria,
de una API o de un fichero. ¿Y qué ganas? Dos cosas concretas. Una, puedes cambiar el origen
(de memoria a API real) sin tocar una línea de la lógica. Y dos —la que conecta con todo lo que
acabas de aprender—, puedes testear mejorDelRol pasándole un repo en memoria con datos de
ejemplo, sin red. Una dependencia detrás de una interfaz es una dependencia que puedes sustituir:
eso es exactamente lo que hacía testeable el código en los capítulos anteriores.
Strategy: algoritmos intercambiables#
El patrón Strategy encapsula algoritmos intercambiables tras un tipo común. En lugar de un
if gigante que crece con cada caso, cada algoritmo es una pieza, y una función las recibe y aplica.
Fíjate en que rankear no conoce ningún algoritmo: recibe la estrategia y la aplica. ¿Y qué
gana eso? Que añadir una forma de ordenar nueva (alfabética, por victorias) es escribir una pieza
nueva, no editar rankear. Eso es la O de SOLID: abierto a extensión, cerrado a modificación. Lo
contrario —un if/else if creciente dentro de rankear— te obligaría a tocar código ya probado cada
vez, con el riesgo de romperlo. Lo practicas tú en el ejercicio.
Observer: avisar a quien escucha#
El patrón Observer es un sujeto que avisa a quien se haya suscrito cuando algo cambia. El sujeto no sabe quién escucha: solo emite.
El emisor y los dos observadores están desacoplados: puedes añadir o quitar oyentes sin tocar el emisor. ¿Por qué te importa esto? Porque es la base conceptual de cosas que ya usas o usarás: los stores de estado (en Zustand te suscribes a una porción del estado y tu componente se re-renderiza cuando cambia), los buses de eventos, RxJS. Reconocer el patrón hace que esas herramientas dejen de ser magia: por dentro, son alguien emitiendo y alguien escuchando.
Antipatrones: lo que conviene reconocer#
Tan útil como los patrones es oler los antipatrones. El más común en React es el componente que lo hace todo: pide datos, guarda estado, contiene la lógica de negocio Y pinta, todo mezclado.
// Antipatrón: un componente que mezcla red, lógica y UI. Imposible de testear por partes.
function PanelHeroes() {
const [heroes, setHeroes] = useState([]);
useEffect(() => {
fetch("/api/heroes") // ← red
.then((r) => r.json())
.then((datos) =>
// ← lógica de negocio metida en la UI
setHeroes(datos.filter((h) => h.victorias / h.partidas > 0.5)),
);
}, []);
return <ul>{heroes.map((h) => <li>{h.nombre}</li>)}</ul>; // ← presentación
}¿Y qué tiene de malo, si funciona? Que no puedes probar la regla de negocio (el > 0.5) sin
montar el componente y simular la red, y que el día que esa regla se use en otro sitio, la copiarás.
La versión sana separa: un repository trae los datos, una función de dominio aplica la regla
(testeable sola, sin red), y el componente solo pinta lo que recibe. Cada pieza, una
responsabilidad. Otros antipatrones que reconocerás con el tiempo: pasar props por seis niveles
(prop drilling) cuando tocaría un contexto o un store, y el Singleton como estado global
disfrazado (que ya viste en el Nivel 4).
Micro frontends, de concepto#
Para cerrar, una idea que oirás pero que casi nunca es tu punto de partida: los micro frontends. La idea es dividir un frontend grande en piezas desplegables de forma independiente, cada una propiedad de un equipo distinto (el equipo de “pagos” despliega su trozo sin esperar al de “catálogo”).
El trade-off es honesto: ganas autonomía a escala —equipos que no se bloquean entre sí—, a cambio de complejidad (integrar las piezas, compartir estilos y versiones, posible duplicación). Es una solución para organizaciones grandes con muchos equipos; en un proyecto normal, parte de un monolito bien organizado por dominio (lo de arriba) y plantéate micro frontends solo si el tamaño del equipo lo pide de verdad.
Pruébalo#
Abre el playground de Strategy de arriba y añade una tercera estrategia —por ejemplo, alfabetico,
que ordene por nombre con localeCompare— y úsala con la misma rankear. Comprueba que no
tocas rankear ni las otras estrategias: solo añades. Esa sensación —crecer sin editar lo que ya
funciona— es lo que persigue toda buena arquitectura.
Comprueba lo que sabes#
Pregunta 1 de 5
Organizar el código "por dominio" (DDD) en vez de "por capas técnicas" significa…
Tu turno#
Implementa el patrón Strategy para rankear héroes. Empieza con una estrategia y rankear; luego añade
más sin tocar rankear. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el
salto de un tier al siguiente: de una estrategia a varias, y de varias a estrategias puras que crecen
sin riesgo.
Ejercicio · en esta página
Implementa el patrón Strategy para rankear
Monta el patrón Strategy para ordenar héroes del Team Builder. Define el tipo EstrategiaRanking (una función que ordena héroes), implementa al menos porWinrate y una función rankear(heroes, estrategia) que aplique la estrategia que recibe. La clave: rankear debe servir para CUALQUIER estrategia sin cambiar cuando añadas una nueva.
Paso 1: El patrón con una estrategia
- Defines el tipo EstrategiaRanking: una función (heroes: Heroe[]) => Heroe[].
- Implementas porWinrate (mayor winrate primero) y rankear, que recibe la estrategia y la aplica.
- rankear no contiene la lógica de ordenación: la delega en la estrategia.
Paso 2: Varias estrategias, rankear intacta
- Añades al menos dos estrategias más (por partidas, alfabético) que comparten el tipo EstrategiaRanking.
- rankear NO cambia ni una línea para soportarlas: abierta a extensión, cerrada a modificación.
- Demuestras las tres con la misma rankear, cambiando solo el segundo argumento.
Paso 3: Estrategias puras y crecimiento seguro
- Las estrategias son PURAS: copian con [...heroes] y no mutan el array original (lo compruebas en la salida).
- rankear ofrece una estrategia por defecto sin dejar de estar cerrada a modificación.
- Añades una estrategia nueva y muestras que ni rankear ni las demás se enteran ni cambian.
Ver soluciones
// Tier OK: el patrón Strategy con una estrategia. rankear ya separa el QUÉ del CÓMO.
export interface Heroe {
nombre: string;
partidas: number;
victorias: number;
}
// Una estrategia es una función que recibe héroes y los devuelve ordenados.
export type EstrategiaRanking = (heroes: Heroe[]) => Heroe[];
// Estrategia concreta: por winrate (victorias/partidas), de mayor a menor.
// [...heroes] copia antes de ordenar: sort muta, y no queremos tocar el original.
export const porWinrate: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => b.victorias / b.partidas - a.victorias / a.partidas);
// rankear no sabe NADA del algoritmo: recibe la estrategia y la aplica.
export function rankear(
heroes: Heroe[],
estrategia: EstrategiaRanking,
): Heroe[] {
return estrategia(heroes);
}
// Demo: el ranking por winrate.
const heroes: Heroe[] = [
{ nombre: "Genji", partidas: 20, victorias: 13 },
{ nombre: "Mercy", partidas: 30, victorias: 21 },
{ nombre: "Reinhardt", partidas: 12, victorias: 9 },
];
console.log(
"Por winrate: " + rankear(heroes, porWinrate).map((h) => h.nombre).join(", "),
); Por qué este nivel
- Monta el patrón: una estrategia es una función, y rankear la recibe y la aplica. Ya separa el QUÉ (ordenar héroes) del CÓMO concreto (por winrate).
- Su límite: con una sola estrategia no se ve la ventaja real. El patrón brilla cuando hay varias y se pueden intercambiar sin tocar rankear.
// Tier mejor: varias estrategias que comparten el tipo, y rankear NO cambia ni una línea.
export interface Heroe {
nombre: string;
partidas: number;
victorias: number;
}
export type EstrategiaRanking = (heroes: Heroe[]) => Heroe[];
// Por winrate, de mayor a menor (copia antes de ordenar para no mutar).
export const porWinrate: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => b.victorias / b.partidas - a.victorias / a.partidas);
// Por número de partidas jugadas, de más a menos.
export const porPartidas: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => b.partidas - a.partidas);
// Por nombre, alfabético.
export const alfabetico: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => a.nombre.localeCompare(b.nombre));
// La MISMA rankear de antes: no ha cambiado al añadir estrategias (abierta a extensión).
export function rankear(
heroes: Heroe[],
estrategia: EstrategiaRanking,
): Heroe[] {
return estrategia(heroes);
}
const heroes: Heroe[] = [
{ nombre: "Genji", partidas: 20, victorias: 13 },
{ nombre: "Mercy", partidas: 30, victorias: 21 },
{ nombre: "Reinhardt", partidas: 12, victorias: 9 },
];
// La misma función sirve para cualquier estrategia: solo cambia el segundo argumento.
console.log("Por winrate: " + rankear(heroes, porWinrate).map((h) => h.nombre).join(", "));
console.log("Por partidas: " + rankear(heroes, porPartidas).map((h) => h.nombre).join(", "));
console.log("Alfabético: " + rankear(heroes, alfabetico).map((h) => h.nombre).join(", ")); Por qué es mejor que el anterior
- Añade más estrategias compartiendo el tipo EstrategiaRanking. Lo clave: rankear no cambió ni una línea para soportarlas —está abierta a extensión, cerrada a modificación (la O de SOLID)—.
- La misma rankear sirve para las tres: solo cambia el segundo argumento. Ahí se ve por qué el patrón paga.
// Tier excelente: estrategias puras, una por defecto, y crecimiento sin tocar lo existente.
export interface Heroe {
nombre: string;
partidas: number;
victorias: number;
}
// El contrato común de toda estrategia: misma firma, intercambiables entre sí.
export type EstrategiaRanking = (heroes: Heroe[]) => Heroe[];
// Cada estrategia es PURA: copia con [...heroes] y devuelve un array nuevo, sin mutar.
export const porWinrate: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => b.victorias / b.partidas - a.victorias / a.partidas);
export const porPartidas: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => b.partidas - a.partidas);
export const alfabetico: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => a.nombre.localeCompare(b.nombre));
// rankear admite una estrategia por defecto (winrate), pero sigue cerrada a modificación:
// añadir estrategias nuevas no la toca.
export function rankear(
heroes: Heroe[],
estrategia: EstrategiaRanking = porWinrate,
): Heroe[] {
return estrategia(heroes);
}
const heroes: Heroe[] = [
{ nombre: "Genji", partidas: 20, victorias: 13 },
{ nombre: "Mercy", partidas: 30, victorias: 21 },
{ nombre: "Reinhardt", partidas: 12, victorias: 9 },
];
// Una estrategia NUEVA escrita aquí mismo: rankear y las demás no se enteran ni cambian.
const porVictorias: EstrategiaRanking = (heroes) =>
[...heroes].sort((a, b) => b.victorias - a.victorias);
// Sin segundo argumento usa la estrategia por defecto.
console.log("Por defecto (winrate): " + rankear(heroes).map((h) => h.nombre).join(", "));
console.log("Por victorias: " + rankear(heroes, porVictorias).map((h) => h.nombre).join(", "));
// El original nunca se tocó: las estrategias son puras.
console.log("Original intacto: " + heroes.map((h) => h.nombre).join(", ")); Por qué es mejor que el anterior
- Las estrategias son puras: devuelven un array nuevo con [...heroes], no mutan el original (y lo demuestra en la salida). Una función pura es más fácil de razonar y de testear.
- rankear tiene una estrategia por defecto pero sigue cerrada a modificación. Y se escribe una estrategia NUEVA aquí mismo sin tocar rankear ni las demás: crecer sin riesgo de romper lo que ya funciona es justo lo que el patrón te compra.