rgoussu@goussu: ~/library/java/build-ecosystem
~/library/java/build-ecosystem cat gradle.md

Gradle deep dive

# Gradle's task DAG, Kotlin/Groovy DSLs, configuration vs execution, caching and incrementality, version catalogs, and convention plugins.

Conceptsaved 2026-08-09updated 2026-08-20 #java#build#tooling#gradle

Overview

Gradle models a build as a directed acyclic graph of tasks with declared inputs and outputs, rather than Maven's fixed phase sequence. That model buys incrementality (skip tasks whose inputs haven't changed), caching (reuse outputs across builds and machines), and arbitrary extensibility — the build script is real code. The price is the same thing: build logic is a codebase of its own that must be kept fast, comprehensible, and compatible across Gradle versions.

Key points

  • Task DAG vs phases: every unit of work is a task; dependencies between tasks form a graph, and Gradle runs only the tasks needed for what you asked (gradlew build is just a task with dependencies). Contrast Maven, where a phase always runs its full prefix of the lifecycle.
  • Two DSLs: Groovy (build.gradle) and Kotlin (build.gradle.kts). Kotlin DSL is the modern default — static typing gives IDE completion and earlier failure.
  • Configuration vs execution phases: scripts are evaluated first (building the task graph), then tasks execute. Slow configuration hurts every invocation — hence the configuration cache, which serialises the task graph and skips configuration entirely when nothing relevant changed.
  • Incremental builds & build cache: up-to-date checks skip tasks whose inputs/outputs are unchanged; the build cache (local and remote) reuses task outputs by input hash — remote cache lets CI populate results developers then download instead of rebuild.
  • Dependency configurations: implementation (internal, not leaked to consumers' compile classpath — faster rebuilds, cleaner APIs), api (part of your public signature, only in library projects applying java-library), plus compileOnly, runtimeOnly, testImplementation.
  • Java toolchains: the java { toolchain { languageVersion = … } } block declares which JDK compiles the code, independently of the JVM running Gradle. With the foojay resolver plugin applied, Gradle will download a matching JDK if none is installed — genuinely useful, and the reason a Gradle build can be JDK-agnostic on the host.
  • But Gradle still has to start on the JVM it is launched with, and a given Gradle version only supports JDKs up to a known maximum. So a too-new JAVA_HOME fails before any toolchain logic runs, with an error that looks nothing like "upgrade Gradle". The practical rule: the Gradle version and the launching JDK are coupled even when the compile JDK is not — change one, check the other.
  • Version catalogs (gradle/libs.versions.toml): central, typed declaration of dependency versions shared across modules — libs.junit.jupiter in scripts.
  • Convention plugins: shared build logic packaged as plugins (typically in buildSrc/ or an included build) instead of copy-pasted script blocks — the sanctioned way to keep multi-project builds DRY.
  • Daemon & wrapper: a long-lived daemon JVM keeps caches and JIT warm between invocations; the wrapper (gradlew) pins the Gradle version in the repo — commit it.

Details

Multi-project builds

  • settings.gradle.kts declares the included projects; each has its own build script. Prefer many small decoupled projects: they parallelise, and incrementality works at project granularity.
  • Cross-project coordination goes through convention plugins and version catalogs, not allprojects {} / subprojects {} blocks — cross-project configuration defeats the configuration cache and couples builds.

When the flexibility pays — and its cost

  • Pays: large multi-module builds (incrementality + caching dominate), Android (mandated), builds with genuinely custom steps (code generation, unusual packaging), polyglot builds.
  • Costs: build code is code — it needs review, tests (TestKit), and upgrades; major Gradle versions deprecate APIs aggressively. A team that writes clever build logic owns that logic forever. If the project is a plain library, Maven's rigidity is often the cheaper choice.

Everyday commands

Command Effect
./gradlew build Assemble + test everything needed
./gradlew test --tests "SomeClass" Run a single test class
./gradlew :app:dependencies Dependency report for one project
./gradlew build --scan Publish a build scan for diagnosing performance

Examples

Minimal build.gradle.kts for a Java 21 application:

plugins {
    java
    application
}

group = "dev.rgoussu"
version = "0.1.0-SNAPSHOT"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
    testImplementation(platform("org.junit:junit-bom:5.11.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

application {
    mainClass = "dev.rgoussu.demo.Main"
}

tasks.test {
    useJUnitPlatform()
}

Related

  • The Java build ecosystem — parent summary: where Gradle sits among the three tiers.
  • Maven deep dive — the convention-first alternative; contrast fixed lifecycle vs task DAG.
  • Testing in Javatest tasks, JUnit Platform wiring, and the integration-test source-set pattern live here.
  • Version managers — provisioning the launching JDK, which toolchains deliberately do not cover.