wandres.dev
ARQUITECTURA RECOMENDADA · capas y flujo unidireccional

Modularizar: módulos Gradle, tiempos de compilación y fronteras que aguantan

Modularizar es la única técnica que convierte una convención de arquitectura en una restricción que el sistema de construcción hace cumplir, y también una de las que más proyectos han estropeado al aplicarla por imitación. Esta lección separa las razones legítimas de las decorativas, contrapone la división por funcionalidad con la división por capa y explica por qué la combinación de ambas es lo que sostiene el crecimiento, examina con honestidad qué acelera y qué no acelera la compilación cuando se parte un proyecto en muchas piezas, y termina con las herramientas que hacen que una frontera siga existiendo dos años después: la distinción entre dependencias expuestas e internas, la visibilidad de módulo y los complementos de convención que impiden que cada equipo invente su propia configuración.

⏱ 22 min

Todo lo que las cuatro lecciones anteriores describen es, en un proyecto de un solo módulo, una convención. Nada impide que un Composable importe el cliente de red, que un repositorio conozca la pantalla o que alguien llame directamente a una fuente de datos desde un ViewModel. Que no ocurra depende de que cada persona recuerde la regla en cada cambio y de que quien revisa la detecte, y ambas cosas fallan con una probabilidad que crece con el tamaño del equipo y con la presión de la entrega. Modularizar es el paso que convierte esa convención en física: si el módulo de interfaz no declara ninguna dependencia sobre el cliente de red, la importación no compila, y la discusión termina antes de empezar. Ese es el valor central de la técnica, y conviene tenerlo claro porque casi todo el discurso público sobre modularización habla de otra cosa, normalmente de velocidad de compilación, que es el beneficio más citado y el más condicionado.

🎯 Al terminar esta lección sabrás
  • Distinguir las razones legítimas para modularizar de las decorativas y elegir el momento de hacerlo.
  • Combinar la división por funcionalidad con la división por capa sin producir un grafo inmanejable.
  • Evaluar con realismo el efecto de la modularización sobre los tiempos de compilación.
  • Sostener las fronteras en el tiempo con dependencias bien declaradas, visibilidad restringida y configuración compartida.

Las razones que valen y la que no

La primera razón legítima es la que ya se ha enunciado: la frontera pasa a estar comprobada por el compilador. Una regla que no se puede violar sin que falle la construcción es una regla de otra naturaleza que una que vive en un documento.

Conviene apreciar la diferencia de naturaleza que introduce esa comprobación. Una convención se sostiene sobre la atención de las personas, y la atención es un recurso que fluctúa con la carga de trabajo, la hora del día y la antigüedad en el proyecto. Una restricción del sistema de construcción no fluctúa: se cumple igual el primer día que el último viernes antes de una entrega, que es exactamente cuando más falta hace.

La segunda es la propiedad del código. Un módulo tiene dueños identificables, y esa correspondencia entre estructura técnica y estructura humana es lo que permite que varios equipos trabajen sin pisarse. Sin ella, cualquier cambio profundo se convierte en una negociación con todo el mundo.

Esa correspondencia también funciona al revés y conviene tenerlo presente: si el grafo de módulos no se parece al organigrama, uno de los dos está mal, y cambiar el organigrama suele ser más caro. Diseñar el grafo mirando solo el código y no las personas produce fronteras técnicamente impecables que nadie respeta porque atraviesan por la mitad el trabajo de alguien.

La tercera es la reutilización real: compartir código entre variantes de la aplicación, entre la aplicación y su versión de reloj, entre productos distintos de la misma empresa. Un módulo con dependencias explícitas y superficie declarada se puede llevar a otro sitio; un paquete dentro de un monolito, no.

Hay una cuarta razón que se cita poco y que en proyectos grandes acaba siendo decisiva: el aislamiento de las herramientas. Un módulo que no aplica el complemento de Android compila como código puro de Kotlin, con procesadores más ligeros y sin recursos que empaquetar, y sus pruebas corren en la máquina de desarrollo en milisegundos. Sacar el dominio y los contratos a módulos sin dependencia de la plataforma es a menudo la modularización con mejor relación entre esfuerzo y beneficio, y además es la que abre la puerta a compartir ese código con otras plataformas más adelante.

Y la razón que no vale por sí sola es la estética. Modularizar porque un proyecto de referencia lo hace, sin que exista ninguno de los tres problemas anteriores, añade coste inmediato y garantizado: más configuración que mantener, más ficheros de construcción, navegación más costosa, más ceremonia para mover una clase de sitio. En un proyecto de dos personas y quince pantallas, ese coste no lo compensa nada.

💡
La señal que indica que ha llegado el momento

