Maven vs Gradle, migrating from Maven, dependency configurations (implementation vs api), multi-project builds with convention plugins, incremental builds, troubleshooting build scripts, custom tasks, resolving version conflicts, environment configuration, and the structure of build.gradle(.kts).
Published September 25, 2026
Modern Gradle answers use the Kotlin DSL, version catalogs, convention plugins and configuration avoidance. Answers built around Groovy subprojects {} blocks, JCenter and compile configurations date from around 2018. Show the current idioms.
Short answer:
| Maven | Gradle | |
|---|---|---|
| Build definition | Declarative XML (pom.xml), a fixed lifecycle | A Kotlin or Groovy DSL, a task graph |
| Flexibility | Conventions first; customising means plugins | Highly programmable (tasks, plugins, logic) |
| Performance | Full phases each time (plus the build cache extension) | Incremental builds, a build cache, configuration cache, a daemon: usually much faster on large builds |
| Conflict resolution | Nearest wins | Highest version wins (plus rich constraints) |
| Ecosystem | Ubiquitous, very stable | Standard for Android and Kotlin; common for large JVM monorepos |
| Learning curve | Low | Higher: easy to write builds that are hard to understand |
Key points to cover:
Short answer:
gradle init, which converts a POM into a starting build (review the result, don't trust it blindly).platform(...), versions go into a version catalog (gradle/libs.versions.toml), and the scopes map to configurations (compile → implementation/api, provided → compileOnly, runtime → runtimeOnly, test → testImplementation).Short answer: Declare them in dependencies {} with the right configuration, keep their versions in a version catalog, and use platforms (BOMs) to align families of libraries.
// build.gradle.kts
dependencies {
implementation(platform(libs.spring.boot.bom)) // BOM → aligned versions
implementation(libs.spring.boot.starter.web)
api(libs.money.api) // exposed to consumers of this module
compileOnly(libs.lombok)
annotationProcessor(libs.lombok)
runtimeOnly(libs.postgresql)
testImplementation(libs.junit.jupiter)
}
repositories { mavenCentral() }
Key points to cover:
implementation vs api: implementation hides the dependency from consumers' compile classpaths, which gives faster recompilation and less leakage. Use api only when the types appear in your public API (this needs the java-library plugin).mavenCentral() and your company repository.Short answer:
settings.gradle.kts declares the modules (include("api", "domain", "persistence", "app")), and dependency resolution management (repositories, version catalogs).build-logic/ as an included build, or buildSrc), such as java-conventions and spring-service-conventions, and apply them per module. Avoid the older allprojects {} / subprojects {} cross-configuration: it breaks isolation and the configuration cache.project(":domain") dependencies.Short answer: Every task declares its inputs (source files, properties, classpath) and outputs (directories, files). Gradle fingerprints them. If nothing changed since the last run, and the outputs still exist, the task is UP-TO-DATE and is skipped. Java compilation is also incremental within a task, recompiling only the affected classes. The build cache extends this across branches, machines and CI (the task is loaded FROM-CACHE).
Advantages: much faster edit-build-test cycles, and faster CI, especially in large multi-module builds.
Key points to cover:
@InputFiles, @OutputDirectory). Otherwise they always run, or, worse, are wrongly skipped.Short answer (a model story): "After an upgrade, the build failed with a NoSuchMethodError from a Jackson class, but only in CI. ./gradlew dependencyInsight --dependency jackson-databind --configuration runtimeClasspath showed that a plugin's transitive dependency upgraded Jackson beyond what our framework supported. The build scan (--scan) confirmed that CI resolved differently because of a stale lock file. I aligned Jackson through the BOM platform, added dependency locking, and a failOnVersionConflict() check for critical groups. Other tools I rely on: --stacktrace/--info, ./gradlew help --task <name>, and ./gradlew :app:dependencies."
Short answer: Register a task lazily with tasks.register. For anything non-trivial, write a task class with typed, annotated inputs and outputs, so it's cacheable and incremental.
abstract class GenerateBuildInfo : DefaultTask() {
@get:Input abstract val version: Property<String>
@get:OutputFile abstract val output: RegularFileProperty
@TaskAction fun write() {
output.get().asFile.writeText("""{"version":"${version.get()}"}""")
}
}
tasks.register<GenerateBuildInfo>("buildInfo") {
version = project.version.toString()
output = layout.buildDirectory.file("generated/build-info.json")
}
tasks.named("processResources") { dependsOn("buildInfo") }
Use cases: generating code or metadata, running a database migration check, packaging extra artifacts, verifying licences, starting and stopping test infrastructure, and custom release steps.
Short answer:
./gradlew dependencyInsight --dependency <lib> --configuration runtimeClasspath explains which version was chosen and why.implementation(platform(...))).constraints { implementation("lib:1.2.3") }), with a reason.resolutionStrategy.force(...), which hides future conflicts.failOnVersionConflict() for critical groups, and Renovate or Dependabot for controlled upgrades.Short answer: Keep the artifact environment-neutral, and supply environment configuration at runtime: Spring profiles, environment variables, a config server. The build shouldn't care whether it's heading for dev or prod. Gradle properties (-Pfoo, gradle.properties) are for build options (enabling integration tests, signing, a native build), not for application settings such as database URLs.
Common trap: separate dev.gradle/prod.gradle files that bake in different URLs. That produces different binaries per environment, which is the same anti-pattern as Maven profiles used for environments.
build.gradle(.kts), and how is it structured?Short answer: It's the build script for a project. It configures that project's plugins, dependencies and tasks. A typical structure:
plugins { // 1. plugins (convention plugins preferred)
id("acme.spring-service-conventions")
alias(libs.plugins.spring.boot)
}
group = "com.acme.orders" // 2. coordinates
version = "1.4.0"
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } // 3. extensions config
dependencies { … } // 4. dependencies
tasks.test { useJUnitPlatform() } // 5. task configuration
Key points to cover:
settings.gradle.kts defines the build (its modules, plugin management, repositories and catalogs). gradle.properties holds build flags (org.gradle.caching=true, org.gradle.configuration-cache=true).Q: What is a version catalog?
A: A TOML file (gradle/libs.versions.toml) that centralises library and plugin coordinates and versions, and generates type-safe accessors (libs.spring.boot.starter.web) shared by all modules.
Q: Groovy DSL or Kotlin DSL?
A: Kotlin DSL (.gradle.kts) gives type safety, IDE completion and refactoring support, and has been the default for new builds since Gradle 8.2. Groovy still works, and appears in many older builds.
Q: What is the configuration cache? A: It caches the result of the configuration phase (the task graph), so later builds skip evaluating the build scripts. It gives large speed-ups, but it requires tasks and plugins to avoid touching mutable global state at execution time.
Q: compileOnly vs runtimeOnly?
A: compileOnly is needed to compile but not at runtime (Lombok, some annotations). runtimeOnly is needed at runtime but not to compile (JDBC drivers, logging implementations).