Gradle Basics

Incremental builds, the task graph, and dependency configurations that actually matter

← Back to Index

What is Gradle?

Gradle is a build automation tool configured with a real programming language (Kotlin or Groovy) instead of declarative XML. Where Maven follows a fixed lifecycle of named phases, Gradle builds a task graph: a directed graph of tasks with dependencies between them, computed fresh for each build based on what actually needs to run.

The problem this solves: Maven's fixed lifecycle is predictable but rigid — every build re-runs the same phases regardless of what changed. Gradle tracks inputs and outputs of every task and skips work that's already up to date. For a large codebase, this is the difference between a 3-minute build and a 15-second build when you've only touched one file.

// The headline difference: Gradle SKIPS work Maven would always redo
./gradlew build
// :compileJava UP-TO-DATE   ← nothing changed since last run, skipped entirely
// :test UP-TO-DATE          ← test inputs unchanged, skipped
// :jar                      ← only this actually ran

// This page uses Kotlin DSL (build.gradle.kts) — the modern default,
// statically typed with IDE autocomplete. Groovy DSL (build.gradle) still
// exists in many legacy projects but Kotlin DSL is what new projects use.

Project Structure

my-project/
├── build.gradle.kts      # build configuration — what THIS project builds
├── settings.gradle.kts   # project name + included subprojects
├── gradle/wrapper/        # pins the exact Gradle version — always commit this
├── gradlew, gradlew.bat   # wrapper scripts — never invoke 'gradle' directly
└── src/main/java, src/test/java   # same convention as Maven

A real Spring Boot build.gradle.kts

plugins {
    java
    id("org.springframework.boot") version "3.2.0"
    id("io.spring.dependency-management") version "1.1.4"  // gives BOM-style version management
}

group = "com.company"
version = "0.0.1-SNAPSHOT"

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

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("org.postgresql:postgresql")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.test { useJUnitPlatform() }

// Tasks provided by the Spring Boot plugin:
//   ./gradlew bootRun          — run the application
//   ./gradlew bootJar          — build an executable fat jar
//   ./gradlew bootBuildImage   — build an OCI/Docker image, no Dockerfile needed

Dependency Configurations

Gradle's equivalent of Maven scopes — but with one extra distinction Maven doesn't have: api vs implementation, which matters specifically when your project is itself a library consumed by others.

dependencies {
    implementation("com.google.guava:guava:32.1.3-jre")  // standard — compile + runtime

    compileOnly("org.projectlombok:lombok:1.18.30")       // needed to compile, NOT bundled/runtime
    annotationProcessor("org.projectlombok:lombok:1.18.30") // runs Lombok's code generation

    runtimeOnly("org.postgresql:postgresql:42.6.0")        // needed at runtime, NOT to compile your code

    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")

    // api vs implementation — ONLY matters if you're building a library
    // consumed by other projects/modules
    api("com.company:public-contracts:1.0")       // leaks into CONSUMERS' compile classpath
    implementation("com.company:internal-utils:1.0") // hidden from consumers entirely
}
api vs implementation — why it matters for build speed

If module B exposes a type from dependency X as part of its own public API, and module A depends on B via implementation, consumers of A cannot see X on their compile classpath — which breaks if they actually need it. Using api exposes X transitively, as Maven always does by default. But there's a real cost to defaulting to api everywhere: any change to X forces recompilation of every consumer down the chain. Use implementation by default; reach for api only when a dependency genuinely leaks into your own public method signatures. This distinction is exactly what makes large multi-module Gradle builds compile faster than the Maven equivalent — fewer unnecessary recompilations.

Common Tasks

# Always invoke via the wrapper — guarantees everyone uses the same Gradle version
./gradlew build              # compile, test, package — Gradle's equivalent of mvn package
./gradlew clean build        # wipe build/ first

./gradlew test                          # run all tests
./gradlew test --tests "UserTest"       # single class
./gradlew test --tests "*ServiceTest"   # pattern match

./gradlew run                # run via the 'application' plugin
./gradlew bootRun            # run via the Spring Boot plugin

# Diagnostics — the Gradle equivalents of Maven's dependency:tree commands
./gradlew dependencies                              # full dependency tree
./gradlew dependencyInsight --dependency jackson-core  # WHY is this version resolved? shows the conflict path

./gradlew tasks              # list all available tasks for this project
./gradlew tasks --all        # include tasks from applied plugins too

Incremental Builds — Gradle's Real Advantage

Every Gradle task declares its inputs (source files, configuration) and outputs. Before running a task, Gradle hashes the inputs and compares against the last successful run. If nothing changed, the task is marked UP-TO-DATE and genuinely skipped — not just fast, but not executed at all.

