wandres.dev
APLICACIONES GRANDES · componer módulos

Organizar el código: módulos por dominio, tipos compartidos y sin ciclos

Un módulo de Elm es un archivo, su nombre determina su ruta y sus dependencias forman un grafo que el compilador exige acíclico. De esas tres reglas, aparentemente administrativas, se derivan casi todas las decisiones de organización que importan en una aplicación grande. Esta lección desarrolla el criterio completo: por qué conviene empezar con un archivo grande y dejar que las fronteras emerjan en vez de decretarlas el primer día, por qué agrupar por dominio envejece mejor que agrupar por capa técnica, dónde colocar los tipos que varios módulos comparten para que la dependencia siempre apunte hacia abajo, qué revela un intento de importación circular sobre el modelado, y cómo la lista de exportación se convierte en la herramienta de diseño más barata y más infrautilizada del lenguaje.

⏱ 18 min

La organización del código suele discutirse con un vocabulario de gustos y costumbres, y por eso las discusiones no terminan nunca: dos estructuras de carpetas parecen igual de defendibles y la elección acaba dependiendo de a qué estaba acostumbrado quien empezó el proyecto. Elm ofrece una salida a ese impasse porque impone unas cuantas reglas que no admiten negociación y que, al aplicarlas, resuelven la mayoría de las dudas por eliminación. Un módulo es exactamente un archivo, sin excepciones ni módulos anidados dentro del mismo texto. El nombre del módulo determina su ubicación en el disco, de modo que la jerarquía de carpetas es la jerarquía de nombres y no puede divergir de ella. La lista de exportación es obligatoria y explícita, así que la superficie pública de cada archivo está escrita en su primera línea. Y las importaciones no pueden formar un ciclo, ni siquiera indirecto, de manera que el grafo de dependencias del programa entero es forzosamente un orden parcial. Estas restricciones no son burocracia: son el andamiaje que convierte preguntas de estilo en preguntas de modelado con respuesta objetiva.

🎯 Al terminar esta lección sabrás
  • Aplicar la regla de dejar crecer un archivo hasta que las fronteras se hagan visibles, en lugar de decretar módulos por adelantado.
  • Agrupar por dominio en vez de por capa técnica y justificar la diferencia en términos de qué cambia junto.
  • Colocar los tipos compartidos en módulos hoja para que la dependencia apunte siempre en un sentido.
  • Diagnosticar una importación circular como un síntoma de modelado y aplicar las tres maniobras que la deshacen.

Deja que las fronteras aparezcan

El consejo más contraintuitivo y más citado de la comunidad es que un archivo grande no es un problema y que dividirlo pronto casi siempre lo es. La razón es que una frontera trazada el primer día se traza con la información del primer día, que es justamente cuando menos se sabe del problema, y una vez trazada resulta cara de mover porque arrastra importaciones, nombres y hábitos del equipo. Un archivo que crece, en cambio, no impide nada: las funciones que se llaman entre sí siguen visibles, renombrar es trivial y el momento de separar llega solo cuando aparece un grupo de definiciones que se usan entre ellas y casi no se mezclan con el resto. Esa cohesión observada es una evidencia; la cohesión anticipada es una conjetura.

Hay además una razón técnica, y no solo prudencial, para esta paciencia. En Elm dividir un archivo no mejora nada mecánicamente: no hay compilación separada que el programador controle, no hay carga diferida por módulo, no hay aislamiento en tiempo de ejecución. Lo único que produce la división es un cambio en la visibilidad de los nombres, es decir, un efecto puramente humano sobre qué puede leer quién. Como el beneficio es humano, el momento adecuado también lo es, y depende de si el equipo ya entiende el concepto lo bastante bien como para nombrarlo. Dividir antes de entender consiste en fijar un vocabulario provisional en la estructura del proyecto, que es el peor sitio donde fijarlo.

El criterio operativo, entonces, es esperar a que se cumplan a la vez dos señales. La primera es que un conjunto de funciones y tipos manipule los mismos datos y apenas toque los demás. La segunda es que exista una invariante sobre esos datos que merezca protegerse, es decir, algo que sería un error hacer desde fuera. Cuando solo se cumple la primera, mover el código a otro archivo aporta orden pero no garantías; cuando se cumplen las dos, aparece un módulo de verdad, con un tipo opaco y un vocabulario de operaciones. Y conviene resistir la tentación de crear módulos con una sola función que se llama desde un sitio, porque el coste de saltar entre archivos para leer una historia continua es real y no lo compensa ninguna estética.