No modularices por tamaño de código sino por síntomas. Tres son inequívocos: dos equipos que se bloquean en los mismos ficheros, una compilación incremental que supera el minuto de forma habitual, o una violación de la regla de dependencia que ya se ha colado dos veces pese a haberla señalado en revisión. Cuando aparece cualquiera de ellos, la modularización deja de ser una preferencia y pasa a ser la solución a un problema que ya estás pagando.

Por funcionalidad, por capa, y por qué se combinan

Dividir por funcionalidad agrupa todo lo que sirve a una parte del producto: la pantalla de perfil, su ViewModel, su repositorio y sus fuentes viven juntos. La virtud es enorme y a menudo se subestima: la mayoría de los cambios reales son cambios de funcionalidad, y en esta división un cambio típico toca un solo módulo. La debilidad aparece cuando dos funcionalidades necesitan el mismo dato, porque una de ellas acaba dependiendo de la otra y el grafo empieza a enredarse.

Esa debilidad tiene además una manifestación sutil: las funcionalidades tienden a duplicar código de infraestructura porque compartirlo obligaría a acoplarse. Un formateador de precios acaba escrito tres veces con tres criterios distintos, y el usuario ve tres formatos en la misma aplicación. La solución no es permitir la dependencia cruzada sino bajar la pieza compartida, y hacerlo pronto, antes de que existan las tres versiones.

Dividir por capa agrupa todo lo que hace el mismo tipo de trabajo: un módulo de datos, uno de dominio, uno de interfaz. La virtud es que la regla de dependencia queda expresada directamente en el grafo. La debilidad es simétrica a la anterior: cualquier cambio de producto toca los tres módulos a la vez, lo que anula buena parte de la ganancia de compilación y multiplica los conflictos.

flowchart TD
A[app] --> B[feature perfil]
A --> C[feature catalogo]
B --> D[core data]
C --> D
B --> E[core ui]
C --> E
D --> F[core network]
D --> G[core database]
E --> H[core designsystem]
style D fill:#a6e3a1,color:#11111b
style E fill:#89b4fa,color:#11111b

La disposición que sostiene el crecimiento, y que es la que emplean los proyectos de referencia de Google, combina ambas: módulos por funcionalidad en la superficie, módulos de núcleo por capa debajo. Cada funcionalidad depende de los núcleos que necesita y ninguna depende de otra funcionalidad. El módulo de aplicación existe solo para ensamblar el conjunto y navegar entre destinos, y por eso conviene que sea casi vacío.

La regla que mantiene sano ese grafo es que las funcionalidades no se conocen entre sí. Cuando dos necesitan hablar, el dato compartido baja al núcleo correspondiente o la comunicación se hace a través de una abstracción que ambas conocen y que ninguna posee. La alternativa, una dependencia directa entre funcionalidades, funciona la primera vez y produce a los seis meses un grafo donde nada se puede compilar por separado.

La navegación es el caso donde esa regla se pone a prueba, porque una funcionalidad casi siempre necesita llevar al usuario a otra. La salida limpia es que ninguna conozca el destino ajeno: cada una declara las rutas que ofrece en un módulo diminuto de contratos, y el módulo de aplicación es el único que ensambla el grafo completo. Así, añadir una funcionalidad no obliga a tocar ninguna de las existentes.

Conviene además nombrar bien los núcleos, porque el nombre gobierna lo que la gente mete dentro. Un módulo llamado core sin más se convierte en el desván del proyecto en cuestión de meses: todo lo que no encaja en otro sitio aterriza ahí, y acaba siendo un módulo del que dependen todos y que cambia constantemente, es decir, la peor combinación posible para los tiempos de compilación. Núcleos con nombres específicos, uno por responsabilidad, obligan a justificar cada incorporación.

🧱

Grafo ancho

Muchas funcionalidades hermanas sobre pocos núcleos estables. Se compila en paralelo y los cambios se aíslan.

🪜

Grafo profundo

Cadenas largas de dependencias. Cada cambio bajo desencadena una recompilación en cascada y en serie.

Compilación: la mitad de lo que se promete

El argumento de la velocidad es cierto pero llega con condiciones que casi nunca se enuncian. Gradle recompila solo lo que ha cambiado y lo que depende de ello, y ejecuta en paralelo lo independiente. En un proyecto de un módulo, cambiar una línea recompila todo; en uno de veinte, recompila uno y sus dependientes. Cuando el grafo es ancho y plano, la mejora es real y grande.

Cuando el grafo es profundo, en cambio, la mejora se evapora. Si el módulo de núcleo del que dependen todos cambia, todos recompilan, y además ahora en serie por niveles. Y hay un coste fijo que crece con el número de módulos: la fase de configuración, los procesadores de anotaciones, la resolución de dependencias. Pasado cierto punto, añadir módulos empeora los tiempos.