// gradle.properties — performance tuning that actually matters in CI
org.gradle.parallel=true              // build independent subprojects concurrently
org.gradle.caching=true               // reuse task outputs across builds/machines (build cache)
org.gradle.configuration-cache=true   // cache the build configuration phase itself, not just tasks
org.gradle.jvmargs=-Xmx2g

# Command-line equivalents for a one-off run
./gradlew build --parallel
./gradlew build --build-cache
./gradlew build --no-daemon   # disable the daemon — only for CI isolation, not local dev
The build cache vs the daemon — different things

The Gradle daemon is a long-lived background JVM process that keeps the build tool warm between invocations — avoids JVM startup cost on every command, on by default. The build cache is separate: it stores task outputs keyed by input hash, and can be shared across machines (a remote/CI build cache means a teammate's identical compile can be reused by your machine, skipping the work entirely). Both contribute to speed but solve different problems — JVM startup overhead vs redundant computation.

Multi-Project Builds

// settings.gradle.kts — declares which subprojects exist
rootProject.name = "my-project"
include("api", "service", "web")

// Root build.gradle.kts — shared config applied to every subproject
subprojects {
    apply(plugin = "java")
    repositories { mavenCentral() }
    java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
}

// Project-specific dependency, including a project-to-project dependency
project(":service") {
    dependencies {
        implementation(project(":api"))  // depends on the sibling 'api' module's output directly
    }
}

Gradle computes the build order from these project-to-project dependencies automatically — same principle as Maven's reactor, different syntax.

Custom Tasks

// Simple ad-hoc task
tasks.register("printVersion") {
    doLast { println("Building version ${project.version}") }
}

// Task with explicit dependency ordering
tasks.register("releaseCheck") {
    dependsOn("test", "check")
    doLast { println("All checks passed — ready to release") }
}

// Typed task — gets you Gradle's built-in input/output tracking for free,
// which is what makes THIS task incrementally skippable too
tasks.register<Copy>("copyDocs") {
    from("docs")
    into("build/docs")
    include("**/*.md")
}

// Configuring an existing task (from a plugin) rather than creating a new one
tasks.named<Jar>("jar") {
    manifest {
        attributes("Main-Class" to "com.company.Main")
    }
}

The Gradle Wrapper

# Generate/update the wrapper to pin a specific Gradle version
gradle wrapper --gradle-version 8.5

# Everyone — devs and CI — uses the wrapper, never a locally-installed gradle
./gradlew build      # Unix/Mac
gradlew.bat build    # Windows
Always commit the wrapper files

gradlew, gradlew.bat, and gradle/wrapper/ must be in version control. This guarantees every developer and every CI run uses the exact same Gradle version — without it, "works on my machine" becomes "works with my locally installed Gradle version," which is exactly the inconsistency the wrapper exists to eliminate.

Interview Questions

🎓 Junior level

Q: What is the difference between implementation and api?
Both make a dependency available to compile and run your own module. api additionally exposes it transitively to anything that depends on your module — equivalent to how Maven dependencies normally work. implementation hides it from consumers entirely. Default to implementation; it produces faster builds because changes to a hidden dependency don't force recompilation of downstream consumers.

Q: Why does Gradle use a wrapper instead of expecting a global install?
The wrapper (gradlew/gradlew.bat + gradle/wrapper/) pins an exact Gradle version per project and downloads it automatically on first use. This guarantees every developer and CI environment builds with the identical version, eliminating "works on my machine" caused by Gradle version drift.

🔥 Senior level

Q: How does Gradle's incremental build model differ fundamentally from Maven's lifecycle?
Maven's lifecycle always executes every phase up to the one requested — there is no built-in mechanism to skip a phase whose inputs haven't changed. Gradle models the build as a DAG (directed acyclic graph) of tasks with declared inputs and outputs; before executing a task, Gradle hashes its inputs and compares against the last successful execution, marking it UP-TO-DATE and skipping it entirely if nothing changed. Combined with the build cache (which can be shared across machines/CI), this means a large multi-module project can see dramatic speedups on incremental changes — Maven re-validates and recompiles modules it doesn't need to, while Gradle genuinely skips them.

Q: When would api leak cause a real production build problem?
If module B (a library) declares implementation("commons:io:1.0") but a method in B's own public API returns a type from commons:io, any consumer calling that method needs commons:io on its own compile classpath — but implementation hides it. The consumer's build fails with a "cannot find symbol" error referencing a type they never explicitly depended on. The fix is changing B's declaration to api for that specific dependency, making the leak explicit and intentional rather than an accidental compile failure downstream.