Gradle Basics Explained

BUILD & TOOLING › Gradle

Every Android Gradle project is built from three kinds of files, and interviewers expect you to place each one instantly. settings.gradle.kts sits at the root and does the bootstrapping: it registers which modules exist with include(...), and it configures where Gradle looks for plugins and dependencies, through pluginManagement and dependencyResolutionManagement. The root build.gradle.kts declares plugin versions for the whole project, almost always paired with apply false. Each module's own build.gradle.kts holds that module's android {} block and its dependencies.

These files map onto Gradle's three build phases, which run on every single invocation. Initialization reads settings.gradle.kts and creates a Project object for each included module. Configuration then runs every module's build.gradle.kts top to bottom, evaluating the DSL and building the task graph, and this happens even if you only asked for one task. Execution finally runs just the tasks that are actually needed, skipping anything already up to date. Keep that configuration phase in mind, it matters again later when caching comes up.

// settings.gradle.kts
pluginManagement {
    repositories { google(); mavenCentral() }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories { google(); mavenCentral() }
}
rootProject.name = "MyApp"
include(":app", ":core", ":feature-login")

Modern Android projects default to the Kotlin DSL, files ending in .kts, over the older Groovy DSL, plain .gradle files. Both produce identical build outputs. The difference is entirely in the authoring experience. Kotlin DSL is statically typed, so Android Studio gives real autocomplete and catches mistakes at edit time. Write compileSdk = 34 with the wrong type in Kotlin DSL and the IDE flags it immediately, where the dynamically typed Groovy equivalent, compileSdkVersion 34, would only fail once you actually run a build. Same artifacts, better tooling, that's the whole pitch.

// build.gradle (Groovy DSL) - dynamic typing, no IDE checking
android {
    compileSdkVersion 34
    buildTypes { release { minifyEnabled false } }
}

// build.gradle.kts (Kotlin DSL) - statically typed, full autocomplete
android {
    compileSdk = 34                         // wrong type would be a compile-time error
    buildTypes { release { isMinifyEnabled = false } }
}

Plugins add build capabilities such as the Android Gradle Plugin, Kotlin support, or Hilt, and you declare them with the plugins {} block. In the root build.gradle.kts you pin each plugin's version but append apply false. This is the classic gotcha interviewers probe: apply false does not disable the plugin. It resolves and pins the version as a build-wide dependency so every module can apply that same plugin without repeating the version string, while keeping the plugin unapplied to the root project itself, which has no Android code of its own to build. Each module's own build.gradle.kts then applies the plugin for real.

// root build.gradle.kts - pins versions without applying to the root project
plugins {
    id("com.android.application")      version "8.5.0" apply false
    id("org.jetbrains.kotlin.android") version "2.0.0" apply false
}

// app/build.gradle.kts - applies without repeating the version
plugins {
    id("com.android.application")
}

Once a plugin is declared in the version catalog, you apply it with alias(libs.plugins...) rather than the plain id("...") form. alias() takes the type-safe accessor generated from the catalog's [plugins] table, so the version is already pinned centrally and there is nothing left to repeat. id("...") still has its place, it is what you reach for when applying a plugin that genuinely isn't declared in the catalog, such as a local convention plugin. Mixing them up, passing a catalog accessor into id(), simply won't compile.

// gradle/libs.versions.toml:
// [plugins]
// android-application = { id = "com.android.application", version.ref = "agp" }

// app/build.gradle.kts:
plugins {
    alias(libs.plugins.android.application)  // correct: sourced from the catalog
    // id("com.android.application")         // bypasses the catalog, must repeat version
}

The dependency configuration you choose controls where a dependency is visible: the compile classpath, the runtime classpath, both, and whether it leaks into modules that depend on yours. The two everyday choices are implementation and api. implementation puts a dependency on both classpaths of your own module only, nothing downstream sees it. api does the same but also exposes the dependency transitively, so a consumer of your module can reference that dependency's types directly.

Default to implementation. It keeps your module's internals encapsulated, and changing an implementation dependency only forces a recompile of your own module, not everything downstream. Reach for api only when the dependency's types genuinely appear in your module's own public function signatures, return types or parameters that other modules will compile against.

// library/build.gradle.kts
dependencies {
    // Retrofit is an internal detail, so use implementation, not api
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    // api(...) would leak Retrofit onto every consumer's compile classpath
}

api's transitivity is not an abstract rule, it changes what actually compiles downstream. If Module A declares a dependency with api, then anything that depends on Module A, including a Module B that only used implementation to reach Module A, still receives that dependency on its own compile classpath. Module B can import and reference those types directly, no reflection required. That is exactly why api should be the exception: declare it carelessly and a change to that dependency now forces a recompile everywhere it leaked to, not just in the module that declared it.

// module-a/build.gradle.kts
dependencies {
    api("com.example:widgets:1.0")        // exposed transitively to all consumers
}

// module-b/build.gradle.kts
dependencies {
    implementation(project(":module-a"))  // also gets widgets on its compile classpath
}

