B / Blog sobre React

Cómo estructuro una aplicación React grande

Una guía de arquitectura con criterio propio para organizar funcionalidades, lógica de dominio, clientes API, componentes compartidos, API de módulos, dependencias y pruebas en una aplicación React de producción.

Enfoque
Arquitectura React
Publicado
Tiempo estimado de lectura
12 min de lectura
  • Arquitectura React
  • Organización por funcionalidades
  • Límites de módulos
  • Pruebas frontend

Una aplicación React grande no se vuelve difícil porque tenga demasiados archivos. Se vuelve difícil cuando un cambio no tiene un responsable evidente, las dependencias pueden apuntar en cualquier dirección y cada abstracción útil acaba convirtiéndose lentamente en global.

Mi estructura preferida se basa en funcionalidades, pero las carpetas no son la arquitectura. La arquitectura es el conjunto de decisiones que hay detrás: qué asume un módulo, qué puede importar, qué expone y cómo pasa el código de local a compartido a medida que evoluciona el producto.

Optimizo para una pregunta práctica: cuando un desarrollador recibe un cambio, ¿puede encontrar su lugar natural, entender sus dependencias y modificarlo sin aprender toda la aplicación?

Esta guía presenta el punto de partida que utilizo para aplicaciones React de producción. Tiene criterio propio, pero no es una plantilla que deba copiarse mecánicamente. Los límites importan más que los nombres exactos.

B-01

Por qué las carpetas genéricas se convierten en cajones de sastre

Muchas aplicaciones React comienzan con una estructura como esta:

src/
|-- components/
|-- hooks/
|-- services/
|-- types/
|-- utils/
`-- pages/

Parece organizada porque cada archivo tiene una categoría técnica. El problema es que esas categorías no dicen nada sobre la responsabilidad dentro del producto.

Una tabla de historial de pedidos, un diálogo de checkout y la navegación principal son componentes. Un formateador de moneda, una comprobación de permisos y un cálculo del checkout pueden llamarse utilidades. Un hook de obtención de datos y otro de atajos de teclado son hooks, aunque pertenezcan a partes totalmente distintas del producto.

A medida que la aplicación crece, estas carpetas desarrollan fallos previsibles:

  • Los desarrolladores deben buscar en varios directorios globales para entender una funcionalidad.
  • Se acumulan nombres similares porque la carpeta ya no aporta contexto de negocio.
  • Los detalles privados de implementación se convierten en importaciones cómodas para funcionalidades no relacionadas.
  • Cambios que deberían ser locales exigen editar componentes, hooks, servicios, tipos y utilidades.
  • Nadie sabe qué es realmente compartido y qué simplemente acabó en una ubicación compartida.
  • Aparecen dependencias circulares porque no existe una dirección de recorrido declarada.

El problema no son las palabras components, hooks o utils. Sigo usando esas carpetas dentro de un módulo delimitado. El problema es convertir el tipo técnico en el principio de organización de más alto nivel.

A nivel de aplicación, organizo por responsabilidad y motivo de cambio.

B-02

La estructura desde la que parto

Para un producto sustancial, mi punto de partida se parece a esto:

src/
|-- app/
|   |-- routes/
|   |-- providers/
|   `-- composition/
|-- domains/
|   |-- orders/
|   |   |-- model/
|   |   |-- policies/
|   |   |-- tests/
|   |   `-- index.ts
|   `-- accounts/
|-- features/
|   |-- cancel-order/
|   |   |-- api/
|   |   |-- components/
|   |   |-- hooks/
|   |   |-- model/
|   |   |-- tests/
|   |   `-- index.ts
|   `-- edit-billing-address/
`-- shared/
    |-- api/
    |-- ui/
    |-- lib/
    |-- config/
    `-- testing/

e2e/

La capa de aplicación compone rutas, proveedores y flujos de nivel superior. Los módulos de dominio contienen conceptos y reglas de negocio estables. Los módulos de funcionalidad implementan capacidades de usuario. Los módulos compartidos proporcionan infraestructura independiente del producto y elementos de UI. Las pruebas de extremo a extremo se sitúan fuera de src porque ejercitan el sistema ensamblado, no un único módulo.