Tres decisiones concretas separan un grafo rápido de uno lento. La primera es distinguir las dependencias expuestas de las internas: declarar con api obliga a recompilar a todos los consumidores cuando cambia, mientras que implementation detiene la propagación en el propio módulo. Usar lo primero por costumbre convierte el grafo en un monolito con más ficheros.

dependencies {
    api(projects.core.model)            // los tipos salen en mi superficie publica
    implementation(projects.core.network)  // detalle mio, nadie mas lo recompila
}

La regla práctica es sencilla: usa api solo cuando un tipo del módulo del que dependes aparece en tu propia superficie pública, por ejemplo como tipo de retorno de un método que otros llaman. En cualquier otro caso, implementation. Aplicar esta revisión a un proyecto existente suele reducir el número de módulos afectados por un cambio típico a la mitad.

La segunda es que las interfaces vayan por delante de las implementaciones. Un módulo de funcionalidad que depende de un módulo pequeño de contratos y recibe la implementación en tiempo de ensamblado deja de recompilarse cuando la implementación cambia, que es lo que cambia a menudo.

La tercera es evitar la generación de código innecesaria en los módulos hoja, porque el procesamiento de anotaciones es con frecuencia la parte más lenta de la compilación de cada módulo y su coste se paga tantas veces como módulos lo activen.

Hay una cuarta menos evidente y muy rentable: la estabilidad de la superficie. Gradle puede saltarse la recompilación de los consumidores si lo que cambió en un módulo no altera su interfaz visible, de modo que un módulo cuya superficie pública es pequeña y estable protege a todo lo que hay encima aunque su interior cambie a diario. Ese es el argumento técnico, además del arquitectónico, para separar contratos de implementaciones.

Y conviene recordar que la modularización no es la única palanca sobre los tiempos, ni siempre la mayor. La caché de construcción, la configuración perezosa de tareas, el uso de un catálogo de versiones y la eliminación de procesadores heredados suelen dar mejoras comparables con una fracción del esfuerzo y sin reorganizar nada. Empezar por ahí y modularizar después es el orden que produce menos arrepentimientos.

⚠️
Mide antes de creer

Antes de partir nada, ejecuta varias veces la construcción con el análisis de tiempos activado y averigua dónde se va realmente el reloj. Es habitual descubrir que la configuración, un procesador de anotaciones mal configurado o una tarea de recursos consumen más que la compilación de Kotlin, y en ese caso la modularización no arreglará nada. Repite la medición después: si los tiempos no mejoraron, el grafo es demasiado profundo o demasiado acoplado, y la respuesta es aplanarlo, no seguir dividiendo.

Fronteras que siguen ahí dentro de dos años

Una frontera no se erosiona por un ataque frontal sino por concesiones pequeñas y razonables. Alguien necesita una clase de otro módulo y la hace pública. Alguien añade una dependencia para salir del paso. Ninguna de esas decisiones parece grave por separado y su suma es un grafo donde todo depende de todo.

Esa erosión tiene un rasgo característico que ayuda a detectarla: es asimétrica. Nadie retira nunca una dependencia por iniciativa propia, porque quitar no resuelve ningún problema urgente, mientras que añadir resuelve el problema de hoy. Un grafo abandonado a su dinámica natural solo crece, y por eso la revisión periódica del grafo, aunque no haya síntomas, es lo único que lo mantiene en su sitio.

La primera defensa es la visibilidad. Kotlin ofrece el modificador internal, que limita el acceso al propio módulo, y en un proyecto modularizado ese modificador recupera todo su sentido: la superficie pública de un módulo pasa a ser una decisión deliberada en lugar del resultado de escribir clases sin pensar en el alcance. Una regla sencilla y muy rentable es que todo nace internal y solo se abre cuando alguien de fuera lo necesita de verdad.

Hay un matiz sobre esa regla que conviene anticipar. En un proyecto de un solo módulo, internal no protege de nada porque todo el código está dentro; por eso mucha gente llega a la modularización sin el hábito de usarlo y con miles de clases públicas por omisión. Convertir esa superficie accidental en una superficie decidida es a menudo el trabajo más revelador de todo el proceso, porque enseña cuánto del proyecto dependía de detalles que nadie había querido exponer.

La segunda es la configuración compartida. Cuando cada módulo tiene su propio fichero de construcción escrito a mano, las versiones se desincronizan, las opciones del compilador divergen y añadir un módulo se convierte en un ejercicio de copiar y pegar con errores. La solución es un complemento de convención propio, aplicado en una línea por módulo, que concentre toda esa configuración en un solo sitio.

plugins {
    id("miapp.android.feature")
    id("miapp.android.library.compose")
}