Conviene añadir que la herramienta más barata de organización en Elm no es la carpeta sino la lista de exportación, y está infrautilizada de forma casi universal. Un módulo que expone todo con la notación abreviada renuncia a decir nada sobre sí mismo: cualquier función auxiliar escrita para uso interno pasa a formar parte del contrato público y ya no se puede renombrar ni eliminar sin revisar el resto del proyecto. Escribir la lista a mano cuesta unos segundos y produce tres efectos inmediatos. El primero es documental, porque la primera línea del archivo pasa a ser un resumen legible de lo que ofrece. El segundo es de refactorización, porque todo lo que no aparece en la lista puede cambiarse con la certeza de que ningún otro archivo depende de ello. El tercero es de diseño, porque el propio acto de decidir qué entra en la lista obliga a distinguir el vocabulario del módulo de sus detalles de implementación, distinción que rara vez se hace si nadie la exige.

-- Renuncia a decir nada: todo es publico, nada se puede tocar
module Cesta exposing (..)


-- Contrato explicito: el tipo viaja, sus constructores no
module Cesta exposing (Cesta, vacia, agregar, quitar, total)

type Cesta
    = Cesta (List Linea)


-- fusionar y normalizar son internas: se pueden reescribir sin avisar
fusionar : Producto -> Int -> List Linea -> List Linea
normalizar : List Linea -> List Linea
💡
La pregunta que decide una extracción

Antes de mover código a un archivo nuevo, formula el nombre del módulo y su lista de exportación en voz alta y comprueba si puedes describir qué garantiza sin usar la palabra y. Si la descripción es que gestiona la cesta y valida cupones y formatea precios, no hay un módulo sino tres cosas que coincidían en la misma pantalla. Un módulo que se describe con una sola frase sin conjunciones suele ser un módulo que dentro de un año seguirá teniendo sentido.

Por dominio, no por capa técnica

Antes de entrar en el argumento conviene fijar el vocabulario, porque la palabra dominio se usa con demasiada ligereza. Aquí significa algo preciso: un conjunto de datos con reglas propias que existirían aunque la aplicación tuviera otra interfaz, otro servidor y otro cliente. El pedido es dominio; la tabla que lo muestra no lo es. La distinción importa porque es la que decide qué código sobrevivirá al próximo rediseño y qué código se tirará con él, y una estructura de carpetas que no la refleja obliga a mezclar las dos vidas en el mismo sitio.

La estructura por capas técnicas es la que sale sola: una carpeta para tipos, otra para vistas, otra para utilidades, otra para peticiones. Su atractivo es que cualquiera sabe dónde poner un archivo nuevo sin pensar. Su defecto aparece cuando hay que cambiar algo, porque un cambio real casi nunca ocurre dentro de una capa: añadir un campo a un pedido toca el tipo, la codificación a la red, la decodificación, la validación y la vista, es decir, cinco carpetas distintas, y cada una contiene además cosas de otros veinte conceptos que no tienen nada que ver. La organización por dominio invierte el reparto: todo lo que se refiere al pedido vive junto, y lo que cambia junto se lee junto.

-- Por capa tecnica: un cambio toca cinco carpetas
src/Types/Pedido.elm
src/Decoders/Pedido.elm
src/Views/PedidoView.elm

-- Por dominio: lo que cambia junto vive junto
src/Pedido.elm             -- tipo opaco, invariantes, operaciones
src/Pedido/Json.elm        -- codificar y decodificar
src/Pedido/Vista.elm       -- funciones de vista, sin estado
src/Pagina/Checkout.elm    -- estado de pantalla, usa Pedido

La objeción habitual a la organización por dominio es que hay código que no pertenece a ningún dominio: funciones de formato, ayudas sobre listas, envoltorios de peticiones. Es cierta, y la respuesta no es forzar la pertenencia sino aceptar una carpeta pequeña de utilidades transversales con una regla estricta que la mantenga pequeña: solo entra ahí lo que no menciona ningún tipo del dominio. En cuanto una función de utilidad recibe o devuelve un pedido, un cliente o una cesta, deja de ser transversal y pertenece al módulo de ese concepto. Esa regla, aplicada sin excepciones, impide el destino habitual de esas carpetas, que es convertirse en el vertedero donde acaba todo lo que nadie supo dónde poner y que al cabo de un año es la parte del proyecto que más se importa y menos se entiende.

