Version catalogs: libs.versions.toml como fuente única de verdad
Un proyecto multi módulo sin catálogo termina siempre igual: la misma librería declarada en siete ficheros con tres versiones distintas, y un conflicto de resolución que nadie sabe de dónde sale. El catálogo de versiones resuelve el problema en la capa correcta, declarando las coordenadas una sola vez en un fichero TOML que Gradle lee durante la inicialización y convierte en un accesor tipado y autocompletable. Esta lección recorre las cuatro secciones del formato —versions, libraries, bundles y plugins—, explica las reglas de nombrado que determinan cómo se generan los accesores, distingue el papel del catálogo del de una BOM, y aterriza el gobierno del día a día: actualizaciones controladas, catálogos compartidos entre repositorios y diagnóstico de conflictos de versión.
Todo proyecto Android que crece atraviesa la misma degradación. Al principio las dependencias caben en un fichero y se leen de un vistazo. Luego llegan los módulos, y con ellos la misma librería declarada en siete sitios. Alguien actualiza seis de los siete y el séptimo se queda atrás; Gradle resuelve el conflicto subiendo a la versión más alta y durante meses nadie nota nada, hasta que un día una biblioteca transitiva cambia una firma y un módulo compila mientras otro estalla. La solución artesanal —constantes en el fichero raíz, un ext con un mapa de versiones, un fichero de dependencias importado en todas partes— funciona a medias y no la entiende el IDE. El catálogo de versiones ataca el problema donde corresponde: convierte las coordenadas de tus dependencias en datos declarativos, con un fichero que es la única fuente de verdad y del que Gradle deriva un accesor tipado.
- Entender qué problema estructural resuelve el catálogo y por qué las soluciones artesanales no bastan.
- Dominar las cuatro secciones de
libs.versions.tomly las reglas de nombrado de los alias. - Usar
bundlesypluginspara reducir ruido y unificar la aplicación de plugins. - Gobernar versiones en la práctica: BOM, rangos, catálogos compartidos y diagnóstico de conflictos.
Un fichero, cuatro secciones
El catálogo vive en gradle/libs.versions.toml y Gradle lo descubre por convención, sin que tengas que declarar nada. Se lee durante la fase de inicialización, antes de que exista ningún proyecto, y de su contenido se genera un objeto accesible desde cualquier módulo. El formato tiene exactamente cuatro secciones y cada una responde a una pregunta distinta.
[versions]
agp = "8.13.0"
kotlin = "2.2.20"
composeBom = "2025.09.00"
room = "2.8.0"
coroutines = "1.10.2"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.17.0" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-material3 = { module = "androidx.compose.material3:material3" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
[bundles]
compose = ["androidx-compose-ui", "androidx-material3"]
room = ["room-runtime"]
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
versions declara números con nombre para poder referenciarlos desde varios sitios; su valor está en agrupar familias que deben moverse juntas, como todos los artefactos de Room o todos los de coroutines. libraries declara coordenadas completas: module es el grupo y el artefacto, y la versión puede ir literal o como referencia. bundles agrupa alias que siempre se declaran juntos. plugins hace lo mismo para los identificadores de plugin, que hasta la llegada del catálogo se repetían con su versión en cada fichero.
androidx-compose-ui y androidx-material3 no declaran versión, y no es un olvido. Su versión la fija la BOM de Compose en tiempo de resolución. El catálogo y la BOM resuelven problemas complementarios: el catálogo unifica dónde se declara una coordenada, la BOM unifica qué versión se resuelve dentro de una familia de artefactos publicada en bloque. Usar los dos a la vez es lo idiomático, no una redundancia.
Del alias al accesor: la regla que hay que conocer
Gradle convierte cada alias del catálogo en una propiedad de un accesor generado, y la regla de traducción es sencilla pero hay que saberla: los guiones y los puntos del alias se convierten en niveles de anidamiento separados por punto, y los nombres resultantes se escriben en estilo de camello. Así, androidx-core-ktx se consume como libs.androidx.core.ktx, y kotlinx-coroutines-test como libs.kotlinx.coroutines.test. Los bundles viven bajo libs.bundles y los plugins bajo libs.plugins.
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.compose) // varias librerias de un golpe
implementation(libs.androidx.core.ktx)
implementation(libs.room.runtime)
ksp(libs.room.compiler)
testImplementation(libs.kotlinx.coroutines.test)
}
Lo importante de este accesor es que está tipado. No es una cadena que se resuelve por nombre: es una propiedad generada, así que el IDE la autocompleta, un alias inexistente es un error de compilación del script y renombrar en el TOML rompe el build de inmediato en lugar de silenciosamente. Ese es exactamente el mismo argumento que justificaba el DSL de Kotlin en la lección anterior, aplicado ahora a las coordenadas de las dependencias.
Un alias no puede coincidir con el prefijo de otro alias más largo si eso obliga a que un mismo nombre sea a la vez valor y contenedor. Si declaras room y también room-compiler, libs.room tendría que ser simultáneamente una dependencia y un objeto con hijos. Gradle lo rechaza. La convención que evita el problema desde el principio: nombra siempre con al menos dos segmentos, del más general al más específico —room-runtime, room-compiler, room-testing— y no uses nunca el nombre desnudo de la familia.
Cuándo se lee y por qué eso importa
flowchart TD A[Fase de inicializacion - Gradle lee gradle barra libs punto versions punto toml] --> B[Genera el accesor tipado libs para todo el build] B --> C[Modulo app usa libs punto androidx punto core punto ktx] B --> D[Modulo datos usa libs punto room punto runtime] B --> E[Modulo diseno usa libs punto bundles punto compose] C --> F[Una sola declaracion de version por coordenada] D --> F E --> F style A fill:#89b4fa,color:#11111b style B fill:#cba6f7,color:#11111b style F fill:#a6e3a1,color:#11111b
Que el catálogo se lea en la fase de inicialización tiene dos consecuencias prácticas. La primera es que está disponible en absolutamente todos los módulos sin importarlo ni propagarlo: no hay que aplicar nada, el accesor sencillamente existe. La segunda es que también está disponible en settings.gradle.kts y en los plugins de convención que escribas dentro de buildSrc o de un módulo incluido, aunque en esos casos el acceso sea algo más ceremonioso porque el accesor generado no está en el ámbito. Esa combinación —catálogo para las versiones, plugins de convención para la configuración repetida— es el patrón estándar de los proyectos multi módulo serios: el TOML dice qué versiones, el plugin de convención dice qué configuración, y ningún módulo repite ninguna de las dos cosas.
Una versión, un sitio
Actualizar una librería es editar una línea de un fichero. Deja de existir la posibilidad de que dos módulos discrepen, porque estructuralmente no hay dónde discrepar.
Revisable en el diff
Cada subida de versión aparece como un cambio de una línea en un fichero declarativo. Revisar qué se actualizó en una semana es leer el historial de un TOML, no arqueología en quince ficheros.
Bundles contra el ruido
Compose, Room o el conjunto de pruebas se declaran como un bundle y se consumen en una línea. El fichero de cada módulo vuelve a caber en una pantalla.
Compartible entre repositorios
Un catálogo puede publicarse como artefacto e importarse en varios proyectos desde settings.gradle.kts. Es la vía para que una organización imponga una línea base común de versiones.
Gobernar versiones, no solo declararlas
El catálogo ordena la declaración, pero no decide por ti. Tres asuntos siguen requiriendo criterio. El primero es la estrategia de actualización: fija versiones exactas y súbelas de forma deliberada; los rangos abiertos convierten tu build en no reproducible, porque el mismo commit compilado en dos momentos distintos puede resolver dependencias distintas. El segundo es la resolución de conflictos: cuando dos dependencias transitivas piden versiones distintas del mismo módulo, Gradle elige la más alta por defecto, y esa decisión silenciosa merece vigilancia. El tercero es el bloqueo, cuando necesitas garantía total de reproducibilidad.
# Que versiones se resolvieron de verdad y quien las pidio
./gradlew :app:dependencies --configuration releaseRuntimeClasspath
# Por que una dependencia concreta acabo en la version que acabo
./gradlew :app:dependencyInsight --dependency kotlinx-coroutines-core \
--configuration releaseRuntimeClasspath
// Forzar una resolucion cuando el conflicto no se puede arreglar aguas arriba
configurations.configureEach {
resolutionStrategy {
force(libs.kotlinx.coroutines.core.get().toString())
failOnVersionConflict() // en vez de elegir la mas alta en silencio
}
}
dependencyInsight es la herramienta que resuelve las discusiones: te dice qué versión ganó, quién la pidió y por qué mecanismo. failOnVersionConflict es más agresivo de lo que parece y no siempre conviene, pero activarlo una tarde en un proyecto grande es un ejercicio revelador: descubrirás conflictos que llevaban años resolviéndose solos con criterios que nadie eligió.
Renovate y Dependabot entienden el formato del catálogo y proponen los cambios como ediciones de una sola línea en el TOML. Eso convierte la actualización de dependencias en una revisión trivial en lugar de un cambio disperso. Si además agrupas familias bajo una misma entrada de versions, cada propuesta sube la familia entera de forma coherente, que es justo lo que quieres con Room, con coroutines o con el propio Kotlin y su plugin de Compose.
Lo que el catálogo de versiones enseña no es un formato de fichero: es una discriminación que aparece en todos los sistemas grandes y que casi siempre se resuelve tarde y mal. En cualquier configuración conviven dos cosas de naturaleza distinta. Están los hechos —esta librería vive en estas coordenadas, esta familia va por esta versión, este plugin tiene este identificador— que son datos puros, sin lógica, enumerables, comparables y perfectamente representables en un fichero declarativo. Y está la política —qué se compila con qué, qué módulo depende de cuál, cómo se firma una variante— que sí es lógica y necesita un lenguaje. El desastre empieza cuando se mezclan: cuando los hechos se codifican dentro de la lógica, se duplican en cada sitio donde la lógica se repite, y a partir de ahí ya no existe la versión de una librería, existen siete versiones que casualmente coinciden. Nadie diseña eso; se llega por acumulación, un módulo cada vez. La corrección estructural es extraer los hechos a un lugar donde no puedan duplicarse, y observa la propiedad decisiva del arreglo: no es que sea más ordenado, es que hace imposible la clase entera de errores. Con un catálogo no puedes tener dos versiones de Room declaradas en dos módulos, no porque el equipo tenga disciplina, sino porque no hay un segundo sitio donde escribirla. Esa es la diferencia entre una convención y una garantía, y es la vara con la que deberías juzgar cualquier propuesta de organización: si la corrección depende de que todo el mundo se acuerde, no has resuelto el problema, lo has documentado. Los proyectos que envejecen bien no son los que tienen equipos más cuidadosos, son los que fueron colocando cada hecho en un único sitio antes de que hubiera un segundo sitio donde ponerlo.
- Inventaria las dependencias de un proyecto multi módulo real y localiza al menos una coordenada declarada en más de un fichero. Comprueba si las versiones coinciden.
- Crea
gradle/libs.versions.tomlcon las cuatro secciones y migra un módulo entero, incluidos los plugins, usandoaliasen el bloqueplugins. - Agrupa en un
bundlelas dependencias de Compose y otro con las de pruebas. Mide cuántas líneas desaparecen del fichero del módulo. - Provoca a propósito un alias inválido y otro con el conflicto de prefijos descrito arriba. Lee los mensajes de error y explica la regla de nombrado con tus palabras.
- Ejecuta
dependencyInsightsobre una dependencia transitiva que aparezca en varias versiones, identifica quién pide cada una y decide de forma razonada si conviene forzar o corregir aguas arriba.