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
}
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 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
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
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.
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.