No creo todos los directorios el primer día. Una carpeta debe existir porque el código real tiene una responsabilidad que necesita nombre. Una arquitectura vacía es solo ceremonia.

B-03

Los módulos de funcionalidad asumen capacidades de usuario

Una funcionalidad es un comportamiento que puede realizar un usuario u otro sistema: cancelar un pedido, invitar a un miembro, actualizar un método de pago o exportar un informe. Prefiero nombres de capacidades a áreas vagas como dashboard o common.

Un módulo de funcionalidad puede contener:

  • Los componentes React utilizados para realizar esa capacidad.
  • Hooks específicos y estado de interacción.
  • Validación y mapeo de formularios.
  • Operaciones de API utilizadas solo por la funcionalidad.
  • Coordinación entre reglas de dominio e infraestructura.
  • Pruebas que protegen el comportamiento de la funcionalidad.

Esto mantiene juntos los archivos que cambian juntos. Un desarrollador que trabaja en la cancelación de pedidos no debería visitar seis carpetas globales para seguir una interacción.

Organizar por funcionalidades no significa poner todo lo relacionado con pedidos en una enorme carpeta orders. Un dominio es un concepto de negocio. Una funcionalidad es una operación que involucra ese concepto. Esa distinción evita que un gran módulo de dominio se convierta en el siguiente cajón de sastre.

La capa de ruta o aplicación compone las funcionalidades. Una funcionalidad no debe entrar en los archivos internos de otra para tomar prestado un hook o componente. Si dos funcionalidades necesitan la misma regla de negocio, probablemente pertenezca a un módulo de dominio. Si necesitan el mismo elemento técnico, probablemente pertenezca a shared.

B-04

Mantener la lógica de dominio independiente de React

La lógica de dominio describe qué permite el producto y cómo se comportan los valores de negocio. No debe depender del renderizado de React, las API del navegador, una caché de consultas ni la forma de una respuesta HTTP.

export type Order = {
  id: string;
  status: "draft" | "confirmed" | "shipped" | "cancelled";
  refundableUntil: Date | null;
};

export function canCancelOrder(order: Order, now: Date): boolean {
  if (order.status === "shipped" || order.status === "cancelled") {
    return false;
  }

  return order.refundableUntil === null || now <= order.refundableUntil;
}

Esta regla puede utilizarla un botón, un guard de ruta, un flujo en segundo plano o una prueba sin montar React ni simular fetch. La funcionalidad decide cuándo llamarla y cómo presentar el resultado. El dominio decide qué es válido.

No todas las condiciones merecen una carpeta domains. Una regla solo visual utilizada por un componente puede quedarse dentro de esa funcionalidad. Promuevo lógica a un módulo de dominio cuando representa lenguaje de negocio compartido, contiene invariantes relevantes o debe mantenerse coherente entre varios flujos.

B-05

Colocar los clientes API en el borde del módulo

Separo la mecánica de transporte de las operaciones del producto.

La capa compartida de API puede asumir cuestiones como URL base, cabeceras de autenticación, cancelación de solicitudes, normalización de errores, cabeceras de trazas y análisis de JSON. No debe saber cómo cancelar un pedido ni invitar a un miembro.

El adaptador del dominio o funcionalidad correspondiente contiene las rutas de endpoints, los DTO de solicitud y respuesta y el mapeo entre datos de transporte y tipos de aplicación.

import { httpClient } from "@/shared/api";

type CancelOrderResponseDto = {
  order_id: string;
  status: "cancelled";
  cancelled_at: string;
};

export async function cancelOrder(orderId: string) {
  const response = await httpClient.post<CancelOrderResponseDto>(
    `/orders/${orderId}/cancellation`,
  );

  return {
    orderId: response.order_id,
    status: response.status,
    cancelledAt: new Date(response.cancelled_at),
  };
}

Este límite evita que los formatos de transporte se filtren a los componentes y a las reglas de dominio. Si el backend cambia el nombre de un campo o devuelve otra representación, el cambio se absorbe donde el contrato externo entra en el módulo.