// In Module B source, this compiles because widgets is transitively available:
// import com.example.widgets.Widget
// val w = Widget()

Two more configurations round out the classpath picture. compileOnly puts a dependency on the compile classpath only, not the runtime classpath, so it is never packaged into the shipped APK. It exists for cases like annotation processors, or a library the runtime environment already provides on its own, where bundling it again would be redundant or could even break things.

// build.gradle.kts
dependencies {
    // On the compile classpath only, NOT packaged in the APK
    compileOnly("javax.annotation:jsr250-api:1.0")
    // The runtime environment must provide this library instead
}

runtimeOnly is compileOnly's mirror image. It makes a dependency available and packaged at runtime, but leaves it off the compile classpath entirely, so your own module's code cannot reference its types directly while compiling. A typical use is a JDBC driver or a similar implementation that your code only ever looks up by name or through a service loader, never imports directly.

// build.gradle.kts
dependencies {
    // Packaged in the APK and available at runtime, but NOT on the compile classpath
    runtimeOnly("com.h2database:h2:2.2.224")

    // Contrast:
    // compileOnly -> compile classpath only, not packaged
    // implementation -> both compile classpath and packaged
}

Test-scoped dependencies get their own configurations, and none of them leak into the shipped app. testImplementation reaches only the local JVM unit tests under src/test, things like JUnit or MockK that run on your development machine's JVM without an Android device. androidTestImplementation reaches only the instrumented tests under src/androidTest, libraries like Espresso that need an actual device or emulator. Neither crosses into the other's source set, and neither reaches production code.

// build.gradle.kts
dependencies {
    testImplementation("junit:junit:4.13.2")                  // src/test (local JVM) only
    androidTestImplementation("androidx.test.ext:junit:1.2.1") // src/androidTest only
    // neither one reaches the shipped APK
}

A version catalog centralizes dependency and plugin coordinates so every module references one shared, type-safe accessor instead of copy-pasted version strings scattered across build files. Gradle looks for it at gradle/libs.versions.toml by default, and using that exact name and location is what enables the type-safe accessors automatically, no extra wiring needed. The file has four sections: versions holds named version variables, libraries declares dependencies by group and name plus a version or version.ref, plugins declares plugin ids, and bundles groups several library aliases under one accessor.

# gradle/libs.versions.toml  - default location, no extra config needed
[versions]
coreKtx = "1.13.1"
agp     = "8.5.0"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

Gradle generates a type-safe accessor for every catalog entry, and the naming rule is mechanical: dashes, and underscores, in the alias get turned into dots in the generated accessor. So a library declared as androidx-core-ktx in the [libraries] table becomes libs.androidx.core.ktx in your build files. This trips people up constantly because the alias in the toml file and the accessor you type in Kotlin look almost, but not quite, the same.

// gradle/libs.versions.toml:
// [libraries]
// androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
//              (dash in the alias)

// build.gradle.kts:
dependencies {
    implementation(libs.androidx.core.ktx)  // dash becomes dot in the generated accessor
}

bundles solve a narrower problem: when a feature always pulls in the same cluster of libraries together, Compose UI, Compose Material3, and the tooling preview library, for example, you can group their aliases under one bundle name in the catalog. A module then adds all of them with a single accessor like libs.bundles.compose, instead of listing each dependency line by line. It doesn't touch transitive version pinning and it doesn't merge separate catalog files, it's purely a convenience grouping inside one catalog.

# gradle/libs.versions.toml:
# [bundles]
# compose = ["compose-ui", "compose-material3", "compose-ui-tooling-preview"]
// build.gradle.kts:
dependencies {
    implementation(libs.bundles.compose)  // adds all three libraries at once
}

Two separate caches speed up Gradle builds, and interviewers like to check you don't conflate them. Remember the configuration phase from earlier: it runs your build scripts top to bottom on every single invocation, evaluating the DSL and rebuilding the task graph, even when you only asked for one task. The configuration cache, enabled with org.gradle.configuration-cache=true, stores the outcome of that phase, so on a matching later build Gradle skips re-running the scripts entirely and jumps straight to execution.

# gradle.properties
org.gradle.configuration-cache=true
# On first run Gradle serializes the task graph to disk.
# Later matching runs skip re-executing build scripts and jump straight to task execution.

The build cache is the other lever, and it operates at a completely different layer. Where the configuration cache skips re-running build scripts, the build cache, enabled with org.gradle.caching=true, keys each task's output to a hash of that task's own inputs, and reuses the matching output instead of re-executing the task, whether that match came from an earlier local build or a remote cache shared across machines. The two are independent and stack together. Combined with defaulting to implementation over api to limit what needs recompiling in the first place, they are the main levers for keeping a large multi-module Android build fast.

# gradle.properties
org.gradle.caching=true               # build cache: reuses task outputs by input hash
org.gradle.configuration-cache=true   # configuration cache: skips re-running build scripts
# They are independent features and can both be enabled at the same time.

Back to Gradle Basics