Gradle Basics Explained
BUILD & TOOLING › Gradle
Every Android Gradle project is built from three kinds of files that interviewers expect you to place instantly: **settings.gradle.kts** (root level, declares which modules exist via include(...) and configures plugin or dependency repositories), the **root build.gradle.kts** (declares plugin versions for the whole project, almost always with apply false), and each **module's build.gradle.kts** (that module's own android {} block and dependencies).
Modern Android projects default to the **Kotlin DSL** (.kts) over the older Groovy DSL (plain .gradle). 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 typos (compileSdk = 34 versus the dynamically-typed compileSdkVersion 34) at edit time instead of at build time.
Gradle builds run through three phases every single time, and knowing them cold is a favorite interviewer probe. **Initialization** reads settings.gradle.kts and creates a Project object for every included module. **Configuration** then runs every module's build.gradle.kts script top to bottom, evaluating the DSL and building the task graph (which tasks exist and how they depend on each other), this happens even if you only ask for one task. **Execution** finally runs the tasks that are actually needed, in dependency order, skipping anything already up to date.
The distinction interviewers probe: code inside a dependencies {} block or a bare top-level statement in a build script runs during **configuration**, on every build invocation, while code inside a task's doLast { } action runs during **execution**, only when that specific task actually runs.
The dependency configuration you pick controls where a dependency is visible: the compile classpath, the runtime classpath, both, and whether it leaks to modules that depend on yours. The two everyday ones: **implementation** puts a dependency on both the compile and runtime classpath of your module only, other modules depending on you don't see it. **api** does the same but also exposes it transitively, so a consumer of your module can reference that dependency's types directly.
// library/build.gradle.kts
dependencies {
implementation("com.squareup.retrofit2:retrofit:2.11.0") // internal detail
api("com.example:shared-models:1.0") // used in public API
}
Default to implementation. It keeps your module's internals encapsulated, and it means changing that dependency only recompiles your module, not everything downstream. Reach for api only when the dependency's types genuinely appear in your module's own public function signatures.
Two more configurations round out the picture. **compileOnly** puts a dependency on the compile classpath only, not the runtime classpath, so it isn't packaged into the APK; use it for annotation processors or libraries the runtime environment already provides. **runtimeOnly** is the mirror image: available and packaged at runtime, but absent at compile time.
Test-scoped work gets its own configurations that never leak into the shipped app: **testImplementation** reaches only the local JVM unit tests in src/test, and **androidTestImplementation** reaches only the instrumented tests in src/androidTest. Neither crosses into the other's source set or into production code.
Plugins add build capabilities, the Android Gradle Plugin, Kotlin support, Hilt, and are declared with the plugins {} block. In the **root** build.gradle.kts, you pin each plugin's version but append apply false, this makes the version available to every module without applying the plugin to the root project itself, which has no Android code of its own to build:
// root build.gradle.kts
plugins {
id("com.android.application") version "8.5.0" apply false
}
Each module's own build.gradle.kts then applies the plugin for real, without repeating the version:
// app/build.gradle.kts
plugins {
id("com.android.application")
}
A **version catalog**, gradle/libs.versions.toml by default, centralizes dependency and plugin coordinates so every module references one shared, type-safe accessor instead of copy-pasted version strings. It has four sections: [versions] (named version variables), [libraries] (group or name plus a version or version.ref), [plugins] (plugin ids), and [bundles] (grouping several library aliases under one accessor).
[versions]
coreKtx = "1.13.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
Gradle turns dashes in the alias into dots for the generated accessor, so androidx-core-ktx becomes libs.androidx.core.ktx in your build files, and a catalog-declared plugin is applied with alias(libs.plugins.android.application) rather than id("...").
Two separate caches speed up builds, and interviewers like to check you don't conflate them. The **build cache** (org.gradle.caching=true) reuses a task's *output* across builds, even across machines, whenever that task's inputs hash to something already produced, skipping execution entirely. The **configuration cache** (org.gradle.configuration-cache=true) instead caches the *result of the configuration phase*, so on a matching subsequent build Gradle skips re-running your build scripts and jumps straight to execution.
They're independent and stack: the build cache saves execution time task-by-task, the configuration cache saves the fixed cost the configuration phase pays on every invocation. Together, and combined with picking implementation over api to limit what needs recompiling, they're the main levers for keeping a large multi-module Android build fast.