El coste: sobre-modularizar y cuándo un solo módulo es lo correcto
La modularización tiene una curva de rendimientos que se invierte. El coste fijo por módulo, la fricción de la superficie pública, el enlazado que se dispara con cuarenta targets y el criterio honesto para decidir que un módulo único es la respuesta correcta.
Toda técnica de ingeniería tiene una curva y casi ninguna se enseña con ella. La modularización se presenta como una escala monótona —más módulos, más orden, más velocidad— y no lo es: hay un punto, distinto para cada proyecto, a partir del cual cada módulo nuevo cuesta más de lo que rinde. El coste no aparece de golpe ni con un mensaje de error; se acumula en forma de manifiestos que mantener, de public que escribir, de cambios triviales que ahora tocan cuatro paquetes, de un enlazado que crece hasta comerse lo que ahorró la compilación paralela, y de una carga cognitiva nueva que consiste en saber dónde vive cada cosa en un proyecto con cuarenta y tres módulos. Esta lección es la contraparte honesta de las cuatro anteriores: qué se paga exactamente, dónde está el punto de inversión y por qué en un número considerable de proyectos reales la respuesta correcta, tras medir, sigue siendo un solo módulo.
- Enumerar los costes fijos y variables que introduce cada módulo nuevo.
- Explicar por qué la superficie
publiccrea fricción sostenida y no puntual. - Reconocer los síntomas de un proyecto sobre-modularizado y su efecto en el enlazado.
- Aplicar un criterio explícito para decidir el número de módulos, incluido el caso de uno.
Lo que cuesta cada módulo
El primer coste es de escritura y es el más visible: un manifiesto que actualizar, un producto que declarar, una entrada en la lista de dependencias del consumidor y, si el módulo se prueba, un testTarget. Es tedioso pero acotado, y no es el que hace daño.
Tampoco lo es el esquema, aunque duela: cada módulo con demo o con tests propios quiere su esquema en Xcode, y los esquemas compartidos se versionan, se rompen y hay que mantenerlos. Súmale la configuración de integración continua, que deja de ser una línea y pasa a ser una matriz. Todo esto es trabajo real que nadie contabiliza al decidir, porque ocurre después y lo hace otra persona.
El segundo coste es la fricción de la frontera, y ese sí es permanente. Dentro de un módulo, mover un método de un tipo a otro es una operación de treinta segundos. Cruzando una frontera, el mismo movimiento exige decidir si el símbolo es public, escribir el init que Swift ya no sintetiza como accesible, propagar Sendable si el tipo cruza dominios de aislamiento, y a veces marcar @inlinable o @frozen si el rendimiento lo requiere. Ninguna de esas cosas es cara por separado; todas juntas convierten un refactor de tarde en un refactor de día, y su efecto real es que la gente deja de hacer refactors pequeños. El diseño se endurece no porque nadie vea la mejora, sino porque el coste de aplicarla dejó de ser trivial.
// Dentro de un modulo: esto ya funciona
struct Cupon { let codigo: String; let descuento: Decimal }
// Cruzando una frontera: lo mismo, con impuestos
public struct Cupon: Sendable {
public let codigo: String
public let descuento: Decimal
public init(codigo: String, descuento: Decimal) {
self.codigo = codigo
self.descuento = descuento
}
}
Ese ejemplo parece anecdótico y no lo es: es la unidad de medida del coste. Multiplícalo por los cuarenta tipos que cruzan fronteras en un proyecto mediano y tendrás varios cientos de líneas que no expresan ninguna regla de negocio, que hay que mantener sincronizadas con los campos y que se convierten en ruido en cada revisión de código. Swift ha ido limando estos bordes —el init público sigue siendo obligatorio, pero la inferencia de Sendable y los valores por defecto ayudan—, aunque el fondo del asunto no desaparece: publicar un tipo es un acto de compromiso y los compromisos cuestan.
El tercer coste es de compilación y aparece tarde. Cada módulo tiene un preámbulo fijo: arrancar el proceso del compilador, cargar los módulos de los que depende, emitir la interfaz del suyo, y luego enlazar. Con ocho módulos ese preámbulo es ruido; con cuarenta empieza a dominar. El enlazado en particular es la fase que peor escala, porque es en gran medida serial y crece con el número de unidades y símbolos. Es perfectamente posible llegar a una configuración donde la compilación limpia tarda el doble que en el monolito y la incremental apenas mejora, porque el módulo que tocas siempre acaba invalidando a media docena.
El cuarto coste es humano y no aparece en ninguna medición. En un proyecto con cuarenta y tres módulos, la pregunta «dónde vive esto» deja de tener respuesta obvia, y la respuesta a «dónde debería vivir esto nuevo» se convierte en una discusión recurrente. Esa discusión tiene un coste agregado enorme y es invisible en las gráficas de build.
Hay un quinto coste que solo se descubre operando: la herramienta. Un espacio de trabajo con muchos paquetes tarda más en indexar, la resolución de dependencias se ejecuta con más frecuencia, la caché de módulos ocupa más disco y se invalida más a menudo, y el autocompletado empeora justo cuando el proyecto es más grande y más lo necesitas. Nada de esto es fatal por separado, y todo junto produce la sensación difusa de que el entorno «va lento» sin que nadie pueda señalar la causa. La sensación es real y su causa está en el número de unidades.
# Sintoma habitual de sobre-modularizacion: el enlazado domina la build
xcodebuild -scheme App -destination 'generic/platform=iOS' clean build \
-showBuildTimingSummary | tail -30
# Si Ld y SwiftDriver suman mas que SwiftCompile, sobran unidades
La curva y su punto de inversión
Los beneficios de modularizar crecen rápido al principio y se saturan; los costes crecen de forma aproximadamente lineal y no paran. La consecuencia es una curva neta con un máximo, y la única manera de saber dónde está es medir el proyecto propio.
La saturación tiene una explicación concreta y no es un artefacto retórico. El beneficio de recompilación depende de qué fracción del proyecto queda fuera de la unidad que tocas: pasar de un módulo a dos puede eliminar la mitad del trabajo, y pasar de veinte a veintiuno elimina una fracción despreciable. Los rendimientos decrecen porque cada división posterior separa una porción cada vez menor del total, mientras que el coste de cada división —manifiesto, superficie pública, preámbulo, enlazado— se mantiene aproximadamente constante. De ahí que la curva tenga máximo y no asíntota.
flowchart TB uno[Un modulo] --> pocos[Cinco a diez modulos] pocos --> muchos[Cuarenta modulos] uno --> c1[Sin manifiestos y sin fronteras verificadas] pocos --> c2[Beneficio maximo: incremental corta y previews rapidos] muchos --> c3[Enlazado dominante y refactors caros] style c2 fill:#a6e3a1,color:#11111b style c3 fill:#f38ba8,color:#11111b style c1 fill:#f9e2af,color:#11111b
Los síntomas de haber pasado el punto son concretos y se reconocen sin ambigüedad. Un cambio de una línea que obliga a tocar cuatro Package.swift. Módulos con un solo tipo dentro. Un Comun del que dependen todos, que es el monolito reconstruido con más burocracia. Compilación limpia que empeoró sin que la incremental mejorara. Y el síntoma definitivo: gente que evita mejoras de diseño porque cruzan fronteras, y que en su lugar duplica el código en el lado donde está trabajando.
Esa última conducta merece atención porque es silenciosa y acumulativa. Nadie anuncia que ha duplicado un formateador de fechas para no tener que publicarlo desde otro módulo; simplemente lo copia, el cambio pasa la revisión porque es pequeño y correcto, y seis meses después hay cinco formateadores que se comportan de forma sutilmente distinta. La sobre-modularización no produce errores de compilación: produce incentivos. Y los incentivos actúan sobre decisiones que individualmente parecen razonables, lo que hace que el deterioro sea invisible hasta que es estructural.
Merece la pena decir además que el número correcto de módulos no es una constante del proyecto sino una función de su tamaño y de su equipo, y que puede bajar. Fusionar dos módulos que siempre cambian juntos es una operación legítima, sana y prácticamente nunca considerada, porque la cultura técnica trata la división como progreso y la unión como retroceso. Si dos paquetes aparecen sistemáticamente en el mismo commit, la frontera entre ellos no está separando nada: está cobrando peaje.
Coste fijo por módulo
Manifiesto, producto, dependencias y testTarget. Tedioso, acotado y no es el que duele.
Fricción de frontera
public, init explícito y Sendable en cada cruce. El diseño se endurece porque refactorizar deja de ser barato.
Enlazado que domina
El preámbulo por módulo y el enlazado serial crecen. Con demasiadas unidades se comen el ahorro de la paralelización.
Coste cognitivo
Con cuarenta módulos, «dónde vive esto» deja de tener respuesta obvia y se convierte en discusión recurrente.
El caso legítimo del módulo único
Hay proyectos donde modularizar es objetivamente la decisión equivocada, y decirlo en voz alta es parte de saber ingeniería. Un solo módulo es correcto cuando la compilación incremental ya se mide en pocos segundos, cuando los preview arrancan rápido, cuando la persona que trabaja en el proyecto es una o dos y las fronteras se sostienen por conversación directa, cuando el producto todavía está buscando su forma y los límites entre partes cambian cada semana, o cuando el equipo no tiene todavía experiencia con paquetes y va a pagar la curva de aprendizaje en un momento en el que necesita entregar.
A esa lista hay que añadir un caso que suele ignorarse por incómodo: el proyecto que ya está modularizado y cuyo grafo no funciona. Deshacer una modularización mala es una decisión legítima y con frecuencia la de mejor retorno, porque fusionar dos módulos es una operación mecánica —mover archivos, borrar un manifiesto, eliminar dependencias— mientras que sostener un grafo enfermo es un impuesto diario e indefinido. La resistencia a hacerlo es cultural, no técnica.
Ese último punto merece énfasis. Modularizar temprano congela un diseño que aún no conoces. Las fronteras de módulo son caras de mover, y en un producto joven las fronteras correctas no se deducen: se descubren después de haber escrito el código y de haber visto qué cambia junto con qué. Un proyecto que se modulariza en la primera semana suele acabar con módulos que reflejan la arquitectura imaginada, no la real, y la fricción de mover fronteras hace que esa discrepancia se conserve durante años.
Existe además un término medio que casi nadie explora porque no tiene nombre pegadizo: un único paquete local con varios target dentro. Obtienes fronteras verificadas por el compilador y superficie pública explícita, pero mantienes un solo manifiesto, un solo lugar donde declarar dependencias y una resolución trivial. Para la mayoría de las apps de tamaño medio esta configuración captura casi todo el beneficio con una fracción de la burocracia, y sigue permitiendo separar en varios paquetes más adelante si el proyecto lo pide.
La estrategia con mejor retorno es incremental y su orden importa. Empieza por un módulo único con carpetas bien nombradas. Extrae primero lo estable y sin dependencias: el dominio, los tipos de valor, el sistema de diseño. Extrae después la feature que más recompilaciones sufre, que suele ser también la que más gente toca. Mide después de cada paso y detente cuando la medida deje de mejorar. Ese punto es tu óptimo y muy probablemente esté entre cinco y quince módulos, no en cuarenta.
La evidencia para trazar fronteras no está en un diagrama sino en el historial del repositorio, que registra qué archivos se modifican juntos. Ese acoplamiento observado es el único dato empírico disponible sobre qué cambia con qué, y contradice con frecuencia la arquitectura que el equipo cree tener.
# Que archivos cambian juntos: evidencia de acoplamiento real
git log --format='%H' --since='6 months ago' \
| while read c; do git diff-tree --no-commit-id --name-only -r "$c"; echo "--"; done \
| awk '/^--$/{print ""} !/^--$/{printf "%s ", $0}' \
| sort | uniq -c | sort -rn | head -20
Si dos rutas aparecen sistemáticamente en la misma línea, pertenecen al mismo módulo aunque el diagrama diga lo contrario. Si una ruta aparece siempre sola, es candidata a extracción aunque hoy viva en el mismo target que todo lo demás. Es una heurística tosca y sesgada por la historia del equipo, y sigue siendo mejor evidencia que cualquier intuición formulada antes de escribir el código.
# La comprobacion honesta tras cada extraccion
touch Modulos/Sources/FeaturePerfil/PerfilView.swift
time xcodebuild -scheme App -destination 'generic/platform=iOS' build
# Si este numero no baja, la extraccion anterior no rindio:
# revisa el grafo antes de extraer la siguiente
Un módulo es una predicción. Al trazar una frontera estás afirmando que lo de dentro cambiará junto y lo de fuera cambiará por separado, y esa afirmación es sobre el futuro, sobre requisitos que aún no existen y sobre un producto que todavía no sabes en qué se va a convertir. Cuando la predicción acierta, el módulo rinde de forma compuesta: las recompilaciones se acotan, los tests se aíslan, la feature se reutiliza. Cuando falla —y falla siempre que se traza demasiado pronto— obtienes lo contrario exacto de lo que buscabas: cada cambio de requisito atraviesa cuatro fronteras, y cada travesía cobra su peaje de public, de init y de manifiesto. Lo que hace el problema estructuralmente difícil es que la información necesaria para acertar solo llega después de haber escrito el código y de haberlo visto evolucionar unos meses; el historial del control de versiones, que registra qué archivos se modifican en el mismo commit, es una fuente de evidencia sobre acoplamiento real infinitamente mejor que cualquier diagrama dibujado antes de empezar. De ahí que la regla más útil no sea una regla de arquitectura sino de secuencia: retrasa la decisión hasta que tengas datos, y cuando los tengas extrae primero lo que el historial demuestre que cambia solo. Esta es la misma lógica que sostiene el consejo de no abstraer con una sola implementación, de no diseñar la base de datos antes de conocer las consultas y de no elegir la topología de microservicios antes del primer usuario. En todos los casos la tentación es idéntica y su origen también: la estructura da sensación de control, y esa sensación es especialmente seductora justo cuando menos se sabe. La madurez técnica no consiste en modularizar bien, sino en saber cuándo todavía no toca.
Cada módulo cuesta un manifiesto, una superficie pública que sostener, un preámbulo de compilación y un enlazado que escala mal, más una carga cognitiva que no aparece en ninguna gráfica. La curva de beneficio neto tiene un máximo, normalmente entre cinco y quince módulos. Un solo módulo es la respuesta correcta cuando la incremental ya es rápida, el equipo es pequeño o el producto aún busca su forma: modularizar temprano congela fronteras que todavía no se conocen.
- Cuenta los módulos de tu proyecto y localiza los que contienen menos de tres tipos; argumenta si justifican su manifiesto.
- Cronometra un refactor que cruce una frontera y el mismo refactor dentro de un módulo, y compara los dos tiempos.
- Mide qué fracción de tu compilación limpia se va en enlazado y observa cómo cambia al añadir un módulo más.
- Extrae del historial de tu repositorio los archivos que más veces cambian en el mismo commit y contrástalos con tus fronteras actuales.
- Escribe el caso más fuerte que puedas a favor de fusionar dos de tus módulos y defiéndelo con las medidas que has tomado.