Merece la pena insistir en el coste que evita esa configuración compartida, porque es el que más crece con el tiempo. En un proyecto con treinta módulos escritos a mano, subir la versión del lenguaje significa treinta ediciones idénticas y la certeza estadística de que dos quedarán atrás; con complementos de convención significa una. La modularización sin esa pieza no escala, y muchos proyectos que abandonaron la modularización en realidad abandonaron el mantenimiento manual de treinta ficheros de construcción.

La tercera es la comprobación automática del grafo. Se puede verificar en la integración continua que ningún módulo de funcionalidad dependa de otro, que ningún módulo de interfaz dependa de la red, que nadie use api sin justificación. Un grafo de dependencias es un dato inspeccionable, y una prueba sobre él es tan legítima como cualquier otra.

La cuarta, y la que más se olvida, es dejar escrito por qué existe cada módulo. Un fichero breve en la raíz del proyecto que diga qué responsabilidad tiene cada pieza y qué le está prohibido depender cuesta media hora y responde a la pregunta que todo el mundo se hace al llegar. Sin él, las incorporaciones deducen las reglas por imitación de lo que encuentran, y lo que encuentran incluye todos los atajos que alguien tomó bajo presión.

Queda una advertencia sobre el camino, porque el error más caro de esta lección no es elegir mal la división sino elegirla toda de golpe. Una modularización que se plantea como una reescritura del proyecto compite con la entrega de producto y pierde, y lo que queda a medias es peor que el punto de partida: dos organizaciones conviviendo, ninguna comprobada. La forma que funciona es incremental y aburrida: se extrae primero un núcleo pequeño y estable, después la funcionalidad nueva nace ya modularizada, y las viejas se mueven cuando toca tocarlas por otra razón.

Los módulos son organización humana escrita en el sistema de construcción

Conviene terminar el nivel con lo que la modularización realmente es, porque el encuadre habitual, una técnica para acelerar compilaciones, es el que produce las decisiones peores. Un grafo de módulos es una descripción de cómo se organiza el trabajo humano en un proyecto, y esa descripción tiende a converger con la estructura del equipo lo quiera nadie o no. Cuando las fronteras técnicas coinciden con las fronteras de responsabilidad, cada grupo puede avanzar sin negociar, los cambios se revisan entre quienes entienden el contexto y los conflictos de integración se vuelven raros. Cuando no coinciden, ocurre lo contrario y no hay proceso que lo arregle: dos equipos que comparten un módulo compartirán también sus bloqueos, sus reversiones y sus conversaciones sobre estilo. De ahí se sigue el criterio más útil para diseñar un grafo, que no es técnico. Antes de decidir dónde parte un módulo, mira quién trabaja en qué y con qué frecuencia. Las líneas correctas son las que separan trabajos que rara vez se tocan, y las incorrectas son las que atraviesan por la mitad una actividad que siempre se hace junta. Un grafo elegante que obliga a tocar cinco módulos para añadir un campo es peor que uno tosco donde ese cambio cabe en dos, aunque el primero se dibuje mejor en una presentación. La segunda mitad del argumento es la que explica por qué esto importa a largo plazo. Las convenciones dependen de la memoria colectiva, y la memoria colectiva de un equipo tiene una vida media sorprendentemente corta: entre la rotación normal, los cambios de proyecto y la incorporación de gente nueva, el conocimiento de por qué las cosas están como están se pierde en un par de años. Lo que sobrevive a ese olvido no es lo que estaba documentado sino lo que estaba comprobado, porque una regla que rompe la construcción se explica sola en el momento exacto en que alguien la infringe, mientras que una regla escrita en un documento interno solo la lee quien ya la conocía. Modularizar es, en ese sentido, un acto de escritura dirigido a personas que todavía no han llegado al proyecto: es la forma de que las decisiones que hoy sostienen la arquitectura sigan sosteniéndola cuando nadie de los que las tomaron siga por aquí. Y como todo lo que se escribe para el futuro, su valor depende menos de lo sofisticado que sea el resultado que de que diga la verdad sobre cómo funciona realmente este sistema.

⚔️ Dibuja y comprueba tu grafo
  1. Genera el grafo de dependencias real de tu proyecto y marca cada arista que no debería existir. Escribe para cada una qué la introdujo.
  2. Mide tus tiempos de compilación con el análisis de la construcción antes de tocar nada y averigua qué fase domina realmente el reloj.
  3. Revisa todas tus declaraciones api y convierte en implementation cada una que no exponga tipos en su superficie pública. Vuelve a medir.
  4. Marca como internal todo lo que nadie usa desde fuera de su módulo y comprueba cuánto de tu superficie pública era accidental.
  5. Extrae la configuración repetida de tus ficheros de construcción a un complemento de convención y añade en integración continua una comprobación que impida que dos módulos de funcionalidad dependan entre sí.