Dentro de esa estructura hay una separación que conviene mantener con disciplina, y es la que distingue los módulos de dominio de los módulos de pantalla. Un módulo de dominio no sabe nada de la interfaz: define un tipo, protege sus invariantes y ofrece operaciones puras sobre él. Un módulo de pantalla sí conoce la interfaz, y es donde vive el estado, los mensajes y las llamadas al servidor de una vista concreta de la aplicación. La dirección de la dependencia entre ambos nunca se invierte: la pantalla importa el dominio y el dominio ignora que existen las pantallas. Ese detalle es lo que permite que un tipo del dominio se reutilice en tres pantallas sin arrastrarlas consigo, y lo que hace que las pruebas del dominio no necesiten nada de la capa visual.

🌳

Un archivo, un módulo

El nombre determina la ruta, así que la carpeta y el espacio de nombres nunca pueden divergir.

📜

La lista manda

Lo que no está en la lista de exportación no existe fuera. Es documentación y permiso de refactorizar a la vez.

🧭

Dominio arriba, pantalla abajo

La pantalla importa al dominio y nunca al revés. Así un tipo sirve en tres pantallas sin arrastrar ninguna.

🚫

Sin ciclos

El compilador rechaza la circularidad. Cada rechazo es una pregunta de modelado que alguien tenía pendiente.

Tipos compartidos y la dirección de las flechas

Cuando dos módulos necesitan el mismo tipo aparece la pregunta de dónde ponerlo, y hay una respuesta que casi siempre es correcta: en un módulo que no importe a ninguno de los dos, situado por debajo de ambos en el grafo. Un módulo hoja, que solo define tipos y funciones sobre ellos, puede ser importado por todo el mundo sin generar ninguna tensión, porque no depende de nadie. La regla práctica es que los tipos viajan hacia abajo y el comportamiento hacia arriba: si dos pantallas comparten el concepto de usuario, el tipo del usuario baja a su propio módulo y las dos pantallas lo importan, en lugar de que una pantalla importe a la otra para robarle la definición.

Esa disciplina evita el problema estructural más frecuente en aplicaciones que crecen sin plan, que es el ciclo de importaciones. Elm lo rechaza en la compilación con un mensaje explícito, y esa negativa, que al principio resulta incómoda, es en realidad la información más valiosa que ofrece el compilador sobre tu arquitectura, porque un ciclo significa siempre que dos módulos se necesitan mutuamente, y eso significa que la frontera entre ellos no separa dos conceptos sino que parte uno por la mitad. Hay tres maniobras para deshacerlo y conviene conocer las tres.

-- Ciclo: Pedido necesita Cliente y Cliente necesita Pedido
module Pedido exposing (..)
import Cliente

module Cliente exposing (..)
import Pedido        -- el compilador lo rechaza


-- Maniobra 1: bajar el tipo compartido a una hoja
module Id exposing (Id, deTexto, aTexto)

-- Maniobra 2: fusionar lo que era un solo concepto
module Compra exposing (Pedido, Cliente, asociar)

-- Maniobra 3: invertir con un parametro en vez de importar
historialDe : (Id -> Maybe Pedido) -> Cliente -> List Pedido

La primera maniobra es la que resuelve la mayoría de los casos y consiste en observar que el ciclo casi nunca lo produce el concepto entero sino una pieza pequeña que ambos necesitan, típicamente un identificador o un tipo de estado. Bajar esa pieza a un módulo hoja rompe el ciclo sin fusionar nada y deja además un módulo minúsculo con una invariante bien protegida. La segunda es la que cuesta más aceptar y a veces es la correcta: si dos módulos se necesitan mutuamente en toda su extensión, no eran dos conceptos, y el ciclo es la manera que tiene el compilador de decírtelo. Fusionarlos y volver a dividirlos más adelante por otra línea suele producir un modelado mejor que el de partida.

La tercera merece un comentario más extenso porque es la menos evidente y la más potente. En lugar de que un módulo importe a otro para consultarle algo, recibe como parámetro la función que necesita. La dependencia deja de ser estática y pasa a ser un argumento, con lo cual el módulo se vuelve además comprobable sin montar el otro. Es la misma idea que la inyección de dependencias, sin contenedor, sin registro y sin configuración, porque en un lenguaje donde las funciones son valores no hace falta ninguna infraestructura para pasar una.

