El patrón ducks: el módulo como unidad de dominio
El Redux clásico repartía cada funcionalidad entre cuatro carpetas técnicas —constantes, acciones, reducers, selectores— de modo que añadir un solo campo obligaba a editar cuatro ficheros que no se leen juntos jamás. En 2015 Erik Rasmussen propuso invertir el eje del corte con una regla mínima: un módulo por dominio que exporta su reducer por defecto y sus creadores de acción como exportaciones nombradas. Esta lección reconstruye el argumento de cohesión que hay debajo, formula la regla completa del duck y sus extensiones con selectores, y muestra que createSlice de Redux Toolkit no es una alternativa al patrón sino su institucionalización: la API oficial fabrica un duck bien formado y hace del módulo de dominio la unidad indivisible de la estructura.
Durante los primeros años de Redux, la comunidad organizó el código por su papel técnico: una carpeta para los tipos de acción, otra para los creadores, otra para los reducers y, con suerte, otra para los selectores. La estructura parecía ordenada porque agrupaba cosas que se parecen, pero agrupaba mal: nadie edita nunca todos los reducers a la vez, mientras que todo el mundo edita a la vez la acción, el reducer y el selector de la misma funcionalidad. El patrón ducks nació de constatar ese desajuste y proponer el corte contrario, por dominio en vez de por rol. Su regla cabe en un párrafo y su consecuencia atraviesa el resto del nivel: la unidad de estructura de una aplicación Redux no es el fichero de reducers, sino el módulo que reúne todo lo que un dominio necesita para gobernarse a sí mismo.
- Diagnosticar el coste de la organización por tipo con el criterio de cohesión y el olor de la cirugía de escopeta.
- Enunciar la regla completa del patrón
ducksy el papel del prefijo de dominio en los tipos de acción. - Extender el módulo con selectores y tipos para que su superficie pública sea la única frontera del dominio.
- Reconocer
createSlicecomo un duck generado y ubicar lo que aún queda a tu cargo dentro del módulo.
El corte por tipo y la cirugía de escopeta
La organización por tipo agrupa ficheros que comparten su rol técnico y separa ficheros que comparten su razón de cambio. El resultado tiene nombre en el catálogo de olores de Fowler: shotgun surgery, la cirugía de escopeta, un cambio conceptualmente único que se dispersa en perdigones por todo el árbol. Añadir un campo a un formulario obliga a abrir constants, actions, reducers y selectors, editar cuatro ficheros que no comparten contexto y confiar en que el mismo literal de cadena aparezca idéntico en tres de ellos.
Lo que falla no es la estética sino la métrica clásica de Constantine: la cohesión. Un módulo cohesivo reúne elementos que cambian por la misma causa; la carpeta reducers reúne elementos que solo tienen en común su firma. Y la separación tiene un coste de correctitud, no solo de comodidad: la relación entre el literal despachado y el literal escuchado es lo que Page-Jones llamó conascencia de nombre, la forma más débil de acoplamiento, tolerable cuando vive en un fichero y peligrosa cuando cruza tres carpetas, porque ningún compilador la vigila y ningún grep la garantiza.
Toda conascencia se paga en proporción a la distancia entre los elementos que la sufren. Dos piezas acopladas por nombre dentro del mismo fichero se corrigen de un vistazo; las mismas dos piezas repartidas en carpetas distintas producen la clase de bug que más tiempo consume en Redux clásico: la acción que se despacha, no falla, no avisa y simplemente no la escucha nadie porque una letra bailó en el literal. El patrón ducks no elimina esa conascencia, la comprime hasta hacerla local, que es exactamente lo que la teoría recomienda cuando no puedes eliminar un acoplamiento: acércalo hasta que sea visible.
La regla del duck
En 2015 Erik Rasmussen publicó ducks-modular-redux con una propuesta de cuatro reglas. Un módulo debe exportar por defecto la función reducer; debe exportar sus creadores de acción como exportaciones nombradas; debe nombrar sus tipos de acción con el formato dominio/ACCION, con el nombre del paquete o de la funcionalidad como prefijo; y puede exportar sus tipos de acción cuando otro reducer necesite escucharlos legítimamente. El nombre es un juego con la sílaba final de Redux, y su modestia es deliberada: no es un framework, es una convención de fichero.
// features/carrito/carritoDuck.ts
const AGREGAR = 'carrito/agregar'
const VACIAR = 'carrito/vaciar'
export const agregar = (id: string) => ({ type: AGREGAR, payload: id })
export const vaciar = () => ({ type: VACIAR })
type Estado = { lineas: string[] }
const inicial: Estado = { lineas: [] }
export default function reducer(estado = inicial, accion: any): Estado {
switch (accion.type) {
case AGREGAR: return { lineas: [...estado.lineas, accion.payload] }
case VACIAR: return inicial
default: return estado
}
}
El prefijo de dominio hace un trabajo silencioso pero decisivo. Como el espacio de nombres del tipo coincide con el nombre del módulo, dos funcionalidades pueden tener su propia acción agregar sin colisionar, y cualquier acción que aparezca en las DevTools declara su origen sin que haya que buscarlo. En un store grande, leer el registro de acciones se convierte en leer una traza de eventos etiquetada por dominio, que es justo lo que quieres cuando depuras algo que no escribiste tú.
flowchart LR subgraph tipo [corte por tipo] A1[constants] --> A2[actions] A2 --> A3[reducers] A3 --> A4[selectors] end subgraph dominio [corte por dominio] B1[carrito duck] --> B2[todo junto] B3[usuarios duck] --> B4[todo junto] end style A2 fill:#f38ba8,color:#11111b style B1 fill:#a6e3a1,color:#11111b style B3 fill:#a6e3a1,color:#11111b
El duck extendido: selectores y superficie pública
La propuesta original callaba sobre los selectores porque en 2015 apenas se usaban, y la práctica los incorporó pronto como tercera exportación del módulo. Es el cierre natural del argumento: si el duck agrupa todo lo que cambia por la misma causa, la forma interna del estado y las funciones que la leen tienen que vivir dentro de él, porque un cambio de forma las invalida a todas a la vez. Con selectores dentro, el módulo expone una superficie pública completa —acciones para escribir, selectores para leer— y guarda la forma del estado como detalle privado, que es la definición de ocultación de información de Parnas aplicada a una porción de store.
// El duck completo: escritura, lectura y tipos en un solo modulo.
export type Estado = { lineas: string[] }
// Lectura: los consumidores nunca tocan la forma interna.
export const selectLineas = (raiz: { carrito: Estado }) => raiz.carrito.lineas
export const selectTotal = (raiz: { carrito: Estado }) => raiz.carrito.lineas.length
// Variante re-ducks cuando el modulo crece de fichero a carpeta:
// features/carrito/actions.ts, reducer.ts, selectors.ts, types.ts
// features/carrito/index.ts reexporta exactamente la misma superficie
La variante re-ducks propuso además explotar el duck en carpeta cuando crece, con un fichero por responsabilidad y un index que reexporta la misma superficie. Es un refinamiento de escala, no un patrón distinto: la unidad sigue siendo el dominio, y lo único que cambia es si cabe en un fichero o necesita varios. La prueba de que sigues dentro del patrón es simple: si borrar la funcionalidad entera equivale a borrar una carpeta y ninguna importación queda huérfana, el duck está bien formado.
Merece la pena fijarse en la cuarta regla original, la que permite exportar los tipos de acción cuando otro reducer necesite escucharlos, porque es la única que abre la puerta del módulo hacia fuera. No es una concesión al desorden sino el reconocimiento de que las acciones de Flux son eventos difundidos y no órdenes privadas: que otro dominio reaccione a carrito/vaciar es legítimo y a menudo deseable. Lo que la regla exige es que esa reacción pase por un tipo exportado a propósito, y no por un literal copiado que nadie sabrá relacionar con su origen.
La lista de exportaciones es la parte visible del patrón y la menos interesante; lo que determina su calidad es lo que queda dentro. Si el módulo esconde la forma del estado, los literales de sus tipos de acción y sus funciones auxiliares, cambiarlo por completo es un ejercicio local. Si exporta el estado inicial para que otro lo componga, o su interfaz interna para que otro la reutilice, ha convertido detalles de implementación en compromisos públicos y ya no se puede reescribir sin negociar. Escribe primero la lista de lo que el resto del mundo tiene derecho a usar; todo lo demás es privado por defecto.
Reducer por defecto
La exportación anónima del módulo. Quien monta el store no necesita saber cómo se llama por dentro.
Acciones nombradas
Los creadores como exportaciones nombradas. Escribir en el dominio pasa siempre por ellos.
Tipos con prefijo
El formato dominio/accion evita colisiones y hace legible la traza de las DevTools.
Selectores dentro
La forma del estado es privada. Leer el dominio pasa por sus selectores, nunca por su estructura.
createSlice, o el duck institucionalizado
Cuando Redux Toolkit apareció, no propuso una alternativa a ducks: adoptó el patrón y lo convirtió en API. Un createSlice recibe un nombre de dominio, un estado inicial y unos reducers, y devuelve exactamente las tres piezas del duck: el reducer para montar, los creadores de acción generados y, desde las versiones recientes, los selectores derivados con slice.selectors. Los tipos de acción prefijados dejan de escribirse a mano porque se componen del nombre del slice y del nombre del caso.
import { createSlice } from '@reduxjs/toolkit'
const carrito = createSlice({
name: 'carrito',
initialState: { lineas: [] as string[] },
reducers: {
agregar: (s, a: { payload: string }) => { s.lineas.push(a.payload) },
vaciar: (s) => { s.lineas = [] },
},
selectors: { total: (s) => s.lineas.length },
})
export const { agregar, vaciar } = carrito.actions
export const { total } = carrito.selectors
export default carrito.reducer
Que la API oficial genere ducks bien formados cierra la discusión sobre el patrón y abre la siguiente. Lo que createSlice no decide por ti es dónde colocar ese fichero, qué otros artefactos lo acompañan, qué puede leer de otros dominios y qué pasa cuando el módulo llega tarde. Cada una de esas preguntas es una lección de este nivel, y todas presuponen la respuesta de esta: la unidad que se coloca, se agrupa, se delimita y se carga es el módulo de dominio, no el fichero técnico.
El fallo recurrente al adoptar el patrón es confundir todo lo del dominio con todo lo que el dominio toca. Un duck de carrito que importa el reducer de usuarios para leer el descuento, o que despacha acciones de tres funcionalidades ajenas desde sus propios thunks, ha ganado cohesión aparente y perdido independencia real. La cohesión que buscas es de causa de cambio, no de tema de conversación: si una pieza cambia cuando cambian las reglas de otro dominio, esa pieza no es tuya aunque hable de ti.
Lo que el patrón ducks enseña no es una convención de exportaciones, sino que toda estructura de código es una apuesta sobre qué va a cambiar junto, y que esa apuesta se paga a diario. Cortar por tipo técnico agrupa por parecido —todos los reducers son reducers— y el parecido es una propiedad estática, cómoda para explicar el sistema a alguien que no lo ha tocado nunca y perfectamente inútil para trabajar en él. Cortar por dominio agrupa por causa de cambio, que es una propiedad dinámica y es la única que un equipo experimenta: nadie despierta con ganas de modificar todos los selectores del mundo, la gente despierta con una funcionalidad que debe cambiar de forma. Esta distinción entre la vista estática y la vista dinámica de un sistema es la misma que separa el diagrama de clases del análisis de acoplamiento, y la misma que separa una arquitectura que envejece bien de una que se resiste a cada modificación. El corolario incómodo es que ninguna estructura es correcta en abstracto: es correcta respecto a un patrón de cambio esperado, y cuando ese patrón cambia —cuando dos dominios se fusionan, cuando uno se parte en dos— la estructura debe seguirlo en vez de defenderse. Por eso los ducks se mueven, se parten y se borran enteros con facilidad, y por eso esa facilidad es su mérito principal: la unidad de dominio no es solo el sitio donde vive el código, es la granularidad a la que decides, revisas y revocas. Quien organiza por rol técnico ha optimizado la primera lectura del sistema; quien organiza por dominio ha optimizado las mil ediciones siguientes, y solo una de las dos cosas ocurre más de una vez.
- Elige una funcionalidad real de tu store y lista todos los ficheros que tocarías para añadirle un campo nuevo. Cuenta cuántas carpetas distintas aparecen.
- Reúne esas piezas en un módulo único con la regla del duck: reducer por defecto, creadores nombrados y tipos con prefijo de dominio.
- Mueve dentro del módulo todos los selectores que dependan de la forma interna de ese estado, y comprueba que ningún componente accede ya a esa forma directamente.
- Reescribe el módulo con
createSlicey contrasta línea a línea qué escribías a mano y qué genera la API por ti. - Busca en el duck resultante cualquier importación de otro dominio y anótala: son las candidatas a fronteras que la tercera lección del nivel va a examinar.
- Aplica la prueba de borrado: elimina la carpeta entera en una rama descartable y comprueba cuántas importaciones rotas quedan fuera de ella. Cada rota es una fuga de tu superficie pública.