Build Tools & Modules

Gradle Dependencies & Tasks

15 min Lesson 6 of 13

Gradle Dependencies & Tasks

Gradle's power lies in two interlocking systems: its dependency management model — built around named configurations — and its task graph, which lets you compose, extend, and automate every aspect of your build. In this lesson you will master both at the depth required for production projects.

Dependency Configurations — the Full Picture

In Gradle, a configuration is a named bucket that groups dependencies for a specific purpose (compiling, running tests, generating annotation processors, etc.). The Java plugin and the application/library plugins register several configurations automatically.

The most important ones for a modern Java project are:

  • implementation — the dependency is on the compile classpath of this module but is not leaked to consumers that depend on this module. Use it for the vast majority of your dependencies.
  • api (library plugin only) — like implementation but does expose the dependency to consumers. Use sparingly: overusing api forces unnecessary recompilation across the entire project graph.
  • compileOnly — present during compilation, absent from the runtime classpath. Classic use-case: Lombok, Jakarta annotation APIs when the runtime container provides them.
  • runtimeOnly — absent at compile time, present at runtime. Typical use-case: JDBC drivers, SLF4J bindings (e.g. logback-classic).
  • testImplementation — like implementation but scoped to the test source set.
  • testCompileOnly / testRuntimeOnly — test-scoped equivalents of the above.
  • annotationProcessor — annotation processors run by javac; they are NOT placed on the application classpath at all.
Why does implementation vs api matter so much? Gradle tracks which jars appear on the compile classpath of downstream projects. If you use api, a change in that transitive dependency triggers recompilation of every downstream module. In a large multi-module build this can add minutes to incremental builds. Prefer implementation by default and only promote to api when the type is part of your public API surface.

Declaring Dependencies

Dependencies are declared inside the dependencies { } block in build.gradle (Groovy) or build.gradle.kts (Kotlin DSL). The canonical form is the Maven coordinate: group:artifact:version.

// build.gradle.kts — realistic dependency block for a Spring Boot service dependencies { // Core framework — on the compile + runtime classpath, not leaked to consumers implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") // Compile-time only — Lombok generates code; the jar is not needed at runtime compileOnly("org.projectlombok:lombok:1.18.32") annotationProcessor("org.projectlombok:lombok:1.18.32") // Runtime only — the JDBC driver; no Spring code calls it directly by class name runtimeOnly("org.postgresql:postgresql:42.7.3") // Test dependencies testImplementation("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") }
Use a Bill of Materials (BOM) to align versions across a framework family. Import it once via platform(...) and omit version strings on individual dependencies. Spring Boot's BOM is the canonical example.
dependencies { implementation(platform("org.springframework.boot:spring-boot-dependencies:3.3.0")) // No version needed — the BOM pins it implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.core:jackson-databind") }

Dependency Resolution and Version Conflict Strategies

When the dependency graph contains the same library at two different versions, Gradle applies optimistic version selection by default: it picks the highest requested version. This differs from Maven's nearest-wins strategy and typically produces a more correct result.

You can override resolution behaviour when needed:

// Force a specific version for a transitive dependency (use sparingly) configurations.all { resolutionStrategy { force("com.google.guava:guava:33.2.1-jre") } } // Fail the build if any version conflict is left unresolved — useful in strict audits configurations.all { resolutionStrategy.failOnVersionConflict() }

Custom Tasks — Fundamentals

Every action in a Gradle build is a task. A task has inputs, outputs, and a set of actions. Gradle's incremental build system uses the inputs/outputs to determine whether a task is UP-TO-DATE and can be skipped entirely — this is the primary source of Gradle's speed advantage over Maven for large projects.

The simplest way to define a custom task is using the tasks.register API, which creates the task lazily (it is not configured until something actually needs it):

// tasks.register is preferred over the older tasks.create (eager) tasks.register("printBuildInfo") { group = "build info" description = "Prints project coordinates to the console." doLast { println("Project: ${project.name}") println("Version: ${project.version}") println("Group: ${project.group}") } }

Run it with ./gradlew printBuildInfo. The group and description fields make the task discoverable via ./gradlew tasks --all.

Typed Tasks — the Professional Approach

Most real tasks extend a built-in type such as Copy, Jar, JavaExec, Exec, or Zip. Typed tasks get incremental build support automatically when you declare inputs and outputs correctly.

import org.gradle.api.tasks.Copy // Package config files into a separate zip for deployment tasks.register<Zip>("packageConfig") { group = "distribution" description = "Zips all environment config files for deployment." from(layout.projectDirectory.dir("src/main/config")) include("*.yml", "*.properties") // Output — Gradle tracks this for UP-TO-DATE checks archiveFileName.set("config-${project.version}.zip") destinationDirectory.set(layout.buildDirectory.dir("dist")) } // A JavaExec task that runs a database migration tool tasks.register<JavaExec>("migrateDb") { group = "database" description = "Runs Flyway migrations against the local database." classpath = sourceSets["main"].runtimeClasspath mainClass.set("org.flywaydb.commandline.Main") args("migrate", "-url=jdbc:postgresql://localhost/mydb") // This task is never UP-TO-DATE — it always runs when invoked outputs.upToDateWhen { false } }

Task Dependencies and Ordering

Tasks form a directed acyclic graph (DAG). You control relationships with three mechanisms:

  • dependsOn — ensures the named task runs before this one and actually runs it.
  • mustRunAfter — enforces ordering when both tasks are scheduled but does not trigger the other task.
  • finalizedBy — runs a task after this one, even if this task fails (useful for teardown / test-report generation).
tasks.register("integrationTest") { group = "verification" description = "Runs integration tests against a live database." // Ensure the app jar is built before integration tests start dependsOn(tasks.named("jar")) // Always run the report generator afterwards, even on failure finalizedBy(tasks.named("generateIntegrationReport")) doLast { println("Running integration tests...") } } tasks.named("check") { // Add integrationTest to the standard check lifecycle without replacing it dependsOn(tasks.named("integrationTest")) }
Avoid tasks.create (eager registration). It configures the task immediately at configuration time — every time the build script is evaluated — even if the task is never going to run. With dozens of custom tasks this wastes seconds on every Gradle invocation. Always use tasks.register (lazy) instead.

Incremental Builds and Caching

For custom tasks to be incremental, declare your inputs and outputs explicitly using Gradle's property annotations or the inputs/outputs API:

import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.* abstract class GenerateVersionFile : DefaultTask() { @get:Input abstract val version: Property<String> @get:OutputFile abstract val outputFile: RegularFileProperty @TaskAction fun generate() { outputFile.get().asFile.writeText("version=${version.get()}\n") } } tasks.register<GenerateVersionFile>("generateVersion") { version.set(project.version.toString()) outputFile.set(layout.buildDirectory.file("generated/version.properties")) }

With @Input and @OutputFile in place, Gradle will skip the task if the version string has not changed since the last build — and with the build cache enabled (org.gradle.caching=true in gradle.properties), it can even restore the output from a remote cache shared across CI agents, yielding near-zero rebuild times for unchanged work.

Summary

Dependency configurations (implementation, api, compileOnly, runtimeOnly, annotationProcessor) give you precise control over what appears on each classpath and what is exposed to consumers. Custom tasks — registered lazily with tasks.register, typed for incremental support, and linked via dependsOn/finalizedBy — let you extend the build with arbitrary automation while keeping it fast. Declare inputs and outputs accurately and Gradle's incremental and caching machinery does the rest.