Dónde SOLID, Clean Architecture y los patrones de dominio valen en el frontend, dónde son overhead, y cómo reconocer el punto en que algo deja de ser overengineering.
La arquitectura correcta para el frontend no viene del backend. Viene del producto, del tamaño del equipo, y de cuántas preguntas podés diferir con honestidad. SOLID y Clean Architecture tienen partes que valen; el resto es ritual, y el ritual en el front cuesta caro porque cada capa que sumás aparece en el bundle del usuario.
Este texto es lo que me hubiese gustado leer hace cinco años. No es un manifiesto contra los patrones. Es un ejercicio sobre cuándo aplicarlos, cuándo no, y cómo reconocer el punto exacto en que algo deja de ser overengineering.
System design en el front no es design system
La confusión empieza en el vocabulario. "System design" se volvió sinónimo de "biblioteca de componentes" en muchos equipos de producto. No lo es. System design en el frontend es la misma disciplina del backend, con otro conjunto de preguntas:
- ¿Dónde caen las fronteras entre módulos?
- ¿Cómo fluye el dato (server → cliente → URL → cache)?
- ¿Cuál es el modelo de error?
- ¿Cuál es el contrato con el backend, y quién es el dueño?
- ¿Qué estrategia de renderizado (SPA, SSR, SSG, RSC)?
- ¿Cómo publicás?
Design system, en cambio, es una de las salidas del system design: las primitivas visuales (botón, input, modal). Útil, pero la capa más superficial. Cuando alguien dice "hagamos system design" y abre Figma, sabés que va a salir de ahí con Button y Card, no con decisiones de cache.
La primera responsabilidad del arquitecto frontend es separar esas dos conversaciones. Sin eso, todo trade-off se vuelve opinión de diseño y nada estructural sale del papel.
Las cinco preguntas antes de decidir cualquier cosa
Antes de elegir framework, patrón u organización de carpeta, paso por cinco preguntas. En orden.
¿Quién consume este frontend? Sitio de marketing (foco en SEO y LCP), interfaz interna (foco en productividad del user, cero SEO), app orientada al cliente (foco en UX rica y perf de runtime), o herramienta especializada (canvas, IDE, planilla) donde la complejidad del dominio importa más que la de la UX. Cada respuesta abre arquitecturas distintas.
¿Cuántos ingenieros van a tocarlo simultáneamente? Un arquitecto solo optimiza por velocidad y descartabilidad. Cinco personas rotando optimizan por contratos explícitos. Cincuenta personas en squads independientes optimizan por aislamiento total (module federation, micro-frontends). No es el mismo problema.
¿Cuál es la vida útil esperada? Una campaña de Navidad vive tres meses. Un producto SaaS vive cinco años. Las decisiones que valen a tres meses (cero tests, cero abstracción, código descartable) son suicidas a cinco años.
¿Dónde está lo complejo: dominio o UX? Un CRUD con formularios tiene complejidad de UX (validación, estados, microcopy) pero dominio chato. Un editor de canvas tiene lo opuesto. SOLID y Clean Architecture nacen del dominio rico; aplicarlos a un CRUD es overhead garantizado.
¿Cuánto importa el control del tiempo de carga? Si un LCP de 1s es ventaja competitiva (sitios de medios, e-commerce), estás en un juego de gramos y la arquitectura limita o libera ganancias. Si un LCP de 3s es aceptable (dashboard interno), podés elegir comodidad sobre performance sin culpa.
No conozco equipos con tiempo para arquitectura cuidada. Lo que se puede hacer es responder estas cinco en quince minutos antes de tipear la primera línea. Esos quince minutos pagan meses.
SOLID aplicado al front: lo que sobrevive
SOLID vino de Bob Martin, contexto OOP backend de los 90. Cada letra reaparece en el front con vigor distinto.
Single Responsibility. Vale siempre, pero el concepto de "responsabilidad" en el front es más fluido. Un componente carga estado más estilo más comportamiento más accesibilidad. "Una razón para cambiar" en el frontend se traduce mejor como "una audiencia que conformar". Un Button que tiene que servir al diseñador (visual), al product manager (analytics), al ingeniero (a11y) y al área legal (terms) tiene cuatro audiencias y por lo tanto cuatro razones para cambiar. Sano aceptar eso y diseñar las props para cada audiencia.
Open/Closed. Componentes "abiertos a la extensión" vía API explícita está bien. "Abiertos" vía ocho props booleanas es lo que Sandi Metz llamó the wrong abstraction: esperaste tanto para extraer que extraer ya duele. Heurística: si la suma de props booleanas supera cinco, el componente quiere ser dos.
Liskov Substitution. Apenas aplicable a JSX/HTML por sí solo. Aparece en el diseño de variantes (<Button variant="primary"> vs <Button variant="ghost"> deben responder al mismo conjunto de event handlers y props básicas). No es el foco de la disciplina en el front.
Interface Segregation. Vale fuerte. Props "kitchen sink" (veinte props donde usás tres) es el anti-patrón más común en una UI library. Componentes chicos con props especializadas envejecen mejor que los "genéricos". Tailwind llevó este principio al extremo: Adam Wathan defendió que CSS reusable y HTML reusable son objetivos opuestos, y la elección está en qué dependencia querés.
Dependency Inversion. Acá cae el 80% del cargo cult. En el backend, invertir dependencias tiene costo controlado: creás interface, enchufás adapter en el startup, ganás testabilidad y flexibilidad para cambiar transporte. En el frontend, el "transporte" rara vez cambia (no reemplazás REST por gRPC en una SPA un jueves). Abstraer fetch detrás de interface UserRepository en React es, en el 90% de los casos, ritual.
Resumen: SOLID tiene cinco letras, tres valen directo (S, O, I), una se traduce con cuidado (L en variants), y una es trampa (D en casi todo). Aceptar eso libera tiempo para arquitecturar de verdad.
Clean Architecture: qué llevar y qué doler aceptar dejar
Clean Architecture de Bob Martin es un framework general, no una receta lista. Las partes que sobreviven en apps React modernas:
Use cases / interactors. En el backend se vuelven servicios. En el frontend, custom hooks que devuelven acciones:
function useCheckoutSubmission() {
const [state, setState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
const submit = useCallback(async (cart: Cart) => {
setState('submitting')
try {
await api.checkout(cart)
setState('done')
} catch {
setState('error')
}
}, [])
return { state, submit }
}
Eso es un use case. Concentra la regla del flujo en un solo lugar, expone acciones nombradas, mantiene al componente tonto. Vale.
Entities + Value Objects. Valen si tenés dominio rico. Un editor de canvas necesita Shape, Layer, Selection como entidades con comportamiento. Una planilla necesita Cell, Formula, Range. Un CRUD no. Para un app que muestra lista de productos y formulario de pedido, crear clases para Product y Order cuando el backend ya devuelve JSON es peso muerto.
Repositories abstrayendo HTTP. En el 99% de los casos, es capa por ritual. No reemplazás fetch por axios en un sprint; cuando lo hacés, es refactor global de cualquier forma. La capa no te protege de lo que dice proteger.
La excepción que confirma la regla: apps que necesitan funcionar offline con cola de sincronización (PWA seria) genuinamente se benefician de repository, porque el "transporte" varía entre HTTP, IndexedDB y SyncManager. Ahí vale.
Dependency rule (UI no importa de infra, infra no importa de domain): vale como heurística, no religión. En el front casi siempre se traduce a "components no importan fetch directamente". Cumplirlo con hooks lo resuelve sin cuatro carpetas y arquitectura hexagonal.
Conclusión: de Clean Architecture, agarrar use cases (custom hooks con acciones) y la heurística "código que cambia junto, queda junto". El resto, en la mayoría de los apps, es peso.
Siete señales concretas de overengineering
Lista que aplico en code review. Cada ítem es rojo hasta prueba en contrario.
- Tres o más capas para renderizar una lista de productos.
Container → ListProvider → Repository → Service → Component. Si un GET se vuelve cinco archivos, alguien perdió el objetivo.
- Custom hook envolviendo
useState sin agregar valor. useToggle, useCounter, useBoolean. Sobre un useState de una línea, custom hook es distracción.
- Repository pattern en un app que sólo habla con una API. Capa que no protege de cambio real es decoración.
- Container/Presentational forzado en 2026. La separación que tenía sentido antes de los hooks ya está dentro de los hooks. Forzarla de nuevo es nostalgia.
- Folder structure con ocho carpetas antes de la primera feature.
domain/, application/, infrastructure/, presentation/, shared/, core/, lib/, utils/. Sin código, es taxonomía.
- State management global para dato que sólo una pantalla usa. Redux/Zustand para carrito de compras es defendible; para
isModalOpen de un modal específico es overhead.
- Componente "genérico" con doce props booleanas.
<Button compact loading disabled primary ghost full-width icon-only no-padding rounded uppercase tabular>. Pulpo con bandera blanca, citando a Kent C. Dodds en AHA Programming.
Heurística general detrás de todo, citando a Sandi Metz: es más fácil ir de duplicación a abstracción que de abstracción errónea a la correcta. Esperá que los patrones aparezcan tres o cuatro veces antes de extraer. La repetición visible enseña más que la abstracción prematura.
Cuándo overengineering deja de ser overengineering
La misma estructura que es overhead para un app de cinco personas se vuelve oxígeno para uno de cincuenta. Los disparadores:
- Cinco o más ingenieros tocándolo simultáneamente. A partir de ahí, los contratos explícitos pagan la sobrecarga. Sin ellos, todo merge es teatro.
- Cincuenta mil líneas de código o más. El cerebro humano no sostiene más que eso entero. Las capas ayudan a navegar.
- Dominio genuinamente complejo. Editores, simuladores, IDEs, plataformas multi-tenant. Esos apps son complicados por la naturaleza del problema, no por elección arquitectónica.
- Churn alto de personas. Cuando un dev se queda seis meses en promedio, las capas se vuelven contrato de onboarding. El costo de explicar
useState vs useReducer a alguien que se va en tres meses es mayor que el costo de tener un patrón obligatorio.
- Producto distribuido (multi-tenant, white label, o multi-marca dentro de la misma empresa). Ahí la inversión de dependencia paga: cada cliente se vuelve un adapter.
La heurística inversa que aplico, de Dan McKinley: tenés un suministro limitado de innovation tokens. Cada patrón "moderno" que adoptás antes de necesitarlo gasta un token. Si los gastaste todos antes de la primera feature compleja, no te queda nada cuando la feature compleja aparece.
Un framework simple para system design frontend
Cuando diseño un app desde cero, paso por ocho ejes. Cada uno tiene su "malo, bueno, mejor" relativo al contexto.
Dominio. Identificá capacidades de negocio (no entidades). "Pago", "catálogo", "búsqueda" son capacidades. "User", "Order" son entidades. Capacidad dicta frontera; entidad sólo dicta schema.
Fronteras. Carpetas por capacidad, no por capa. Todo lo que cambia cuando "pago" cambia queda junto: ruta, hook, API call, componente, test:
src/features/
checkout/
api.ts
use-checkout.ts
checkout-page.tsx
checkout-summary.tsx
checkout.test.ts
catalog/
...
vs la versión mala, separada por capa:
src/
components/
checkout/
CheckoutPage.tsx
hooks/
useCheckout.ts
api/
checkout.ts
tests/
checkout.test.ts
La segunda te obliga a abrir cuatro carpetas para tocar una feature. La primera mantiene el trabajo local.
Estado. Tres tipos, tres tratamientos:
// Server state — TanStack Query, SWR, fetch loaders
const { data: user } = useQuery(['user', id], () => fetchUser(id))
// URL state — search params (filtros, paginación, tab activa)
const [params, setParams] = useSearchParams()
const tab = params.get('tab') ?? 'overview'
// UI state — useState local cuando sólo este subárbol lo necesita
const [isOpen, setOpen] = useState(false)
Confundir los tres es la fuente clásica de bugs y re-renders. Estado de servidor en useState fuerza refetch manual. Estado de URL en useState rompe el back button. Estado de UI en store global es overkill.
Rutas. Locale como prefijo + slugs declarados una vez, no esparcidos por el app:
// Malo — rutas hardcoded en varios lugares, locale escapando por acá y allá
<Link to="/checkout">Checkout</Link>
<Link to={`/${locale}/orders`}>Pedidos</Link>
navigate('/orders/' + orderId)
// Bueno — tabla de rutas como única fuente, locale siempre aplicado
const routes = {
checkout: (locale: Locale) => `/${locale}/checkout`,
orderDetail: (locale: Locale, id: string) =>
`/${locale}/orders/${id}`,
} as const
<Link to={routes.checkout(locale)}>Checkout</Link>
<Link to={routes.orderDetail(locale, order.id)}>...</Link>
URLs traducidas (Pattern B: /pt/artigos/... vs /en/articles/...) ayudan al SEO en mercados no-anglos pero exigen un resolver que traduce slug entre locales. Para app interno o producto B2B, slug compartido (Pattern A) alcanza.
Error. Boundary por feature, no global. Un <ErrorBoundary> arriba tira el sitio entero abajo cuando algo se rompe en el checkout. Uno por feature mantiene el resto del app vivo:
// Malo — el boundary global captura todo y pierde contexto
<ErrorBoundary fallback={<GenericError />}>
<App />
</ErrorBoundary>
// Bueno — cada feature tiene su propio fallback y mensaje
<Routes>
<Route
path="/checkout"
element={
<ErrorBoundary fallback={<CheckoutError />}>
<CheckoutPage />
</ErrorBoundary>
}
/>
<Route
path="/catalog"
element={
<ErrorBoundary fallback={<CatalogError />}>
<CatalogPage />
</ErrorBoundary>
}
/>
</Routes>
El global todavía puede existir como red de seguridad final, nunca como única defensa. Cuando le decís al usuario del checkout "no pudimos procesar ahora", es porque el checkout falló. No el sitio. El fallback genérico arriba borra ese matiz y quema credibilidad.
Performance. Code-split por ruta es gratis (ya viene de lazy()). Vendor split por chunk es donde la mayoría se olvida:
// Malo — todo en el entry, la primera página descarga el bundle entero
import EditorCanvas from './features/editor/EditorCanvas' // sólo usado en /editor
import DashboardCharts from './features/dashboard/Charts' // sólo usado en /dashboard
export const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/editor', element: <EditorCanvas /> },
{ path: '/dashboard', element: <DashboardCharts /> },
])
// Bueno — code-split por ruta + vendor chunks separados en Vite
const EditorCanvas = lazy(() => import('./features/editor/EditorCanvas'))
const DashboardCharts = lazy(() => import('./features/dashboard/Charts'))
// vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('react-router')) return 'vendor-router'
if (id.includes('i18next')) return 'vendor-i18n'
if (id.includes('react-helmet-async')) return 'vendor-helmet'
},
},
},
}
Cada vendor que cambia en un ritmo distinto al de tu código debe ser su propio chunk. El browser cachea long, la primera visita duele una vez, las siguientes vuelan.
Renderizado. SSR/RSC cuesta servidor + complejidad. Pre-render por ruta cubre el caso común:
// Malo — SSR religioso para contenido esencialmente estático
// (Next.js / Remix con Node en producción, ISR, headers de cache afinados...)
// Para portafolio, blog técnico, marketing site: peso muerto.
// Bueno — pre-render por ruta
// Crawler ve HTML listo, usuario recibe el mismo HTML, React hidrata encima.
dist/pt/index.html
dist/pt/writing/<slug>/index.html
dist/pt/projects/<slug>/index.html
// Cada uno con <title>, meta description, hreflang, JSON-LD ya en el HTML estático.
SSR/RSC gana cuando el contenido CAMBIA por request (feed personal, multi-tenant con branding, dashboard con dato caliente). Para contenido conocido en build, pre-render entrega el mismo SEO sin servidor.
Deploy. Bucket estático o edge es el default razonable. Servidor real sólo cuando hace falta:
# Malo — Docker + Node + nginx para servir HTML estático
docker build -t app . && docker run -p 80:80 app
# más provisionamiento, CI pesado, alertas, escalamiento, parches del SO...
# Bueno — edge/bucket como default
pnpm build && vercel deploy --prod
# o: netlify deploy --prod, o: cloudflare pages deploy, o: aws s3 sync + cloudfront
# Costo: centavos/mes para tráfico promedio. CDN global incluido. SSL automático.
Servidor real (Node, Bun, edge functions) entra cuando necesitás SSR genuino, una API cerca del edge, o compliance que exige infra propia. Empezá barato; subí cuando la feature de verdad lo pida.
La heurística que aplico antes de CUALQUIER capa nueva
Antes de agregar dependencia, abstracción, hook, servicio, contexto, store — me pregunto una cosa:
¿Esto me da más cosa para cuidar, o menos?
Si la respuesta es "más", tiene que pagar por sí: ganancia real de testabilidad, reducción de duplicación que dolía, o ganancia de perf medible. Vale el trade.
Si la respuesta es "menos" (genuinamente menos, no "menos en el corto plazo"), probablemente vale.
La mayoría de las malas decisiones de arquitectura frontend que vi no fueron por falta de patrón. Fueron por exceso de él.
El mejor arquitecto frontend es el que sabe cuándo no aplicar
La imagen que me ayuda: la arquitectura en el frontend es discípula del KISS, no del SOLID estricto. SOLID es caja de herramientas, y como toda caja, tiene herramientas que no usás en décadas.
Aplico un test después de seis meses: abro el repo y trato de tocar algo que no veía hace tiempo. Si duele, alguna frontera se corrió (el negocio cambió, el diseño no acompañó). Refactorizar frontera es mantenimiento normal. Si no duele, el diseño hizo su trabajo.
No sabés si tu arquitectura es buena hoy. Lo vas a saber en seis meses, cuando vuelvas a tocarla.
Para seguir leyendo
Los textos que formaron la opinión de arriba.
Enable JavaScript for the full experience.