The Build Script You Live In
You'll spend most of your Gradle time in one file: build.gradle or build.gradle.kts. It's where you declare which plugins to apply, which libraries you depend on, and any custom behavior you need. The day-to-day work isn't writing tasks from scratch — it's reading this file, adding a dependency without breaking anything, and knowing which of the near-identical keywords (implementation vs api) to pick. This phase makes that file legible.
Groovy or Kotlin: two dialects, one model
Gradle scripts come in two flavors. build.gradle is Groovy. build.gradle.kts is Kotlin. They describe the exact same build model — same tasks, same plugins, same dependencies — with slightly different syntax.
// build.gradle (Groovy DSL)
plugins {
id 'java'
}
repositories {
}
dependencies {
implementation 'com.google.guava:guava:33.0.0-jre'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}
// build.gradle.kts (Kotlin DSL)
plugins {
java
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.google.guava:guava:33.0.0-jre")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
What just happened: Both files do the same three things — apply the java plugin, point at Maven Central for downloads, and declare two dependencies. The Kotlin version uses parentheses and quotes like a normal function call; the Groovy version is looser. The practical difference: Kotlin gives you autocomplete and compile-time checking in your IDE, which is why newer projects lean toward .kts. Pick one per project and stay consistent.
Plugins: where your tasks come from
Remember from Phase 1 that you didn't write compileJava or test — a plugin did. Applying a plugin is how you pull in a whole bundle of tasks and conventions. The java plugin alone gives you compileJava, test, jar, build, and the standard src/main/java layout.
plugins {
java
application // adds the 'run' task
id("org.jetbrains.kotlin.jvm") version "1.9.22" // third-party, needs a version
}
application {
mainClass.set("com.example.Main")
}
What just happened: Three plugins applied. java and application are built into Gradle, so they need no version. The Kotlin plugin is third-party, so you pin a version. The application plugin contributed a run task wired to the mainClass you set, so now ./gradlew run launches your program. Plugins are the main way you get power without writing it yourself.
The mental shortcut: when you wonder "where did this task come from?", the answer is almost always a plugin. Run
./gradlew tasksand the grouping hints at which plugin contributed what.
Dependencies and the repository
A dependency is an external library you want on your classpath. You declare it by its coordinates — group:name:version — and Gradle downloads it from the repositories you listed.
repositories {
mavenCentral()
}
dependencies {
implementation("org.apache.commons:commons-lang3:3.14.0")
}
What just happened: You told Gradle to look in Maven Central and to put commons-lang3 version 3.14.0 on the compile and runtime classpath. Gradle downloads the JAR (and anything it depends on — transitive dependencies) into a local cache so the next build doesn't re-download it. You can see the full resolved tree:
$ ./gradlew dependencies --configuration runtimeClasspath
runtimeClasspath
\--- org.apache.commons:commons-lang3:3.14.0
What just happened: Gradle printed the dependency tree for the runtime classpath. A bigger library would show its transitive dependencies indented underneath. This command is your first stop when you hit a version conflict or a "class not found" error — it shows exactly what's on the classpath and why.
The keyword that trips everyone: implementation vs api
This is the single most misunderstood part of Gradle, so slow down here. Both implementation and api add a library to your module's compile classpath. The difference is what happens to modules that depend on you.
implementation— the dependency is for your eyes only. Modules that depend on your module do not see it on their compile classpath.api— the dependency leaks through. Modules that depend on you do see it, as if they declared it themselves.
dependencies {
// Guava is part of THIS module's public API — a method returns a Guava type.
api("com.google.guava:guava:33.0.0-jre")
// Jackson is an internal detail — used inside, never exposed in a signature.
implementation("com.fasterxml.jackson.core:jackson-databind:2.16.1")
}
What just happened: You declared Guava as api because your public methods return Guava types — a consumer of your module needs Guava to even compile against you. Jackson you declared as implementation because it's a private detail; consumers should never know it exists. Get this wrong in the safe direction (using implementation when you meant api) and downstream code fails to compile. Get it wrong the other way (over-using api) and you bloat everyone's classpath and trigger needless recompiles when Jackson updates.
The rule of thumb: default to implementation. Only reach for api when a dependency's types appear in your module's public method signatures or return types. There's also testImplementation for dependencies only your tests need (like JUnit), and runtimeOnly for things needed at runtime but not at compile time (like a JDBC driver).
dependencies {
implementation("org.slf4j:slf4j-api:2.0.12") // compile + runtime, hidden from consumers
runtimeOnly("org.postgresql:postgresql:42.7.1") // runtime only, not on compile classpath
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") // tests only
}
What just happened: Three different configurations, each putting its dependency on a different classpath at a different time. slf4j-api is needed to compile and run but hidden from consumers. The Postgres driver is loaded at runtime by reflection, so it never needs to be on the compile classpath. JUnit exists only for tests and never ships in your artifact. Choosing the tightest configuration that works keeps classpaths lean and builds fast.
For builders
In a multi-module project — a core module, a web module, an app — getting implementation vs api right is what keeps your modules genuinely decoupled. If core declares everything as api, then web accidentally compiles against core's internal libraries, and a year later you can't upgrade one of those libraries without touching three modules. Defaulting to implementation is how you keep the option to change core's internals without a ripple effect.
[
{
"q": "You add a library that is used only inside your module and never appears in a public method signature. Which configuration?",
"choices": ["api", "implementation", "runtimeOnly", "compileOnly"],
"answer": 1,
"explain": "Default to implementation. It puts the library on your compile and runtime classpath but hides it from modules that depend on you."
},
{
"q": "What does declaring a dependency as 'api' do that 'implementation' does not?",
"choices": ["Downloads it faster", "Exposes it on the compile classpath of modules that depend on yours", "Makes it test-only", "Skips the version number"],
"answer": 1,
"explain": "api leaks the dependency through to your consumers' compile classpath. Use it only when the dependency's types appear in your public API."
},
{
"q": "Where do tasks like compileJava and test come from in a standard build.gradle?",
"choices": ["You must write them by hand", "They are built into the gradlew script", "An applied plugin (such as the java plugin) registers them", "They are downloaded from Maven Central"],
"answer": 2,
"explain": "Plugins contribute tasks and conventions. The java plugin alone registers compileJava, test, jar, build, and the standard source layout."
}
]
← Phase 1 | Overview | Phase 3: Why It's Fast, and Where It Bites →
Check your understanding 3 questions
1. You add a library that is used only inside your module and never appears in a public method signature. Which configuration?
2. What does declaring a dependency as 'api' do that 'implementation' does not?
3. Where do tasks like compileJava and test come from in a standard build.gradle?