Evito un único api.ts o services.ts global que contenga todos los endpoints. Esos archivos centralizan volatilidad no relacionada. Parecen cómodos hasta que cada equipo debe editar el mismo módulo y todos los consumidores pueden importar cada operación.

B-06

Dar a cada módulo una API pública

Cada dominio y funcionalidad expone una superficie pública pequeña mediante su index.ts raíz.

// features/cancel-order/index.ts
export { CancelOrderDialog } from "./components/cancel-order-dialog";
export { useCancelOrder } from "./hooks/use-cancel-order";
export type { CancelOrderResult } from "./model/cancel-order-result";

Los consumidores importan desde el límite del módulo:

import {
  CancelOrderDialog,
  type CancelOrderResult,
} from "@/features/cancel-order";

No importan desde rutas privadas:

import { cancelOrder } from "@/features/cancel-order/api/cancel-order";

La API pública es una decisión de arquitectura. Indica a otros módulos qué contratos son estables y permite al responsable renombrar archivos, sustituir una biblioteca de estado o reorganizar internamente sin cambiar el resto de la aplicación.

No añado archivos de barril a cada directorio. Ocultan el origen y pueden crear ciclos. Utilizo un único punto de entrada público en un límite de módulo relevante.

B-07

Aplicar una única dirección de dependencias

El modelo de dependencias es deliberadamente sencillo:

app  ----> features ----> domains
 |           |              |
 +-----------+--------------> shared
  • App puede componer funcionalidades, dominios y elementos compartidos.
  • Las funcionalidades pueden depender de módulos de dominio e infraestructura compartida.
  • Los dominios pueden depender de elementos compartidos pequeños y estables, pero no de funcionalidades ni de app.
  • Shared no debe depender de funcionalidades o dominios del producto.
  • Una funcionalidad no debe importar la implementación privada de otra.

Cuando dos funcionalidades necesitan coordinarse, las compongo en la capa de aplicación o extraigo el comportamiento estable de negocio que comparten. No resuelvo el problema con una importación relativa profunda.

Estas reglas deben poder aplicarse. Los alias de ruta mejoran la legibilidad, pero no crean límites. En un equipo grande, añado reglas de lint para importaciones restringidas, herramientas de límites de módulos o exports de paquetes para que una importación no válida falle antes de la revisión. En un monorepo, las mismas reglas pueden expresarse como dependencias entre paquetes.

Existen excepciones legítimas. Una regla de límites debe hacer visible un acoplamiento inusual, no forzar soluciones rebuscadas. Cuando permito una excepción, registro por qué existe la dependencia, quién la asume y qué permitiría eliminarla más adelante.

B-08

Shared es una promoción, no un punto de partida

La carpeta shared es la parte más pequeña de esta estructura, no la más grande.

La UI compartida contiene elementos básicos, estables y de bajo nivel como Button, Dialog, Field, Stack y variables semánticas de diseño. La API compartida contiene la infraestructura de transporte. Shared lib contiene funciones realmente independientes del producto con nombres y contratos claros. Shared config contiene límites de configuración para toda la aplicación.

No traslado código a shared solo porque se utilice dos veces. El recuento de usos es una evidencia débil. Dos funcionalidades pueden parecerse hoy y divergir el mes que viene.

Promuevo código cuando se cumplen las tres condiciones:

  • Los consumidores necesitan el mismo comportamiento, no solo un marcado similar.
  • La abstracción tiene un nombre y contrato estables.
  • Un responsable puede evolucionarla sin entender flujos específicos de funcionalidades.

OrderSummary no es automáticamente UI compartida. Habla el lenguaje del dominio de pedidos, por lo que puede pertenecer a la interfaz pública de ese dominio. Un botón es distinto: su comportamiento y contrato de accesibilidad se aplican a todo el producto.

Esta regla evita que shared se convierta en un nombre prestigioso para código cuyo responsable no está claro.

B-09

Mantener las pruebas con el límite que protegen

Organizo las pruebas por responsabilidad, no por el vocabulario de la biblioteca de pruebas.

