Managing dependencies in multi-module Android projects has historically been a fragmented experience. Developers often juggle hardcoded strings across multiple build.gradle files, rely on the brittle ext block, or use buildSrc, which notoriously invalidates the build cache upon every change.
Google and Gradle now recommend Version Catalogs as the standard solution. However, migrating an existing codebase to the libs.versions.toml standard often results in confusing "unresolved reference" errors and IDE red-lining.
This guide provides a rigorous, step-by-step migration path, explains the mechanics of the generated type-safe accessors, and solves the common friction points encountered during implementation.
The Architecture: Why Version Catalogs?
Before writing code, it is critical to understand the architectural improvement.
Previously, many teams used buildSrc to manage dependencies in Kotlin. While this provided autocomplete, it had a major flaw: any change within buildSrc required Gradle to rebuild the entire build logic, invalidating the project-wide cache. This significantly slowed down build times for minor version bumps.
Version Catalogs (libs.versions.toml) solve this by:
- Decoupling configuration: Changes to the TOML file do not trigger a full build-logic recompilation.
- Standardization: It provides a standard format parsable by external tools (like Dependabot or Renovate).
- Type-Safety: Gradle generates type-safe accessors for your build scripts, eliminating typos in group or artifact IDs.
Step 1: Configuring the TOML File
Create a file named libs.versions.toml inside the gradle folder of your root project structure. If the gradle directory does not exist, create it at the root level.
Gradle automatically looks for a file at gradle/libs.versions.toml. If you place it elsewhere, it will not work without manual configuration in settings.gradle.kts.
Copy the following structure into gradle/libs.versions.toml. This example covers standard libraries, plugins, and strict version references.
[versions]
# Define versions in a single location
kotlin = "1.9.22"
agp = "8.2.2"
compose = "1.6.1"
core-ktx = "1.12.0"
junit = "4.13.2"
androidx-test-ext = "1.1.5"
retrofit = "2.9.0"
[libraries]
# Format: "alias-name" = { group = "...", name = "...", version.ref = "..." }
# Android Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
# Compose (Bill of Materials pattern recommended)
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2024.02.00" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
# Networking
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" }
[plugins]
# Plugins require specific IDs
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" }
[bundles]
# Group related libraries to implement them in one line
compose-ui = [
"androidx-compose-ui",
"androidx-compose-graphics",
"androidx-compose-preview"
]
networking = ["retrofit-core", "retrofit-gson"]
Step 2: Enabling the Catalog in Settings
While Gradle 7.4+ detects the standard path automatically, explicitly declaring it in settings.gradle.kts ensures that your build does not silently fail if the file logic is ambiguous. It also allows you to handle edge cases where the file might be named differently.
Open your root settings.gradle.kts:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
// Explicitly declaring the version catalog (Optional if using standard path)
versionCatalogs {
create("libs") {
from(files("gradle/libs.versions.toml"))
}
}
}
rootProject.name = "MyAndroidProject"
include(":app")
Note: After editing this file, perform a Gradle Sync. This is the step where Gradle parses the TOML and generates the libs accessor class.
Step 3: Migrating the Root Build Script
The root build.gradle.kts usually manages plugin versions for the entire project. We use the alias keyword instead of id to leverage the version catalog.
Old Way (Hardcoded):
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}
New Way (Version Catalog):
// root build.gradle.kts
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
}
If libs is highlighted in red, do not panic. This is often an IDE indexing delay. Run a generic build command (./gradlew assembleDebug) to verify correctness.
Step 4: Migrating Module Dependencies
Now, migrate the app-level build.gradle.kts. This is where the syntax mapping rules (dashes to dots) become important.
Old Way:
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
}
New Way:
// app/build.gradle.kts
dependencies {
// Standard library access
implementation(libs.androidx.core.ktx)
// Using the "Bundle" defined in TOML (Imports Retrofit + Gson)
implementation(libs.bundles.networking)
// BOM implementation
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext)
}
Deep Dive: How Gradle Maps Aliases
The most common source of "Unresolved Reference" errors is misunderstanding how Gradle maps TOML keys to Kotlin properties.
Gradle separates names by dashes (-), underscores (_), or dots (.) and converts them to camelCase accessors.
The Mapping Rules
TOML Key:
androidx-core-ktx- Separator:
- - Kotlin Accessor:
libs.androidx.core.ktx(Gradle creates sub-accessors for readability).
- Separator:
TOML Key:
retrofit- Kotlin Accessor:
libs.retrofit
- Kotlin Accessor:
TOML Key:
google-material- Kotlin Accessor:
libs.google.material
- Kotlin Accessor:
Crucial Warning: If you name a library androidx-compose-ui and another androidx-compose, Gradle generates an accessor for androidx.compose. You cannot then use androidx.compose as a parent accessor for .ui.
Invalid Collision Example:
# This causes issues
compose = { ... }
compose-ui = { ... }
Gradle generates libs.compose (the library) and tries to generate libs.compose.ui (the nested accessor). This collision causes the generation to fail or behave unpredictably. Ensure strict naming hierarchies.
Handling "Unresolved Reference: libs"
If you have synced Gradle and libs is still unresolved in your Kotlin DSL files, follow this troubleshooting checklist:
- Filename Check: Ensure the file is exactly
gradle/libs.versions.toml. Notlib.versions.tomlorlibs.version.toml. - Plugin Block Syntax: In the
plugins {}block, you must usealias(libs.plugins.xxx). You cannot useid(libs.plugins.xxx). - Invalid TOML: Run
./gradlew dependenciesin the terminal. If the TOML is invalid (e.g., missing quotes, duplicate keys), the build will fail immediately with a descriptive parser error. - IDE Cache: Android Studio sometimes caches the old build context.
- File > Invalidate Caches > Check "Clear file system cache" > Restart.
Advanced: Using Bundles for Cleaner Builds
Bundles are the most underutilized feature of Version Catalogs. They allow you to define a set of libraries that always travel together.
Instead of repeating these lines in every feature module:
implementation(libs.retrofit.core)
implementation(libs.retrofit.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
Define a bundle in TOML:
[bundles]
network-stack = ["retrofit-core", "retrofit-gson", "okhttp", "okhttp-logging"]
And import heavily reduced code:
implementation(libs.bundles.network.stack)
Conclusion
Migrating to Gradle Version Catalogs is an upfront investment that pays dividends in build stability and maintainability. By decoupling version definitions from build logic, you reduce cache invalidation and streamline multi-module dependency management.
The key to a successful migration is precise TOML syntax and understanding the kebab-case to camelCase mapping that Gradle performs during the accessor generation phase. Once configured, your project is future-proofed for modern Android development standards.