Gradle Dependency Declarations in Android Studio: Catalog vs buildSrc vs Direct
Compare direct Gradle declarations, buildSrc constants, and version catalogs for multi-module Android projects, with a TOML example, validation commands, and rollback steps.
29 Mar 2026, 01:13 UTC

The decision: where do dependency versions live?
In a multi-module Android project, every module's build file needs coordinates and versions for the libraries it uses. The engineering decision is whether those strings live in each module's build file, in Kotlin constants under buildSrc, or in a Gradle version catalog at gradle/libs.versions.toml. That choice affects how much duplication you carry, how well Android Studio can autocomplete and navigate dependency references, and how expensive a routine version bump becomes.
One constraint usually settles the question: version catalogs are a Gradle feature, not an Android Studio feature. Your Gradle wrapper and Android Gradle Plugin versions must support them, and IDE support for the generated libs.* accessors varies by Android Studio release. Treat everything below as version-sensitive and confirm it against your own toolchain before migrating a large project.
The three options at a glance
| Approach | Where versions live | IDE support | Best fit | Main cost |
|---|---|---|---|---|
| Direct declarations | Each module's build.gradle(.kts) | Full, no setup | Single-module or very small projects | Duplicated version strings; drift as modules grow |
buildSrc constants | Kotlin source in a buildSrc module | Full, because it is ordinary Kotlin | Projects that need computed or conditional values | Changes recompile build logic and invalidate caches |
| Version catalog | gradle/libs.versions.toml | libs.* accessors; quality varies by release | Multi-module apps wanting one source of truth | Naming discipline; Gradle version requirement |
Trade-offs that actually matter
Duplication versus indirection
Direct declarations require no setup and no new concepts. Their weakness is arithmetic: with N modules and M shared libraries, a version bump is up to N edits, and any missed edit produces two versions of the same artifact on the classpath. A catalog or buildSrc collapses that to one edit, at the cost of an extra indirection layer that new contributors have to learn.
Build configuration cost
buildSrc is compiled Kotlin that participates in the build. Editing it forces Gradle to recompile that code and re-run build logic, which is heavier than editing a data file. That makes buildSrc a poor home for values you change often, such as library versions, and a reasonable home for logic you change rarely, such as a function that computes a version suffix. A version catalog is declarative data, so changing an entry is a cheaper operation — though it still changes the build configuration and therefore still invalidates configuration caches.
IDE support
Catalog entries generate typed accessors such as libs.androidx.core.ktx, which give completion, navigation to the TOML entry, and find-usages. Whether a given Android Studio release resolves those accessors reliably is exactly the kind of detail that changes between releases. If the IDE shows an unresolved reference on a libs.* accessor, do not assume the build is broken — run a Gradle sync and, if needed, a command-line build to separate an IDE indexing problem from a real configuration error.
What none of them fix
None of these mechanisms replaces dependency resolution rules, BOM or platform alignment, or conflict resolution. Those belong in build logic. A catalog tells Gradle which version you asked for; it does not guarantee which version wins after constraint resolution.
A concrete catalog setup
The file below uses placeholder versions and coordinates. Replace them with the real values from your project; nothing here has been executed against a specific toolchain.
# gradle/libs.versions.toml
[versions]
coreKtx = "1.2.3" # placeholder
agp = "8.0.0" # placeholder
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.2.3" }
[bundles]
ui = ["androidx-core-ktx", "androidx-appcompat"]
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Alias names map to accessors by splitting on hyphens and dots: androidx-core-ktx becomes libs.androidx.core.ktx, and the bundle becomes libs.bundles.ui. A module then consumes it like this:
// app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
}
dependencies {
implementation(libs.bundles.ui)
}
Bundles are the main reason catalogs scale well: a group of libraries that are always declared together becomes one line per module, and adding a library to the bundle updates every consumer at once.
Validating the change
- Run a Gradle sync in Android Studio. Expected check: no unresolved reference on
libs.*accessors in any module. - From the project root, using the project's wrapper, inspect the resolved graph for one module:
./gradlew :app:dependencies --configuration debugRuntimeClasspath. Configuration names differ between build types and flavors, so substitute the one you actually build. Expected check: each migrated library appears once, at the version in the catalog. - Confirm no dependency is declared twice — once as a catalog reference and once as a raw string. That mixture is the most common cause of a version conflict that is hard to trace.
- Add a small module that consumes the catalog to confirm accessors work across module boundaries, not just in the module you edited first.
- Use the Build Analyzer or a build scan to compare configuration and dependency-resolution times before and after, if build performance is a concern.
No special permissions are needed for these commands; they run with your normal user account. The main risk is a long, noisy dependencies report on large projects, which is why scoping it to one configuration is worth the extra flag.
Limitations and things to check
- TOML schema details, accessor generation rules, and plugin-alias behavior depend on the Gradle version in your wrapper. Verify compatibility before migrating, and check the Android Gradle Plugin version alongside it.
- Catalog support in the IDE is not guaranteed to match Gradle's support. A project can build correctly while the editor still flags accessors.
- Catalogs do not express BOM alignment or resolution strategy. Keep
platform()and constraint declarations in build logic. - A catalog is a single source of truth only if you enforce it. Without review discipline, direct declarations creep back in.
Rolling back
Because migration edits build files, keep it in one commit. To revert, restore the original dependency lines in each module's build file, remove the alias(...) plugin calls, and delete gradle/libs.versions.toml. If you migrated incrementally, revert module by module and re-run the dependency inspection after each step so you can see exactly which change reintroduced a conflict.
Choosing
- Single module, few dependencies: stay with direct declarations.
- Multi-module app on a Gradle version that supports catalogs: use a version catalog, and keep it to versions, coordinates, bundles, and plugin aliases.
- You need computed versions or conditional dependency logic: use
buildSrc, and keep frequently changing version numbers out of it. - You need both: a catalog for coordinates,
buildSrcfor the logic that consumes them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.