src/
|-- domains/orders/tests/
|   `-- can-cancel-order.test.ts
|-- features/cancel-order/tests/
|   |-- cancel-order-contract.test.ts
|   `-- cancel-order-dialog.test.tsx
`-- shared/testing/
    |-- render-application.tsx
    |-- factories/
    `-- server/

e2e/
`-- cancel-order.spec.ts

Las pruebas puras de dominio viven con el dominio. Las pruebas de comportamiento y contrato de una funcionalidad viven con ella. Shared testing contiene infraestructura utilizada por muchos módulos: envoltorios de renderizado, factorías, constructores deterministas y manejadores de red. No debe convertirse en una segunda carpeta utils llena de fixtures específicos de negocio.

Las pruebas de extremo a extremo viven en la raíz del repositorio porque ejercitan recorridos desplegados de usuario que atraviesan límites de módulos. Su carpeta refleja el alcance de la prueba, no la preferencia por una herramienta concreta.

Un árbol de nivel superior dividido en pruebas unitarias, de integración y de componentes suele recrear el mismo problema de navegación que components, hooks y utils. Esas etiquetas describen cómo se ejecuta una prueba. No indican quién asume el comportamiento cuando falla.

B-10

Cómo decido dónde pertenece el código nuevo

Al añadir un archivo, recorro estas preguntas en orden:

  • ¿Qué capacidad de usuario o concepto de negocio asume este comportamiento?
  • ¿El código coordina un flujo, aplica una regla de dominio o proporciona infraestructura?
  • ¿Cuál es el módulo más pequeño que puede asumirlo sin duplicar significado?
  • ¿Qué módulos necesitan importarlo hoy?
  • ¿De qué se debe permitir que dependan esos consumidores?
  • Si cambia la implementación, ¿hasta dónde debería propagarse el cambio?

La última pregunta resulta especialmente útil. Si cambiar una respuesta de API obliga a editar componentes, hooks y páginas, el límite de transporte tiene fugas. Si cambiar el estado interno de una funcionalidad rompe otra, su API pública es demasiado amplia. Si cambiar una regla de negocio exige buscar en componentes de UI, la lógica de dominio no tiene un responsable claro.

La arquitectura se revela mediante el coste y el alcance del cambio.

B-11

Evolucionar hacia la estructura corte a corte

No detendría la entrega para reorganizar una aplicación existente según este árbol. Las grandes reestructuraciones crean agitación sin demostrar que los límites nuevos sean mejores.

En su lugar, elijo un corte vertical relevante. Coloco su UI, lógica de interacción, adaptador de API y reglas de dominio detrás de una API de funcionalidad. Corrijo la dirección de dependencias alrededor de ese corte y utilizo la siguiente funcionalidad para comprobar si el patrón sigue funcionando.

La estructura se gana su lugar cuando los cambios rutinarios se vuelven más locales, el código privado permanece privado y los desarrolladores pueden explicar por qué se permite una dependencia. Si un límite lucha repetidamente contra el producto, cambio el límite. La coherencia es valiosa, pero proteger una abstracción equivocada no lo es.

B-12

La regla final

No juzgo una arquitectura React por la simetría de su árbol de carpetas. La juzgo por si crea una responsabilidad clara y un cambio controlado.

Una estructura sólida indica al equipo dónde pertenece el comportamiento, evita el acoplamiento accidental, mantiene los contratos externos en los bordes y expone solo las API que deben utilizar otros módulos. Las carpetas de funcionalidades aportan proximidad. Los módulos de dominio protegen las reglas de negocio. El código compartido sigue siendo realmente compartido. Las pruebas siguen el comportamiento que protegen. Las reglas de dependencia hacen aplicable el diseño.

Ese es el objetivo: no un árbol perfecto, sino una aplicación que pueda crecer sin obligar a cada desarrollador a mantener todo el sistema en su cabeza.

B-13

Dónde encaja

Definir la responsabilidad de las funcionalidades, los límites de dominio, los adaptadores de API y la dirección de dependencias forma parte de construir una base React que un equipo de entrega pueda ampliar y acabar asumiendo con confianza.

Explorar Construir sobre bases sólidas