A build tool does three jobs for a Java project: it works out the dependency tree, it puts the right things on the classpath, and it runs the same sequence of steps every time. Maven does that with a declarative XML file; Gradle does it with a Groovy or Kotlin script. You can absolutely build a small project by hand with javac and a lib folder, and the tool becomes essential the moment you have more than a handful of dependencies.
What problem does a build tool solve?
When a program uses a library, that library's code has to be on the classpath at compile time and at run time. With one library you can do it by hand.
import java.io.File;
import java.net.URLClassLoader;
public class ManualClasspath {
public static void main(String[] args) {
System.out.println("a jar file is just a zip archive : " + isJar(new File("lib/library.jar")));
System.out.println("a class file is not : " + isJar(new File("classes/Service.class")));
ClassLoader loader = ManualClasspath.class.getClassLoader();
System.out.println("loader type : " + loader.getClass().getSimpleName());
System.out.println("loader parent : " + loader.getParent().getClass().getSimpleName());
System.out.println("library classes live on the classpath, not in your source folder");
}
static boolean isJar(File file) {
return file.getName().endsWith(".jar");
}
}a jar file is just a zip archive : true
a class file is not : false
loader type : AppClassLoader
loader parent : PlatformClassLoader
library classes live on the classpath, not in your source folderWith five libraries, each of which depends on three others at a specific version, it stops being practical. The tool downloads the jars, resolves the version conflicts, and builds the classpath for you. That is the dependency management half of the job, and it is the half you will miss most if you never use one.
What layout do build tools expect?
Both tools use the same convention, and the reason is that it separates production code from test code and from generated output.
pricing/
pom.xml
src/
main/
java/
com/tutorsbot/pricing/
PricingService.java
resources/
application.properties
test/
java/
com/tutorsbot/pricing/
PricingServiceTest.java
target/
classes/
test-classes/
surefire-reports/The rules to remember are short. Production code lives under src/main/java, tests under src/test/java, non-code resources under the matching resources folder, and everything the build produces goes into target (Maven) or build (Gradle). Package directories match the package name, so com/tutorsbot/pricing/PricingService.java declares package com.tutorsbot.pricing;.
What does Maven do?
Maven is declarative. You describe what the project needs - its dependencies, its Java release, its plugins - and Maven works out the steps. The file is called pom.xml, for project object model, and it is the centre of everything. The lesson on testing showed a complete one.
The phases run in order and each one does a specific job: compile compiles the sources, test runs the tests, package builds the jar, verify runs the checks that come after packaging, and install puts the artefact in your local repository so other projects can depend on it. You almost always run mvn verify or mvn test while you work.
What does Gradle do?
Gradle is programmable. The build file is a script, so loops, variables and conditionals are all available. That is its main advantage and its main cost - a build file can grow into something nobody wants to touch.
plugins {
id 'java'
}
group = 'com.tutorsbot'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.slf4j:slf4j-api:2.0.13'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}
test {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed'
}
}Read that build file as four sections: plugins that apply capabilities, the group and version that identify your artefact, a repository to fetch dependencies from, and the dependencies themselves. The test block at the end tells the test task to use the JUnit platform and to print each test result, which is the part you will spend the most time adjusting.
Gradle also applies a convention-over-configuration approach: the standard layout and standard tasks exist without being declared, so a build file can be as short as the dependency list.
How are dependencies identified?
Every artefact has a coordinate: a group, a name and a version.
public class BuildConcept {
record Artifact(String group, String name, String version) {
String coordinate() {
return group + ":" + name + ":" + version;
}
}
public static void main(String[] args) {
Artifact jupiter = new Artifact("org.junit.jupiter", "junit-jupiter", "5.10.2");
Artifact slf4j = new Artifact("org.slf4j", "slf4j-api", "2.0.13");
System.out.println("coordinate : " + jupiter.coordinate());
System.out.println("coordinate : " + slf4j.coordinate());
System.out.println("scope matters : test scope keeps a library out of the artefact");
String[] phases = { "compile", "test", "package", "verify", "install" };
for (int i = 0; i < phases.length; i++) {
System.out.println("phase " + (i + 1) + " : " + phases[i]);
}
}
}coordinate : org.junit.jupiter:junit-jupiter:5.10.2
coordinate : org.slf4j:slf4j-api:2.0.13
scope matters : test scope keeps a library out of the artefact
phase 1 : compile
phase 2 : test
phase 3 : package
phase 4 : verify
phase 5 : installThat triple is what you paste into a dependency block, and it is how the tool knows exactly what to download. The scope matters just as much: a normal dependency is bundled with your artefact, a test dependency is available while testing but stays out of the shipped jar, and a provided dependency is expected to be supplied by the environment at run time.
Which one should you use?
| Consideration | Maven | Gradle |
|---|---|---|
| Configuration style | Declarative XML | Programmable script |
| Learning curve | Gentler, very standardised | Steeper, more freedom |
| Build speed | Slower on large projects | Usually faster, with caching |
| Custom logic | Plugins | Plain code in the build file |
| Where you will meet it | Most enterprise codebases | Most newer projects and Android |
The practical advice is to learn whichever one your project already uses and stop there. Both give you the same result: a repeatable build, a resolved dependency tree, and a test run that anyone can trigger with one command.
What do plugins actually do?
A plugin is the extension mechanism of a build tool. In Maven, a plugin is a jar of tasks, and each task is called a goal - the compiler plugin has a compile goal, the test plugin has a test goal, and the jar plugin has a jar goal. In Gradle, a plugin is code that applies tasks and configuration to the build, so a plugin can do anything a script can do.
Most of the plugins you will use fall into three groups. The first compiles, tests and packages - the core of every build. The second produces extra artefacts, such as a single executable jar with all the dependencies bundled inside, or a source archive to attach to a release. The third integrates with other tools: a plugin that uploads your artefacts, or one that publishes a test report.
public class PluginIdea {
public static void main(String[] args) {
String[] goals = { "compile", "test", "jar", "shade", "javadoc" };
for (int i = 0; i < goals.length; i++) {
System.out.println("goal " + (i + 1) + " : " + goals[i]);
}
System.out.println("a goal is one task inside one plugin");
System.out.println("a plugin can be bound to a phase so it runs automatically");
}
}goal 1 : compile
goal 2 : test
goal 3 : jar
goal 4 : shade
goal 5 : javadoc
a goal is one task inside one plugin
a plugin can be bound to a phase so it runs automaticallyThe idea to take away is the binding. A plugin goal can be attached to a lifecycle phase, so the test goal runs automatically whenever test is invoked, and the jar goal runs whenever package is invoked. That is why mvn package runs the tests first without anyone asking it to.
When you add a plugin, read what phase it binds to and what it actually does before you trust it in a release build. A plugin that runs during package and uploads somewhere is worth understanding before it goes anywhere near your production pipeline.
The habits to keep
- Keep the build file in version control and treat it as code.
- Pin versions of dependencies rather than relying on whatever is newest.
- Use the
testscope for test-only libraries. - Run
mvn verifyorgradle buildbefore you push. - Never commit the
targetorbuilddirectory. - Learn the standard layout so the tool finds your sources without configuration.
Try it yourself: create a folder with the standard layout, put a single class in src/main/java and a test in src/test/java, and write both a pom.xml and a Gradle build file for it. Run the build with each tool and confirm they produce the same artefact in their output directory. Then add a second dependency to one of them and watch the tool download it into its local cache.