08 · Gradle Basics¶
Every module so far has run a single .kt file straight through kotlinc.
Real projects need more: managing dependencies (like the
kotlinx-coroutines and kotlinx-serialization libraries used in
Module 3 and Module 7),
compiling multiple source files together, running tests, and packaging a
runnable artifact. Gradle is the build tool that does all of this for
the vast majority of Kotlin projects, configured through build.gradle.kts
— a build script that's itself just Kotlin.
Project layout¶
A standard Gradle Kotlin project follows this structure, matching what Module 10's capstone uses:
weather-cli/
build.gradle.kts -- what to build and what it depends on
settings.gradle.kts -- project name, module list
gradlew, gradlew.bat -- the "Gradle wrapper" -- see below
src/
main/kotlin/ -- your application code
test/kotlin/ -- your test code
The Gradle wrapper¶
You almost never install Gradle globally. Instead, projects ship a
wrapper (gradlew/gradlew.bat plus a small gradle/wrapper/
directory) that downloads the exact Gradle version the project was built
with, the first time it runs. This means everyone on a team — and CI —
builds with an identical Gradle version without installing anything by
hand.
A fresh project gets its wrapper via gradle wrapper (using a
system-installed Gradle just once) or, more commonly, by generating the
project with gradle init or an IDE's "New Project" flow, which sets up
the wrapper automatically.
A minimal build.gradle.kts¶
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.10" // compiles Kotlin for the JVM
application // adds `run` and packaging tasks
}
version = "1.0.0"
repositories {
mavenCentral() // where dependencies get downloaded from
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
testImplementation(kotlin("test"))
}
application {
mainClass.set("com.example.MainKt")
}
tasks.test {
useJUnitPlatform() // run tests with the JUnit 5 platform
}
settings.gradle.kts just names the project (and, in a multi-module
build, lists the modules):
Dependency configurations¶
The dependencies { } block uses different keywords depending on where
a dependency is needed:
| Configuration | Meaning |
|---|---|
implementation(...) |
Needed to compile and run your code; not exposed to other modules that depend on yours |
api(...) |
Like implementation, but is exposed to consumers of your module (library authors only) |
testImplementation(...) |
Only needed for compiling/running tests (JUnit, Kotest) |
runtimeOnly(...) |
Needed only at runtime, not for compiling (e.g. a JDBC driver) |
Most application code only ever needs implementation and
testImplementation — reach for api only when you're publishing a
library that re-exposes a dependency's types in its own public API.
Common tasks¶
Gradle organizes work into tasks — the plugins you apply (kotlin("jvm"),
application) register a standard set for you.
./gradlew build # compile, run tests, and package -- the full pipeline
./gradlew test # just run tests
./gradlew run # run the application (needs the `application` plugin)
./gradlew clean # delete build outputs
./gradlew tasks # list every task available in this project
$ ./gradlew run
> Task :run
Fetching weather for London...
Temperature: 19.4°C, Wind: 11.9 km/h
BUILD SUCCESSFUL in 2s
2 actionable tasks: 2 executed
Custom tasks¶
You can define your own tasks directly in build.gradle.kts when the
built-in ones aren't enough.
Multi-module projects¶
As a codebase grows, splitting it into modules (e.g. a core library and
a cli that depends on it) keeps build times down — Gradle only rebuilds
the modules whose inputs actually changed. Each module gets its own
build.gradle.kts; the root settings.gradle.kts lists them all.
// cli/build.gradle.kts
dependencies {
implementation(project(":core")) // depend on the sibling module, not a remote artifact
}
Version catalogs (keeping dependency versions in one place)¶
Larger projects centralize dependency versions in
gradle/libs.versions.toml instead of hardcoding version strings in every
module's build.gradle.kts — one file to bump when a library releases a
new version.
# gradle/libs.versions.toml
[versions]
coroutines = "1.11.0"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
How It Actually Works¶
build.gradle.kts is not a config file that Gradle "reads" declaratively —
it is a real Kotlin script, compiled and executed by an embedded Kotlin
compiler on every build (with the compiled result cached so subsequent runs
skip recompiling an unchanged script). The plugins { }, dependencies { },
and application { } blocks are ordinary Kotlin lambdas passed to functions
on the Project object, using Kotlin's trailing-lambda syntax you saw in
the lambdas module — dependencies { implementation(...) } is really
dependencies(fun DependencyHandlerScope.() { implementation(...) }), a
lambda with receiver, which is why you can call implementation(...) inside
the block with no explicit receiver: it's an extension-function-style
receiver being applied, exactly like the this-substitution mechanics
covered for extension functions.
mainClass.set("com.example.MainKt") is the same <FileName>Kt naming
convention from the very first module, now made explicit: if your main()
function lives directly in Main.kt with no wrapping object, the JVM entry
point Gradle needs to launch really is a class called MainKt generated by
the compiler, and this line tells the application plugin's packaging tasks
which class file to put in the runnable jar's manifest
(Main-Class: attribute) so java -jar knows where to start.
The Gradle wrapper works by having gradlew be a tiny, checked-in shell
script plus a gradle-wrapper.properties file recording an exact Gradle
distribution URL and version — the first invocation downloads and caches
that exact zip under ~/.gradle/wrapper/dists, then every subsequent build
(on any machine, any CI runner) reuses that identical binary, which is how
"builds reproducibly across machines" becomes an actual guarantee rather
than a hope: the version is pinned in a file, not in whatever happens to be
on someone's PATH.
Cheat sheet¶
| Task | Command / syntax |
|---|---|
| Build everything (compile + test + package) | ./gradlew build |
| Run just the tests | ./gradlew test |
| Run the application | ./gradlew run |
| Clean build outputs | ./gradlew clean |
| List available tasks | ./gradlew tasks |
| Add a runtime dependency | implementation("group:artifact:version") |
| Add a test-only dependency | testImplementation("group:artifact:version") |
| Use JUnit 5 | tasks.test { useJUnitPlatform() } |
| Reference a sibling module | implementation(project(":module-name")) |
Exercise¶
Starting from the minimal build.gradle.kts above, add a
testImplementation dependency on io.kotest:kotest-runner-junit5:<version>
(from Module 6) alongside the existing JUnit
setup, and add a custom task named hello that prints a greeting including
project.name. Run ./gradlew tasks to confirm your new task shows up,
then run it with ./gradlew hello.