📝
El módulo hoja más rentable de todos

Si tuvieras que crear un solo módulo hoja en una aplicación nueva, el candidato con mejor relación entre coste y beneficio es el de los identificadores. Un tipo opaco por cada clase de identificador, con una función que lo construye desde el texto y otra que lo devuelve, impide para siempre el error de pasar un identificador de cliente donde se esperaba uno de pedido, que en un lenguaje con cadenas desnudas es indetectable y en la práctica ocurre. El módulo cabe en quince líneas, no depende de nada, lo puede importar cualquiera sin crear tensión en el grafo y suele eliminar toda una familia de fallos silenciosos antes de que existan.

Esa vista del grafo, con los tipos abajo, el dominio en medio y las pantallas arriba, tiene una lectura operativa muy directa que conviene tener presente en cada revisión de código: cuanto más abajo está un módulo, más veces se importa y por tanto más caro resulta cambiarlo, así que ahí es donde compensa gastar tiempo en el diseño y donde menos debe haber decisiones provisionales. Cuanto más arriba, menos gente depende de él y más barato es reescribirlo entero, de modo que ahí la experimentación es libre. Aplicar el mismo nivel de cuidado a todo el proyecto es un desperdicio en un extremo y una imprudencia en el otro.

flowchart TD
H[Modulos hoja de tipos] --> D[Modulos de dominio]
D --> P[Modulos de pantalla]
P --> R[Main y enrutador]
X[Importacion hacia abajo] -.-> H
style H fill:#a6e3a1,color:#11111b
style D fill:#89b4fa,color:#11111b
style P fill:#cba6f7,color:#11111b
style R fill:#f9e2af,color:#11111b
La prohibición de ciclos no limita tu diseño: te obliga a tener uno

Vale la pena entender por qué una restricción que en otros lenguajes se considera una molestia resulta aquí una de las mejores propiedades del sistema. En un lenguaje que admite importaciones circulares, el grafo de dependencias de un proyecto maduro tiende irremediablemente a un enredo en el que casi todo alcanza a casi todo, y ese enredo no se percibe porque nada falla: el programa compila, las pruebas pasan y el coste solo aparece cuando alguien intenta extraer un trozo, entender una parte sin el resto o cambiar algo sin recompilar el mundo. La prohibición hace visible ese coste en el momento exacto en que se contrae, no dos años después. Cuando el compilador rechaza tu ciclo no te está señalando un problema de importaciones, te está señalando que has escrito dos módulos que en tu cabeza son un solo concepto, y te obliga a decidir cuál es la relación verdadera entre ellos: si uno es más fundamental que el otro, si comparten algo que debería tener nombre propio, o si nunca debieron separarse. Ninguna de esas tres decisiones es un trámite; las tres son modelado del dominio, y el compilador te fuerza a hacerlas explícitamente en lugar de dejarlas implícitas. Hay una consecuencia todavía más interesante y es que un grafo acíclico admite un orden topológico, lo que significa que siempre existe una manera de leer tu aplicación entera de abajo arriba en la que nada de lo que lees depende de algo que aún no has leído. Esa propiedad, que ningún proyecto con ciclos posee, es exactamente lo que distingue una base de código que se puede aprender de una que solo se puede sufrir. Y es gratis: no requiere disciplina del equipo ni revisiones de código, la garantiza el compilador en cada construcción.

⚔️ Dibuja el grafo antes de mover un archivo
  1. Elige el archivo más grande de tu aplicación y busca en él un grupo de funciones que manipule los mismos datos sin tocar el resto.
  2. Escribe la lista de exportación que tendría ese grupo como módulo y descríbelo en una frase sin usar la palabra y.
  3. Reorganiza tres archivos de una estructura por capas a una por dominio y cuenta cuántas carpetas toca un cambio típico antes y después.
  4. Localiza un tipo que usen dos módulos distintos y bájalo a un módulo hoja; comprueba que ninguno de los dos importa al otro.
  5. Provoca deliberadamente un ciclo de importaciones, lee el mensaje del compilador y deshazlo con cada una de las tres maniobras.
  6. Crea el módulo hoja de identificadores con dos tipos opacos distintos e intenta pasar uno donde se espera el otro.
  7. Dibuja el grafo de dependencias de tu aplicación y ordénalo topológicamente; señala el módulo que más veces aparece importado y pregúntate si está en el nivel correcto.