Arquitectura, carpetas y TypeScript en React | Nicolás Garzón
Una aplicación pequeña puede empezar simple. Al crecer, agrupa por features y límites reales.
Texto
Copiar src/
app/
features/
orders/
components/
hooks/
api/
model/
shared/
ui/
lib/No copies una estructura empresarial antes de necesitarla.
Los componentes de una feature pueden usar utilidades compartidas. La capa compartida no debería importar detalles de features concretas.
Cerca de quien lo usa.
En la URL si representa navegación.
En una caché si viene del servidor.
En un store si cruza límites amplios y necesita coordinación.
TypeScript
Copiar type ProductCardProps = {
product: Product;
onSelect : ( productId: string ) => void ;
} ; Prefiere tipos de dominio a objetos genéricos y modela estados asíncronos con uniones discriminadas.
TypeScript
Copiar type LoadState< T > =
| { status: "idle" }
| { status: "loading" }
| { status: "success" ; data: T }
| { status: "error" ; error: Error } ; Los index.ts pueden simplificar imports, pero también ocultar ciclos y aumentar dependencias accidentales.
Carpetas por tipo con cientos de archivos sin dominio.
Abstracciones compartidas usadas una sola vez.
Tipar todo como opcional.
Permitir estados imposibles con varios booleanos.
La arquitectura debe hacer visibles los límites del producto y permitir cambios localizados.
Una estructura útil agrupa código que cambia por la misma razón. No busca “carpetas perfectas”, sino que un cambio de negocio tenga un área clara.
Texto
Copiar feature orders
├─ ui
├─ model
├─ data access
├─ tests
└─ public APISi agregar una regla de pedidos obliga a editar utilidades globales, componentes genéricos y una store central sin relación visible, los límites no representan el producto.
Texto
Copiar src/
├─ App.tsx
├─ components/
├─ pages/
└─ lib/Es suficiente para una aplicación pequeña. Introduce features, layers o packages cuando aparezcan problemas observables:
Archivos difíciles de localizar.
Ciclos de imports.
Cambios no relacionados juntos.
Código compartido sin ownership.
Tests que requieren montar toda la app.
Texto
Copiar src/
├─ app/
│ ├─ router.tsx
│ └─ providers.tsx
├─ features/
│ ├─ orders/
│ │ ├─ api/
│ │ ├─ components/
│ │ ├─ hooks/
│ │ ├─ model/
│ │ └─ index.ts
│ └─ inventory/
└─ shared/
├─ ui/
└─ lib/No conviertas shared en un lugar para todo. Una pieza debería entrar allí después de demostrar reutilización transversal y contrato estable.
Texto
Copiar app → features → sharedshared no importa una feature concreta. Dos features no deberían depender circularmente de sus implementaciones internas. Cuando necesitan colaborar:
Extrae un contrato de dominio.
Coordina desde app.
Publica una API mínima.
Usa eventos o servicios bien definidos.
TypeScript
Copiar
export { OrdersPage } from "./components/OrdersPage" ;
export type { Order, OrderStatus } from "./model/order" ; Otros módulos importan desde la frontera en vez de rutas profundas. Esto permite reorganizar internals. Pero un index.ts global que reexporta toda la app puede crear ciclos y bundles inesperados.
Texto
Copiar UI
→ interpreta eventos y representa estado
domain/model
→ reglas, tipos y transiciones
data access
→ HTTP, cache y serializaciónNo toda aplicación necesita tres carpetas por feature. La separación aporta cuando cada parte tiene complejidad o pruebas propias.
Evita usar directamente la respuesta API en toda la UI:
TypeScript
Copiar type OrderDto = {
order_id: string ;
total_cents: number ;
} ;
type Order = {
id: string ;
total: Money;
} ; Un mapper valida y adapta el límite. La UI consume un modelo coherente aunque cambie el backend.
TypeScript
Copiar type PaymentState =
| { status: "idle" }
| { status: "processing" ; operationId: string }
| { status: "approved" ; receiptId: string }
| { status: "rejected" ; reason: string } ; Esto evita propiedades opcionales y booleanos que permiten estados imposibles.
TypeScript
Copiar type OrderRowProps = {
order: OrderSummary;
onOpen : ( orderId: OrderId) => void ;
} ; Pasa el dato mínimo que el componente necesita. Un objeto de dominio completo puede acoplar una primitive visual a cambios no relacionados. Tampoco fragmentes cada propiedad si siempre forman una unidad.
TypeScript desaparece en ejecución. Datos externos necesitan schema o validación explícita:
TypeScript
Copiar const order = OrderSchema. parse ( await response. json ( ) ) ; Distingue tipos estáticos de validación de red, storage, search params y formularios.
UI local → componente.
Compartido de feature → ancestro/provider/store de feature.
URL → router.
Remoto → loader/cache/server state.
Persistente → servidor o storage con schema.
La estructura de carpetas debería reflejar estas fronteras, no obligar a colocar todo state en stores/.
Un componente de design system debería depender de primitives y tokens, no de Order o Customer. Un componente de negocio puede usar dominio y composición:
TypeScript
Copiar < OrderStatusBadge status= { order. status} / > No generalices prematuramente StatusBadge<T> si solo existe un caso.
Coloca un Hook junto al dominio que representa:
Texto
Copiar features/orders/hooks/useOrderFilters.tsUn Hook compartido necesita contrato transversal real. useFetch en shared suele ocultar decisiones de datos; useOrders pertenece a orders.
Aliases como @/features/orders mejoran legibilidad, pero no crean arquitectura. Configura TypeScript, bundler, tests y linter de forma consistente. Evita imports relativos que atraviesan muchas fronteras y deep imports que rompen encapsulación.
Valores undefined inesperados.
Orden de inicialización frágil.
Bundles amplios.
Tests que necesitan mocks extraños.
Mover tipos puros a un módulo sin runtime.
Invertir dependencia mediante argumento.
Extraer coordinación al nivel superior.
Reducir reexports.
Herramientas de análisis pueden detectar ciclos, pero el diseño debe eliminarlos.
La arquitectura visual también incluye boundaries:
Texto
Copiar route
├─ error boundary
├─ suspense/loading boundary
└─ feature componentsNo centralices todos los errores en App; permite recuperación local y ownership claro.
Funciones de dominio: unitarias.
Components: comportamiento con RTL.
Data access: contratos y MSW.
Feature: integración.
Rutas críticas: E2E.
Una estructura buena permite probar una feature sin bootstrapping completo.
Se justifica cuando existen aplicaciones o paquetes con ciclos de release y ownership distintos. Añade:
Tooling.
Versionado interno.
Pipelines.
Límites de paquetes.
No conviertas carpetas de una única app en paquetes solo por apariencia empresarial.
Identifica un cambio doloroso.
Define una frontera.
Mueve un flujo completo.
Publica API mínima.
Corrige imports y tests.
Elimina duplicación después.
No reorganices toda la app sin cambios funcionales verificables; genera diffs grandes y riesgo.
Arquitectura organiza razones de cambio y ownership.
Empieza simple y extrae límites por evidencia.
Features pueden contener UI, modelo y data access relacionados.
TypeScript modela contratos, pero runtime valida entradas externas.
Shared debe ser pequeño y estable.
Dependencias deberían fluir en una dirección visible.
¿Cuándo una pieza merece entrar en shared?
¿Qué ventaja tiene una API pública de feature?
¿Por qué no usar DTOs directamente en toda la UI?
¿Qué indica un ciclo de dependencias?
Ver respuestas
Cuando varios dominios la usan bajo un contrato estable.
Oculta internals y permite refactorizar sin romper consumidores.
Acopla la UI al transporte y evita validación/adaptación central.
Límites mal orientados o coordinación ubicada en el nivel incorrecto.
Testing de componentes con React Testing Library verifica que estas fronteras protejan comportamiento y no solo estructura de archivos.