Project Structure, Gradle and Build Variants
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Every Android app is a Gradle project, and Gradle’s job is to turn a tree of Kotlin/XML source files and
declared dependencies into one or more signed .apk/.aab artifacts. This page walks through that project as
it exists on disk, the files that configure it, and the build system that compiles it, targeting the section’s
baseline: Android Gradle Plugin (AGP) 9.4.1 on Gradle 9.6 and JDK 17, with AGP’s built-in Kotlin
compiler (Kotlin 2.4.20, no separate kotlin-android plugin), KSP 2.3.12 for annotation processing, and the
Compose compiler as its own Gradle plugin. The running example, as introduced in
Getting Started with Android, is TrailMate, a hiking-trail
companion app. The Kotlin language itself — syntax, coroutines, the type system — is covered in the
Kotlin Reference and is not repeated here;
Kotlin: Build and Tooling covers the Kotlin compiler
and Gradle plugin from the language side, while this page covers the Android-specific android {} DSL, build
variants and packaging.
Project and Module Layout
A freshly generated Android Studio project looks like this once TrailMate grows past a single module:
TrailMate/
├── build-logic/ # convention plugins (see below)
│ ├── settings.gradle.kts
│ └── convention/
│ ├── build.gradle.kts
│ └── src/main/kotlin/
│ ├── trailmate.android.application.gradle.kts
│ ├── trailmate.android.library.gradle.kts
│ └── trailmate.android.library.compose.gradle.kts
├── gradle/
│ ├── libs.versions.toml # the version catalog
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── app/ # the :app module, produces the .apk/.aab
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin/com/example/trailmate/
│ │ └── res/
│ ├── debug/ # debug-only source set
│ ├── release/ # release-only source set
│ ├── test/ # local JVM unit tests
│ └── androidTest/ # instrumented tests
├── core/
│ ├── model/build.gradle.kts # :core:model -- plain Kotlin, no Android deps
│ ├── data/build.gradle.kts # :core:data
│ ├── database/build.gradle.kts # :core:database -- Room
│ └── network/build.gradle.kts # :core:network -- Retrofit/Ktor
├── feature/
│ ├── trails/build.gradle.kts # :feature:trails
│ └── settings/build.gradle.kts # :feature:settings
├── build.gradle.kts # root build script (usually just plugin declarations)
├── settings.gradle.kts
├── gradle.properties
├── gradlew / gradlew.bat
└── local.properties # SDK location, never committed
Each :core:* and :feature:* directory is its own Gradle module (an Android library or a plain Kotlin/JVM
module), with its own build.gradle.kts and src/main/AndroidManifest.xml if it needs Android APIs. The
app architecture page explains why TrailMate is split this way
(separation of concerns, faster incremental builds, enforced api/implementation boundaries); this page
covers how each module is configured to build at all. See
Android Developers — Overview of Android Studio Projects.
settings.gradle.kts
settings.gradle.kts is read before any other build script. It declares which modules exist and, in modern
Gradle, centralizes two things that used to be scattered across every module: where plugins come from, and
where dependencies come from.
// settings.gradle.kts
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TrailMate"
include(":app")
include(":core:model", ":core:data", ":core:database", ":core:network")
include(":feature:trails", ":feature:settings")
pluginManagement.repositories controls where Gradle resolves plugins (AGP, the Kotlin plugin, KSP, Room,
Hilt). dependencyResolutionManagement.repositories controls where it resolves ordinary dependencies
(library artifacts), and FAIL_ON_PROJECT_REPOS forbids any module’s own build.gradle.kts from declaring a
repositories {} block, so every module resolves from the same two repositories declared once, centrally. This
is the standard shape Android Studio’s project wizard has generated since AGP 8. See
Android Developers — Gradle Build Overview and
Gradle — Centralizing
Repository Declarations.
build-logic/settings.gradle.kts is a second, separate settings file: TrailMate treats build-logic as an
included build (includeBuild("build-logic") in the root settings.gradle.kts) so its convention plugins can
be applied by name from every module, as covered later on this page.
The Version Catalog: gradle/libs.versions.toml
A version catalog is a single TOML file that names every dependency and plugin version once, generating typed
accessors (libs.androidx.core.ktx, libs.plugins.android.application, …) that autocomplete in every
build.gradle.kts. It replaces hand-written ext {} blocks and string-typed dependency coordinates. A
representative (trimmed) TrailMate catalog:
# gradle/libs.versions.toml
[versions]
agp = "9.4.1"
kotlin = "2.4.20"
ksp = "2.3.12"
composeBom = "2026.09.00"
coreKtx = "1.17.0"
lifecycle = "2.11.0"
activityCompose = "1.13.0"
navigationCompose = "2.10.2"
room = "2.8.5"
retrofit = "3.0.0"
okhttp = "5.5.0"
hilt = "2.60.1"
hiltNavigationCompose = "1.4.0"
coil = "3.6.3"
kotlinxSerializationJson = "1.11.0"
coroutines = "1.11.0"
junit = "4.13.2"
mockk = "1.14.11"
turbine = "1.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
[bundles]
compose = ["compose-ui", "compose-ui-tooling-preview", "compose-material3"]
Note that AGP and Kotlin’s own version is declared as a plugin version ([plugins]), applied without a
version in every module (alias(libs.plugins.android.application) apply false at the root, then plain
alias(libs.plugins.android.application) per module) — there is no kotlin-android entry at all, because AGP
9’s built-in Kotlin support means the Kotlin compiler ships as part of the Android Gradle Plugin itself rather
than as a separate plugin applied per module. See
Android Developers — Migrate your build to Version
Catalogs and Gradle — Version Catalogs.
The Root build.gradle.kts
The root build script in a catalog-based, convention-plugin project is deliberately thin — it only declares which plugins are available to sub-projects, without applying them anywhere:
// build.gradle.kts (root)
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt.android) apply false
}
apply false resolves and downloads the plugin’s classpath once, at the root, without activating it — each
module then applies only the plugins it actually needs. This avoids every module re-declaring
buildscript { dependencies { classpath(…) } }, the pre-Gradle-7 way of doing the same thing. See
Android Developers — Migrate your build to the
Kotlin DSL.
The :app Module’s build.gradle.kts
This is where the android {} DSL lives. A representative TrailMate app/build.gradle.kts under AGP 9.4,
built-in Kotlin, the Compose compiler plugin and KSP:
// app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.example.trailmate"
compileSdk = 37
defaultConfig {
applicationId = "com.example.trailmate"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
}
flavorDimensions += "environment"
productFlavors {
create("staging") {
dimension = "environment"
buildConfigField("String", "BASE_URL", "\"https://staging.api.example.com/v1/\"")
}
create("production") {
dimension = "environment"
buildConfigField("String", "BASE_URL", "\"https://api.example.com/v1/\"")
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
implementation(project(":feature:trails"))
implementation(project(":feature:settings"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test.junit4)
}
Because AGP 9 has built-in Kotlin, there is no separate id("org.jetbrains.kotlin.android") plugin to apply — com.android.application itself understands .kt sources and exposes the kotlin { jvmToolchain(17) } block
directly inside android {}. The Compose compiler is applied as its own plugin
(org.jetbrains.kotlin.plugin.compose) rather than being wired implicitly, which is what lets each module pin
an independent Compose compiler version if it ever needs to. See
Android Developers — Migrate to AGP’s
Built-in Kotlin Support, Android Developers — Kotlin
Support in the Android Gradle Plugin and
Android Developers — Configure Your Build Module.
The android {} DSL, Field by Field
| Property | Meaning |
|---|---|
|
The package Kotlin/Java sources and generated |
|
The identifier the Play Store and the device’s package manager use to identify the app; can differ from
|
|
Covered in Getting Started with Android; |
|
Named build configurations ( |
|
Named variations along one or more dimensions (here, |
|
Opt-in switches for generated code paths: |
JVM toolchain |
|
Build Variants, Source Sets and Manifest Merging
A build variant is the cross product of every build type and every flavor dimension: TrailMate’s two build
types (debug, release) times its one flavor dimension with two flavors (staging, production) yields four
variants — stagingDebug, stagingRelease, productionDebug, productionRelease — each with its own
Gradle tasks (assembleStagingDebug, bundleProductionRelease, …) and its own merged manifest, resources and
BuildConfig.
Each variant assembles its sources by layering source sets from most specific to least specific:
src/
├── main/ # shared by every variant
├── debug/ # merged into every *Debug variant
├── release/ # merged into every *Release variant
├── staging/ # merged into every staging* variant
├── stagingDebug/ # merged only into the stagingDebug variant (highest priority)
├── test/ # local JVM unit tests, all variants
└── androidTest/ # instrumented tests, all variants
A resource or manifest entry in a more specific source set (stagingDebug) overrides the same entry from a
less specific one (debug, then main). Manifest merging applies the same priority order to
AndroidManifest.xml fragments — a flavor’s manifest can add a <meta-data> or override an android:label
without touching main's manifest — and Android Studio’s Merged Manifest view shows exactly which source
contributed each attribute. See
Android Developers — Configure Build Variants and
Android Developers — Manage the Manifest File.
BuildConfig is a generated class (one per variant, gated by buildFeatures.buildConfig = true) exposing
buildConfigField entries and standard fields (BuildConfig.APPLICATION_ID, BuildConfig.VERSION_NAME,
BuildConfig.DEBUG, BuildConfig.FLAVOR, BuildConfig.BUILD_TYPE) as compile-time constants, letting code
branch on BuildConfig.BASE_URL without any runtime configuration lookup.
gradle.properties and the Gradle Wrapper
gradle.properties holds JVM and Gradle daemon settings plus AGP/Kotlin feature flags, and is committed (unlike
local.properties, which holds machine-specific paths and is never committed):
# gradle.properties
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
The Gradle wrapper (gradlew/gradlew.bat plus gradle/wrapper/gradle-wrapper.properties) pins the exact
Gradle version (9.6) a project builds with, downloaded on first run, so every machine and CI runner builds
with the identical Gradle regardless of what is installed globally. It is generated and upgraded with
./gradlew wrapper --gradle-version 9.6 and should always be committed. See
Gradle — The Gradle Wrapper and
Android Developers — Configure the JDK Used for Gradle.
Dependency Configurations and BOMs
Each dependencies {} entry names both a library and a configuration that controls its visibility and when
it is needed:
| Configuration | Meaning |
|---|---|
|
Available to compile and run this module, not re-exported to modules that depend on this one. The default choice; keeps a module’s dependency graph private and its own changes from forcing recompilation of consumers. |
|
Like |
|
Registers an annotation processor that runs through KSP (Kotlin Symbol Processing) rather than kapt — |
|
Only on the local-JVM test compile/runtime classpath ( |
|
Only on the instrumented-test classpath ( |
|
Only in |
implementation(platform(libs.compose.bom)) declares a Bill of Materials: a single version
(2026.09.00) that then governs every other androidx.compose.* artifact’s version implicitly, so
compose-ui, compose-material3 and compose-ui-tooling-preview never drift out of sync with each other. Both
Compose and OkHttp ship a BOM this way; declaring one BOM per ecosystem and letting individual artifact versions
follow it is the recommended pattern over pinning each artifact’s version by hand. See
Android Developers — Add Build Dependencies and
Android Developers — Using the Compose BOM.
Common Gradle Tasks
| Task | What it does |
|---|---|
|
Builds a debug |
|
Builds a signed |
|
Runs local JVM unit tests ( |
|
Runs instrumented tests ( |
|
Runs Android Lint across every variant. |
|
Prints the resolved dependency tree for the |
|
Publishes a Build Scan with a detailed breakdown of what took time and why a task was or was not up to date. |
Convention Plugins: build-logic
Repeating the same android {} block, JVM toolchain and lint configuration in every one of TrailMate’s seven
modules invites drift. A convention plugin — an ordinary Gradle plugin, written once in an included build
named build-logic, applied by every module that needs its shape — centralizes that instead:
// build-logic/convention/src/main/kotlin/trailmate.android.library.compose.gradle.kts
plugins {
id("trailmate.android.library")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
buildFeatures {
compose = true
}
}
dependencies {
val bom = project.dependencies.platform("androidx.compose:compose-bom:2026.09.00")
add("implementation", bom)
add("androidTestImplementation", bom)
}
A feature module then reduces its own build.gradle.kts to:
// feature/trails/build.gradle.kts
plugins {
id("trailmate.android.library.compose")
id("trailmate.hilt")
}
android {
namespace = "com.example.trailmate.feature.trails"
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:data"))
}
build-logic is registered as an included build from the root settings.gradle.kts
(includeBuild("build-logic")), which is what lets its plugins be applied to other modules by id, as if they
had been published to a repository, without actually publishing anything. This is the pattern behind Now in
Android and most production multi-module Android projects. See
Android Developers — Extend the Android Gradle Plugin and
Gradle — Sharing Build Logic Between
Subprojects.
AGP 9 Migration: the New Variant API and newDsl
AGP 9.0 removed the legacy variant API (applicationVariants, libraryVariants, mutable
BaseVariant/ApplicationVariant objects read inside android {}) that plugins and build scripts used to
hook into per-variant configuration. In its place, the Variant API (androidComponents { onVariants { … }
}) exposes each variant as an immutable, lazily-evaluated model built for configuration-cache compatibility:
// app/build.gradle.kts -- reading and customizing variants under the new API
androidComponents {
onVariants { variant ->
println("Configuring variant: ${variant.name}")
if (variant.buildType == "release") {
variant.outputs.forEach { output ->
if (output is com.android.build.api.variant.impl.VariantOutputImpl) {
output.outputFileName.set("trailmate-${variant.name}-${variant.versionName.get()}.apk")
}
}
}
}
}
Where old-API code read variant.buildType.buildConfigFields directly during configuration (breaking the
configuration cache, since it captured live, mutable objects), onVariants receives a read-only snapshot and
schedules any further work as a lazy Provider, so Gradle can safely cache and reuse the configuration between
builds. AGP 9’s newDsl setting (on by default since 9.0) additionally changes how android {} itself is
typed internally to support this; existing android {} blocks written the conventional way, as shown throughout
this page, do not need to change. Projects still on the old variant API from AGP 8 or earlier should follow the
official migration guide before upgrading further. See
Android Developers — AGP 9.0.0 Release
Notes, Android Developers — AGP 9.4.0
Release Notes and Android Developers — Extend the Android
Gradle Plugin.
From kapt to KSP
kapt (Kotlin Annotation Processing Tool) ran a full, generated Java stub compilation of every Kotlin file just so Java-based annotation processors (like Room’s or Dagger’s original ones) could see them — slow, and a separate compilation pass per module. KSP (Kotlin Symbol Processing) gives compatible processors direct access to Kotlin’s own compiler symbols, without a Java-stub round trip, typically 2x or faster on processor-heavy modules. Room, Hilt, Moshi and most current-generation Android annotation processors ship a KSP-compatible artifact; the migration is usually just:
// before (kapt)
plugins {
kotlin("kapt")
}
dependencies {
kapt(libs.androidx.room.compiler)
}
// after (KSP)
plugins {
alias(libs.plugins.ksp)
}
dependencies {
ksp(libs.androidx.room.compiler)
}
kapt is in maintenance mode — it still works, and some older third-party processors have not published a KSP variant, but new projects should default to KSP and existing ones should migrate opportunistically, one processor at a time (both can coexist in the same module during a migration). See Android Developers — Migrate from kapt to KSP and Kotlin — Kotlin Symbol Processing (KSP).
The Configuration Cache and the Build Cache
Two independent caches make repeated builds faster:
-
The configuration cache (
org.gradle.configuration-cache=true) skips re-running the configuration phase — evaluating everybuild.gradle.kts, resolvingplugins {}blocks, building the task graph — when nothing that would change it has changed, serializing that graph to disk instead. It is why the old variant API (which read live, mutable objects during configuration) had to be replaced: anything the configuration cache serializes must be a stable, cacheable snapshot, which is exactly whatonVariantsprovides. -
The build cache (
org.gradle.caching=true, plus abuildCache {}block insettings.gradle.ktsfor a shared remote cache in CI) reuses individual task outputs — a Kotlin compile task, a KSP run, a lint report — keyed by a hash of their inputs, whether that hash was last produced locally or, with a remote cache, by a teammate’s or CI’s build.
Both are additive: the configuration cache speeds up the phase that decides what to run, the build cache speeds up the phase that actually runs it, by skipping tasks whose inputs have not changed. See Android Developers — Optimize Your Build Speed and Gradle — Configuration Cache.
Figure: the Build Pipeline
+ resources"] --> B["Compile
(Kotlin/javac,
KSP processors)"] B --> C["Dex
(D8/R8:
bytecode to DEX,
shrink/obfuscate release)"] C --> D["Package
(merge DEX,
resources, assets,
native libs into
APK/AAB)"] D --> E["Sign
(apksigner,
debug or release key)"] E --> F["Installable/publishable
artifact
(.apk / .aab)"]
Compiling happens once per variant’s merged source sets; D8 always converts JVM bytecode to Dalvik
Executable (DEX) format, and R8 (D8’s superset) additionally shrinks, obfuscates and optimizes the code
graph for release builds where isMinifyEnabled = true. Packaging then merges every variant’s DEX files,
merged resources, assets and any native (.so) libraries into one .apk (installable directly) or .aab
(the Play-only format that Play itself splits into per-device APKs). Signing — with the debug keystore for
debug builds, or a release keystore for release — is what makes an artifact installable on a device
(unsigned or debug-signed APKs cannot be published) and is covered in full in the section’s build/sign pages.
See Android Developers — Gradle Build Overview and
Android Developers — Shrink, Obfuscate and Optimize Your App.