Gradle Kotlin DSL
Every Android project you've built across this track has been described, underneath the IDE, by a
Gradle build script — the file that lists dependencies, sets the compile SDK, and defines how a
debug build differs from a release one. This lesson looks directly at that file: the modern Kotlin
DSL that writes it in, what it replaced, and a full worked build.gradle.kts for
TrailLog covering everything from earlier lessons in this track.
Kotlin DSL vs. the Older Groovy DSL
Gradle build scripts can be written in two languages: the original Groovy DSL
(files named build.gradle) or the newer Kotlin DSL (files named
build.gradle.kts). Android Studio has defaulted every new project to the Kotlin DSL for
several years now, and the reason comes down to tooling rather than the language itself: a
.kts file is genuinely just Kotlin, so Android Studio's Kotlin support — real
autocomplete, jump-to-definition on a Gradle API, and compile-time type checking that catches a typo
like a misspelled configuration name before you even run a build — applies to it the same way it
applies to any other Kotlin file in the project. A Groovy build script gets much weaker IDE support
in comparison, because Groovy is dynamically typed and IntelliJ's tooling for it was never as deep
as its Kotlin tooling.
- Groovy DSL (
build.gradle) — the original, still widely seen in older tutorials and existing projects; loose syntax, weaker autocomplete, errors often only surface at build time. - Kotlin DSL (
build.gradle.kts) — the current default for new projects; real static typing means many mistakes are flagged directly in the editor, before you ever hit "Sync" or "Build."
A Full Annotated build.gradle.kts for TrailLog
Here's a realistic app/build.gradle.kts pulling together plugins and libraries from
across this track — Room, Retrofit, Hilt, and kotlinx.serialization:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
}
android {
namespace = "com.traillog.app"
compileSdk = 34
defaultConfig {
applicationId = "com.traillog.app"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
applicationIdSuffix = ".debug"
}
}
}
dependencies {
implementation("androidx.room:room-ktx:2.6.1")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.google.dagger:hilt-android:2.51")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
kapt("com.google.dagger:hilt-compiler:2.51")
kapt("androidx.room:room-compiler:2.6.1")
}
The plugins { } block declares which Gradle plugins apply to this module — the Android
application plugin itself, the Kotlin Android plugin, the serialization compiler plugin, and Hilt's
Gradle plugin. The android { } block configures Android-specific settings: which SDK
version to compile against, the app's package name and version, and the defaultConfig
values every build type inherits unless it overrides them. The dependencies { } block
is the one you'll touch most often day to day — every library the module needs, one line each.
Build Types: Debug vs. Release, With Real Differences
The example above already shows the two build types every Android module ships with by default:
debug and release. They're not just labels — each one can carry genuinely
different configuration, and enabling R8 code shrinking and obfuscation only for the release build
(as shown above with isMinifyEnabled = true inside the release { } block)
is the single most common example. A debug build skips this deliberately: shrinking and obfuscating
makes stack traces harder to read and slows down the fast iterate-and-rerun cycle a debug build is
for, so it stays off there and only turns on for the build you actually ship. See
ProGuard / R8 for what shrinking and obfuscation do in detail.
Product Flavors: Variants of the Same App
Product flavors solve a different problem than build types: shipping meaningfully
different variants of the same app from one codebase, rather than different build
configurations of the same variant. TrailLog might define a free flavor capped at a
small number of saved trails alongside a pro flavor with no cap and cloud sync enabled:
android {
flavorDimensions += "tier"
productFlavors {
create("free") { dimension = "tier" }
create("pro") { dimension = "tier" }
}
}
Gradle then multiplies flavors by build types automatically, so this configuration produces four
actual build variants — freeDebug, freeRelease, proDebug, and
proRelease — each independently buildable and installable side by side on one device.
Version Catalogs: One Place for Every Version Number
On a project with several modules (see Modularization),
repeating a library's exact version string in every module's dependencies { } block
gets error-prone fast — nothing stops :feature-trails and :core-database
from silently drifting onto different Room versions. Gradle's version catalog
solves this with a single gradle/libs.versions.toml file listing every version and
library once:
[versions]
room = "2.6.1"
retrofit = "2.11.0"
[libraries]
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
Every module then references the catalog instead of a hardcoded string —
implementation(libs.room.ktx) — so bumping Room's version means editing exactly one
line in the whole project, not hunting through every module's build script.