Gradle for Java Projects: Builds, Tasks, and Kotlin DSL
Executive Summary
Gradle for Java projects starts from build.gradle.kts, where the project describes itself. A plugins block applies the java plugin, which brings compile, test, and jar tasks by convention. Then repositories declare where dependencies come from. Dependencies use implementation and testImplementation where Maven used compile and test scopes. Finally, a toolchain block pins Java 21 so the build chooses the right JDK.
The build is a task graph, not a fixed sequence. Running ./gradlew build assembles the graph and runs tasks in dependency order. It also skips any task whose inputs and outputs are unchanged, which is the incremental build. A cache extends the same idea across machines. The wrapper commits the Gradle version itself into the repository as gradlew plus a small jar. As a result, every machine and the CI server run exactly the same build tool. Nobody installs Gradle by hand, ever. The java plugin gives the standard layout Maven readers know: src/main/java and src/test/java. Thus, both tools agree on the project shape even where they disagree on the description language. Choose by team and fit. Maven suits convention and universal XML familiarity, while Gradle suits custom logic, incremental speed, and code-native configuration.
Maven’s Problem, Gradle’s Different Answer
Keep the Maven article’s problems fixed, dependency resolution, repeatable builds, and compare the answers side by side:
| Concern | Maven | Gradle |
|---|---|---|
| Project description | pom.xml, declarative XML | build.gradle.kts, executable Kotlin DSL |
| Build model | Fixed lifecycle phases | A directed graph of tasks you can extend or write |
| Dependency scopes | compile, test, provided | implementation, testImplementation, compileOnly |
| Skipping unchanged work | Not built in | Incremental by inputs and outputs, cacheable across machines |
| Tool version | Everyone installs Maven, usually compatible | The wrapper commits the exact Gradle version to the repo |
| Standard layout | src/main/java, src/test/java | Identical, via the java plugin’s conventions |
The row worth pausing on is the build model. Maven’s phases are a fixed corridor, powerful because they are identical everywhere. In contrast, Gradle’s tasks are a graph you can grow. That is powerful because custom build logic becomes a first-class task instead of a plugin workaround. Such logic might generate sources, assemble special artifacts, or run extra checks.
build.gradle.kts: The Project as Code
A complete build file for this course’s projects, annotated for a reader who knows the pom equivalent:
plugins { // what the build can do: conventions + tasks
java // compile, test, jar: the whole corridor
application // adds "run" and a distributable app
}
repositories { // where dependencies come from
mavenCentral()
}
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
// like Maven's compile scope: on the classpath
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
// like test scope: never shipped
}
tasks.test { // hand the test task to JUnit 5
useJUnitPlatform()
}
java { // pin the JDK once, for everyone
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
application {
mainClass = "in.imraan.catalog.Main"
}
Read the differences honestly. The Kotlin DSL is executable code, so variables, conditionals, and helper functions are legal in the build file itself. That is where Maven’s profiles-and-plugins escape hatches mostly disappear. The toolchain block is the killer feature for teams. It does not assume a JDK. Instead, it requests one. Gradle then finds or provisions Java 21 regardless of what the machine’s default JDK happens to be. That ends the “works with my JDK” class of accident. Also, implementation versus testImplementation carries a subtle improvement over Maven’s compile scope. Implementation dependencies do not leak to consumers of your library. As a result, an internal choice stops becoming someone else’s compile-time API. That compile-scope leak is one Maven made easy and Gradle made visible.
Tasks and the Task Graph
Where Maven runs phases, Gradle assembles a task graph and runs it in dependency order. The everyday commands:
./gradlew build // compile, run tests, assemble the jar
./gradlew test // tests only
./gradlew tasks // list what this build can do: the map of the graph
./gradlew clean // delete build/ outputs
// a custom task, legal, readable, and debuggable Kotlin:
tasks.register("linkReport") {
doLast {
val report = file("report.txt")
println("report is ${if (report.exists()) "present" else "missing"}")
}
}
The feature that changes daily experience is up-to-date checking. A task whose inputs and outputs are unchanged is skipped. So the second ./gradlew build after a one-line edit compiles one module and runs affected tests. It finishes in seconds, where Maven rebuilds the corridor. The build cache extends the same idea across machines and CI runs, reusing outputs from previous builds when inputs match. One honest caveat completes the picture. The incremental machinery is only as good as a task’s declared inputs and outputs. For example, a custom task without them re-runs forever, the single most common custom-task bug.
The Wrapper: The Build Tool Ships With the Build
Gradle’s most quietly brilliant feature answers a question Maven leaves open: which version of the build tool itself? The wrapper is a small script and jar committed to the repository. Then gradlew downloads and runs exactly the Gradle version the project declares:
gradle wrapper --gradle-version 8.7 // run once: generates the wrapper
git add gradlew gradlew.bat gradle/wrapper/ // COMMIT IT: the whole point
./gradlew build // any machine: same Gradle, same build
The rule is absolute: commit the wrapper, and run only gradlew, never a locally installed gradle. After all, a team running different Gradle versions has the same reproducibility hole as a team running different JDKs. The wrapper closes it by construction. The setup article’s rule about consistent tools applies with teeth here.
Choosing Between Maven and Gradle for Java Projects
Both tools are excellent, and both resolve the same coordinates from the same repositories. So the industry’s honest answer is that team fluency beats tool features. The decision table:
| Your situation | Lean | Because |
|---|---|---|
| Enterprise, many teams, long-lived builds | Maven | Universal familiarity, fixed conventions, zero surprises across hundreds of projects |
| Android, or heavy code generation | Gradle | The Android toolchain is Gradle-native, and custom task logic is first-class |
| Slow CI you want faster | Gradle | Incremental builds and the remote cache genuinely cut rebuild times |
| Build logic that needs loops, conditionals, functions | Gradle | The DSL is code: the logic that needs plugins in Maven is plain Kotlin here |
| New team, no history either way | Either | Pick one, standardize, and spend the saved energy on tests and CI |
The migration question comes up regularly. The honest answer is that both tools can consume each other’s outputs. Still, Maven-first shops rarely find the migration worth the churn, while new polyglot or Android-adjacent shops rarely regret Gradle. What costs teams is the mixture: pick per repository, and never both in one build.
How Real Systems Do This
Gradle’s production footprint is enormous. Every Android application on earth builds with it, and Spring Boot project generators offer it as a first-class option. Meanwhile, large polyglot companies run multi-language builds, with Java, Kotlin, and Groovy in one graph. At scale, teams add build scans, structured records of every build for diagnosing slow tasks. They also add remote build caches, shared input-to-output stores that let a CI machine reuse what another machine already built.
The cache story I bring to teams is a CI bill, halved. A service’s pipeline ran a clean Gradle build per pull request, about 18 minutes of compile and test on every push. Meanwhile, the queue made afternoons miserable.
We turned on the remote cache with a fixed, content-addressed key. The second surprise was how much of every build was identical. Dependencies resolved the same way, and unchanged modules compiled to the same outputs. Indeed, the cache recognized all of it. Build time fell to 7 minutes on a typical pull request, and the wall clock fell further because the queue shrank. The lesson generalizes to any build tool. Most of any build is work that was already done. So the tooling that recognizes that fact is worth real money at CI scale. I add one caveat every time. The cache is only correct when inputs are declared honestly. Therefore, the custom-task discipline from earlier is not style advice. Rather, it is cache correctness.
Decision Framework
- Is the team already fluent in one tool? Stay: both are excellent, and fluency compounds.
- Android anywhere in the company? Gradle, not a decision at all.
- Enterprise portfolio, conservative tooling? Maven, for the reasons the Maven article gave.
- Custom build logic, generated sources, or build-time computation? Gradle, where that logic is readable Kotlin instead of plugin archaeology.
- CI time is a real cost and builds repeat similar work? Gradle with the remote cache, and honest input declarations.
- Are dependencies conflicting? The same resolution rules apply, and Gradle’s dependencies report, ./gradlew dependencies, is the dependency:tree equivalent.
- Either tool chosen? Standardize: one tool per repository, committed wrapper or fixed pom, and the module and layout conventions both tools expect.
When NOT to Use This
- Do not write custom tasks for what plugins already provide. The java plugin’s corridor covers compile, test, jar, and publishing. Moreover, custom replacements of standard tasks forfeit incremental correctness.
- Do not run bare gradle instead of ./gradlew. The wrapper exists so the build tool version is part of the repository. Thus, every bare invocation is an untracked variable.
- Do not leave the toolchain block out. Without it, the build compiles against whatever JDK it finds. That is the same dice roll the Maven article called maven.compiler.release insurance against.
- Do not mix Maven and Gradle in one repository. Pick one, because two build descriptions disagree eventually, and the disagreement is always at deploy time.
- Do not treat the DSL as a general-purpose program. Loops over your deployment targets are fine. However, application logic in the build is a smell that outlives its author.
Common Mistakes
- Custom tasks without inputs and outputs declared. They re-run every build, break up-to-date checks, and poison cache correctness. This is the most common Gradle bug in the wild.
- Not committing the wrapper: new machines and CI guess the Gradle version, and the build becomes weather again.
- Using api instead of implementation everywhere. In effect, api re-exports your dependencies to consumers, recreating Maven’s compile-scope leak the configuration was designed to prevent.
- Skipping the toolchain and relying on JAVA_HOME. The build then depends on machine state. That is the reproducibility hole both tools exist to close.
- Debugging resolution by reading code: ./gradlew dependencies prints the graph, the same discipline as Maven’s dependency:tree.
- Letting build.gradle.kts grow without structure. Instead, extract build logic into convention plugins before the file becomes the longest Kotlin source in the repo.
- Caching aggressively with unclean inputs. A cache keyed on incomplete inputs returns stale outputs. That failure mode is the reason input honesty has no exceptions.
Key Takeaways
- Gradle solves Maven’s problems with different machinery. It uses build.gradle.kts instead of pom.xml and a task graph instead of fixed phases. It also runs incremental builds instead of full corridors.
- The DSL is code: variables, conditionals, and functions in the build file replace most plugin-and-profile escape hatches.
- The toolchain block requests Java 21 by version. As a result, the build stops depending on whatever JDK the machine happens to have.
- implementation does not leak to consumers, where Maven’s compile scope did: the scope names are honest about exposure.
- The wrapper commits the Gradle version to the repository. Thus, gradlew is the only entry point, on every machine and in CI.
- Up-to-date checks and the build cache skip work whose inputs are unchanged. So custom tasks must declare inputs and outputs to join the system honestly.
- Maven versus Gradle is a team-fluency decision with a feature tiebreaker: conventions and universality versus custom logic and incremental speed.
FAQ
What is Gradle in Java?
A build tool and dependency manager that describes projects in an executable DSL, build.gradle.kts. It resolves the same dependency coordinates as Maven. Then it builds through a graph of tasks that can be extended with Kotlin. It is the standard build tool for Android and a first-class choice for Java services.
What is build.gradle.kts?
The build description file, written in Gradle’s Kotlin DSL. Plugins declare capabilities, and repositories and dependencies resolve libraries. Meanwhile, the toolchain pins the JDK, and task configuration customizes the build. Because it is code, loops and functions are legal inside it.
What is the Gradle wrapper?
A committed script and jar, gradlew, that downloads and runs the exact Gradle version the project declares. It makes the build tool part of the repository. As a result, every developer and CI machine runs the same Gradle without installing anything.
What is the difference between Gradle and Maven?
Both resolve dependencies and produce the same artifacts from the same standard layout. Maven uses declarative XML and fixed lifecycle phases. In contrast, Gradle uses an executable Kotlin DSL, a task graph, incremental builds, caching, and the committed wrapper. Team fluency usually decides between them.
What are Gradle tasks?
The units of work in a Gradle build, such as compileJava, test, jar, and your custom tasks. They form a dependency graph, and a single command runs them in order. Tasks with unchanged declared inputs and outputs are skipped, which is the incremental build.
Conclusion
You can now read and write both build dialects of production Java. The last article covered Maven’s convention corridor. This one covered Gradle for Java projects: the task graph, DSL, toolchain, and wrapper. From here the course runs on Maven for consistency, but every Gradle file you meet is now readable at a glance.
The next article changes the part’s subject from building to verifying with JUnit 5, the test framework that both build tools run. There, you will write your first assertions and organize real test classes. You will also start the habit that separates production code from hopeful code.
One tool per repo, one version committed, one command that builds everywhere. That is the whole discipline.
Last updated on 23 September 2026.
