Fronteras por dominio: dividir el estado como divides el producto
Organizar el código por tipo de artefacto produce carpetas ordenadas y sistemas ilegibles, porque agrupa lo que cambia por motivos distintos y separa lo que cambia por el mismo motivo. Esta lección defiende la tesis contraria: el estado de una feature vive con la feature, y el trazado correcto de sus fronteras es el mismo que ya existe en el producto, en el equipo que lo mantiene y en la conversación que lo describe.
Toda aplicación que sobrevive lo suficiente descubre que su problema ya no es qué librería de estado usar, sino dónde poner cada pieza. La respuesta que el ecosistema ofreció durante una década fue organizar por tipo de artefacto: una carpeta para los reducers, otra para los selectors, otra para los hooks. Es una taxonomía impecable y una arquitectura pésima, porque agrupa por parecido sintáctico cosas que cambian por motivos distintos y dispersa por tres carpetas cosas que cambian siempre juntas. La alternativa no es una convención de carpetas más moderna, sino una tesis sobre el acoplamiento: el estado hereda la topología del producto, y cada frontera que dibujes en el código debería corresponder a una frontera que ya existe en la conversación, en el equipo y en el ritmo de cambio.
- Distinguir la organización por artefacto de la organización por dominio y sus consecuencias.
- Trazar fronteras de estado a partir del lenguaje del producto y del ritmo de cambio.
- Decidir qué pertenece al interior de una frontera y qué constituye su superficie pública.
- Aplicar la prueba de borrado para validar que una frontera está bien situada.
El estado hereda la topología del producto
La observación fundacional es de Parnas y tiene medio siglo: un módulo no debe encapsular un paso del proceso, sino una decisión que puede cambiar. Trasladada al estado, la consecuencia es directa. Los datos del carrito, las reglas que los validan, las acciones que los modifican y las vistas derivadas que los consumen forman una sola decisión de producto, y cambian a la vez cuando esa decisión cambia. Repartirlos entre una carpeta de stores, otra de selectors y otra de actions significa que cualquier cambio trivial toca tres lugares distantes y que ningún lugar contiene la respuesta completa a la pregunta de qué es un carrito en este sistema.
Por artefacto
Carpetas por tipo técnico. Cada cambio de producto atraviesa varias, cada carpeta mezcla dominios que no se conocen y ningún directorio explica una funcionalidad completa.
Por dominio
Carpetas por porción de producto. El cambio queda contenido, la carpeta se lee como una respuesta y el árbol de directorios cuenta lo que la aplicación hace.
Lenguaje ubicuo
La frontera buena tiene nombre en la conversación del equipo. Si nadie fuera del código dice slice de datos compartidos, ese módulo no corresponde a nada real.
Ritmo de cambio
Lo que cambia junto vive junto. Dos porciones de estado que siempre aparecen en el mismo commit pertenecen al mismo dominio aunque el diagrama diga otra cosa.
La objeción previsible es que el lenguaje del producto también cambia y que los nombres de las áreas de negocio se reorganizan con cada cambio de estrategia. Es verdad, pero cambia mucho más despacio que las modas técnicas y, sobre todo, cambia por motivos que el equipo puede anticipar porque participa en ellos. Una frontera trazada sobre el vocabulario del negocio envejece al ritmo del negocio; una trazada sobre categorías técnicas envejece al ritmo del ecosistema, que es un orden de magnitud más rápido y completamente ajeno a la voluntad de nadie.
El criterio operativo más fiable no es semántico sino histórico. Un análisis de coincidencia en el control de versiones revela qué archivos aparecen juntos en los mismos cambios, y esa señal es más honesta que cualquier diagrama, porque describe lo que el sistema hace y no lo que alguien creyó que haría. Cuando dos módulos formalmente independientes coinciden en el ochenta por ciento de los cambios, la frontera que los separa es ficticia; cuando un módulo se parte sistemáticamente en dos mitades que nunca cambian a la vez, hay dos dominios dentro de una carpeta.
// por artefacto: un cambio de producto toca tres carpetas lejanas
// src/stores/carrito.ts src/selectors/carrito.ts src/actions/carrito.ts
// por dominio: el cambio queda contenido en una frontera
// src/dominios/carrito/estado.ts
// src/dominios/carrito/derivados.ts
// src/dominios/carrito/index.ts <- la unica puerta de entrada
Conviene precisar en qué sentido la organización por artefacto es peor, porque no lo es en todos. Para quien escribe la infraestructura del estado —el motor de derivación, el adaptador de persistencia, la capa de transporte— agrupar por tipo técnico es exactamente lo correcto, porque esos archivos sí cambian juntos y por el mismo motivo. El error consiste en extender ese esquema a la capa de dominio, donde los motivos de cambio son de producto y no técnicos. La regla general que unifica ambos casos es la de Constantine sobre la cohesión: agrupa lo que comparte razón de cambio, y la razón de cambio de un store de carrito no es que sea un store, sino que es un carrito.
Situar el estado junto a la feature que lo usa reduce la distancia entre la causa de un cambio y su efecto, y esa distancia es el mejor predictor conocido del coste de mantener un sistema. No se trata de que las carpetas queden bonitas: se trata de que la persona que recibe un incidente a las tres de la mañana pueda leer una sola carpeta y entender la totalidad del comportamiento implicado, en vez de reconstruirlo saltando entre seis archivos que solo comparten el sufijo del nombre.
Qué cabe dentro y qué queda en la superficie
Hay una objeción sensata a todo esto: los dominios no son evidentes al principio y el producto los redefine cada pocos meses. Es cierta, y no invalida el método sino que fija su cadencia. Las fronteras no se trazan una vez sino que se revisan con el mismo ritmo con el que el producto cambia de forma, y su trazado provisional es preferible a la ausencia de trazado porque una frontera equivocada es visible y corregible, mientras que la ausencia de fronteras produce un acoplamiento uniforme que no se puede ni ver ni medir ni deshacer por partes.
Una frontera de dominio solo significa algo si distingue interior de exterior. El interior contiene la forma exacta de los datos, la normalización elegida, los índices auxiliares, las funciones de actualización y todo lo que sea consecuencia de una decisión que el dominio se reserva. La superficie contiene lo que otros necesitan: un puñado de lecturas derivadas con nombre de producto y un puñado de operaciones que expresan intenciones. Si el exterior conoce la forma interna, la frontera es decorativa: cualquier cambio en la estructura de datos se convierte en un cambio distribuido por toda la aplicación.
flowchart TD UI[interfaz de otras features] --> API[superficie publica del dominio] API --> L[lecturas derivadas con nombre de producto] API --> O[operaciones que expresan intenciones] L --> N[estado interno normalizado] O --> N N --> X[forma de datos indices y detalles privados]
La disciplina que hace real la frontera es sorprendentemente barata: una sola puerta de entrada por dominio, y la regla de que nadie importa desde el interior de otro dominio. El archivo índice reexporta la superficie y el resto de los archivos son privados por convención, o por configuración del linter si el equipo prefiere que la regla no dependa de la buena voluntad. Lo importante no es el mecanismo sino la asimetría que instaura: el autor del dominio puede reorganizar todo su interior sin coordinar con nadie, y esa libertad es exactamente lo que se compra al pagar el coste de mantener una superficie estable.
Un criterio de tamaño ayuda a mantener la superficie honesta: si excede la docena de elementos, casi siempre significa que hay dos dominios dentro de la carpeta o que el interior se está filtrando pieza a pieza. El límite no tiene nada de mágico, pero funciona como alarma porque una superficie pequeña es la única que alguien puede sostener entera en la cabeza, y una frontera que no cabe en una cabeza no reduce la carga cognitiva de nadie, que era precisamente su razón de ser.
// superficie publica: intenciones y lecturas, nunca la forma interna
export type ItemCarrito = { readonly sku: string; readonly cantidad: number }
export const totalCarrito = derivar(estado, e => calcularTotal(e.lineas))
export const hayStockPendiente = derivar(estado, e => e.pendientes.size > 0)
export function anadirAlCarrito(sku: string, cantidad: number): void { /* ... */ }
export function vaciarCarrito(): void { /* ... */ }
// no se exporta: el mapa de lineas, los indices, ni el esquema de normalizacion
Hay una tentación recurrente que conviene nombrar para resistirla: exponer el estado crudo porque otra feature necesita justo un dato que ya está ahí. Ceder es rápido y el coste llega meses después, cuando la normalización interna deba cambiar y resulte que catorce lugares dependían de que las líneas fueran un array y no un mapa. La respuesta correcta a esa necesidad no es abrir el interior, sino ampliar la superficie con una lectura derivada que exprese la necesidad en términos del dominio, porque una lectura con nombre puede reimplementarse y una estructura filtrada no.
Queda una pregunta que toda frontera debe responder y que suele quedar sin respuesta hasta que produce un incidente: qué ocurre con las invariantes que atraviesan dos dominios. Un carrito no puede contener más unidades de las que el inventario permite, y ni el carrito ni el inventario poseen esa regla por completo. La solución mala es que cada uno valide lo que puede y confíe en el otro, porque entonces la invariante no vive en ningún sitio y se rompe en cuanto uno de los dos cambia. La solución correcta es reconocer que la invariante define un tercer dominio, el de la reserva, con su propio estado y su propia superficie, y que su existencia estaba oculta porque el producto todavía no le había puesto nombre.
// invariante entre dominios: no pertenece a ninguno de los dos
// mal: cada lado valida lo que puede y confia en el otro
carrito.anadir(sku, n) // comprueba stock leyendo inventario
inventario.reservar(sku, n) // comprueba carrito leyendo carrito
// bien: la invariante revela un dominio propio con su superficie
reservas.reservarParaCarrito(sku, n) // unico lugar donde la regla vive
Si una exportación se llama getLineasMap, la frontera ya se rompió: el nombre revela la implementación y ata al dominio a mantenerla. Si se llama resumenDelCarrito, el dominio conserva la libertad de cambiar por dentro. La prueba es leer los nombres exportados en voz alta ante alguien que no programa: si entiende de qué habla la feature, la superficie está bien trazada.
La prueba de borrado
Existe un experimento mental que decide en minutos si una frontera está bien situada, y consiste en preguntar qué haría falta para eliminar la feature por completo. Si la respuesta es borrar una carpeta y una línea del registro de rutas, la frontera es real. Si la respuesta incluye desenredar campos de un objeto global, revisar un fichero de tipos compartido y eliminar ramas de tres reducers ajenos, la feature nunca tuvo frontera: tenía una carpeta y un montón de tentáculos.
Conviene aplicar la prueba en su versión débil antes que en la fuerte, porque la fuerte casi siempre falla en sistemas reales y un criterio que suspende a todo el mundo no orienta a nadie. La versión débil pregunta cuántos archivos ajenos habría que tocar, y su valor está en el número, no en el veredicto: tres es una frontera correcta con un par de fugas, treinta es una feature sin frontera. Registrar ese número para las cinco features principales y observar su evolución trimestral es una de las mediciones de arquitectura más baratas que existen y de las pocas que un equipo puede sostener durante años.
// sintoma de frontera ficticia: el estado global sabe de todas las features
type EstadoApp = {
carritoLineas: Linea[]
carritoCupon: string | null
checkoutPaso: number
perfilPreferencias: Prefs
buscadorFiltros: Filtros // borrar el buscador obliga a tocar este tipo
}
// frontera real: cada dominio posee su estado y la app solo los compone
type EstadoApp = Record<string, never> // la app no posee estado de dominio
Un corolario poco obvio de esta prueba es que mide algo más valioso que la limpieza: mide la capacidad de experimentar. Un producto que puede eliminar una feature en una tarde puede permitirse lanzar diez y quedarse con dos, y un producto en el que retirar cualquier cosa exige una semana de arqueología acaba conservando todo lo que lanzó, no porque funcione sino porque desmontarlo sale caro. Las fronteras de estado, por tanto, no solo determinan el coste de mantener el sistema; determinan cuánto puede equivocarse el equipo sin que la equivocación se vuelva permanente.
La prueba de borrado es valiosa porque convierte una discusión de arquitectura, que suele resolverse por autoridad o por costumbre, en una pregunta empírica que cualquiera puede responder. Y tiene una versión aún más barata y casi igual de informativa: intentar mover la carpeta del dominio a otro punto del árbol. Si el movimiento solo rompe rutas de importación, la frontera aguanta. Si rompe la compilación por dependencias circulares, el dominio estaba entrelazado con sus vecinos y el diagrama que decía lo contrario describía una intención, no un hecho.
Hay un tercer indicador, más lento de obtener pero decisivo cuando el equipo crece: cuántas personas distintas modificaron el interior de un dominio durante el último semestre. Un dominio bien trazado tiende a concentrar sus cambios en pocas manos, no por propiedad territorial sino porque su alcance corresponde a un área de responsabilidad real. Cuando el interior de un módulo es tocado cada mes por seis equipos distintos, la frontera no delimita un dominio sino una zona de paso, y ninguna cantidad de revisión de código compensa el hecho de que nadie tenga el modelo completo en la cabeza.
La organización por dominio no exige inventar dominios antes de conocerlos. En las primeras semanas de un producto nadie sabe todavía dónde están las junturas, y una división prematura fosiliza un modelo mental que el aprendizaje posterior desmentirá, con el agravante de que mover código entre fronteras es más caro que crearlas cuando ya se ven. Empieza con un módulo grande y honesto, observa dónde se acumulan los cambios, y traza la frontera cuando el propio sistema la haya insinuado dos o tres veces.
La discusión sobre cómo organizar el estado se plantea casi siempre como una elección entre esquemas de carpetas, y por eso casi nunca se resuelve: dos esquemas rivales pueden defenderse indefinidamente porque ninguno es falsable en abstracto. El giro decisivo consiste en dejar de tratar la frontera como un objeto de diseño y empezar a tratarla como un fenómeno observable. Un dominio no es un conjunto de archivos que alguien decidió agrupar; es una región del sistema cuyos elementos cambian en respuesta a la misma clase de acontecimientos externos, y esa propiedad se mide, no se opina. De ahí que el historial del repositorio sea mejor fuente de verdad arquitectónica que cualquier documento de diseño: registra qué cambió con qué durante meses, sin las distorsiones del recuerdo ni los intereses de quien defiende su propuesta. Cuando dos módulos coinciden sistemáticamente en los mismos cambios, están acoplados por la realidad aunque el diagrama los separe, y esa realidad terminará imponiéndose en forma de dependencias que nadie planeó, de circularidades que aparecen sin autor, y de una sensación difusa de que tocar cualquier cosa rompe otra. Por eso la organización por dominio no es una preferencia entre estilos: es el único esquema que puede equivocarse y corregirse, porque hace una predicción comprobable sobre dónde caerán los próximos cambios. Y su virtud última no es la limpieza del árbol de directorios, sino que devuelve al equipo la capacidad de razonar localmente, que es lo único que impide que el coste de entender un sistema crezca más rápido que el sistema mismo.
- Extrae del control de versiones los pares de archivos de estado que más veces coincidieron en el mismo cambio durante los últimos seis meses.
- Contrasta ese acoplamiento medido con el mapa de dominios que tu equipo cree tener y anota las tres discrepancias mayores.
- Elige la feature con más tentáculos y escribe su superficie pública: como máximo cinco lecturas derivadas y cinco operaciones con nombre de producto.
- Aplica la prueba de borrado sobre ella y lista exactamente qué archivos ajenos habría que tocar para eliminarla.
- Mueve la carpeta del dominio a otro punto del árbol y usa lo que se rompa como inventario priorizado de acoplamientos por cortar.