Build Tools and Project Structure
Maven, Gradle, the standard project layout. Dependency management, build lifecycles, and which tool to pick in 2026.
The build problem
A Java project is more than just .java files. You have dependencies (third-party libraries), tests (separate from production code), resources (config files, templates), and the need to produce something deployable (a JAR, a WAR, a Docker image). Doing this manually with javac and java works for hello-world; it falls apart at any real scale.
Build tools manage all of this. They:
- **Resolve dependencies** — download the libraries your code needs, including transitive dependencies (the libraries those libraries need).
- **Compile** — source files in standard locations to bytecode.
- **Run tests** — and report results.
- **Package** — produce a JAR or WAR.
- **Run lifecycle tasks** — generate sources, run linters, deploy artifacts.
Two tools dominate Java: **Maven** and **Gradle**. Both work fine in 2026. Both have downsides. This chapter walks through the standard layout, both tools, and how to pick.
The standard project layout
Both Maven and Gradle expect the same conventional directory structure:
my-app/
├── pom.xml or build.gradle.kts
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/app/
│ │ │ ├── Application.java
│ │ │ └── services/...
│ │ └── resources/
│ │ ├── application.properties
│ │ └── logback.xml
│ └── test/
│ ├── java/
│ │ └── com/example/app/
│ │ └── ApplicationTest.java
│ └── resources/
└── target/ (Maven) or build/ (Gradle)
Key conventions:
- **src/main/java** — production source code. Package directories follow the Java package convention.
- **src/main/resources** — non-Java files shipped with the application (config, templates, static assets).
- **src/test/java** — test source code. **Same package structure as the code under test** so tests can see package-private members.
- **src/test/resources** — test-specific resources.
- **target/** (Maven) or **build/** (Gradle) — compiled output. Always gitignored.
Stick to this layout. Both tools default to it. Frameworks like Spring Boot assume it. IDEs detect projects based on it. Deviating creates friction with no benefit.
Maven
Maven (released 2004) is the older of the two. It's declarative — you describe what you want in pom.xml, and Maven figures out how to do it.
A minimal pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.16.0</version>
</dependency>
</dependencies>
</project>
**Common commands:**
- mvn compile — compile main sources
- mvn test — compile and run tests
- mvn package — produce the JAR
- mvn install — install the JAR into your local Maven repository (~/.m2/repository)
- mvn clean — delete the target/ directory
**The Maven lifecycle** is a fixed sequence of phases: validate → compile → test → package → verify → install → deploy. Running any phase runs all earlier phases too. So mvn install does everything from compile through publish-to-local-repo.
**Pros of Maven:**
- Stable, mature, conservative. Maven projects from 2008 still build today.
- Declarative — no programming logic in build files.
- Standardised — once you know one Maven project, you know them all.
- Huge ecosystem of plugins.
**Cons:**
- XML is verbose and clunky.
- Hard to customise beyond what plugins offer.
- Slower than Gradle for incremental builds.
Gradle
Gradle (released 2007, popularised by Android in 2014) is the newer alternative. It uses a Groovy or Kotlin DSL instead of XML.
A minimal build.gradle.kts (Kotlin DSL — preferred for new projects):
plugins {
java
application
}
group = "com.example"
version = "1.0.0-SNAPSHOT"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.16.0")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
application {
mainClass.set("com.example.app.Application")
}
tasks.test {
useJUnitPlatform()
}
**Common commands:**
- ./gradlew build — compile, test, package
- ./gradlew test — run tests
- ./gradlew run — run the application (if application plugin)
- ./gradlew clean — delete build output
- ./gradlew tasks — list available tasks
The ./gradlew wrapper script lets each project ship its own Gradle version. No global install needed.
**Pros of Gradle:**
- Faster incremental builds — sophisticated up-to-date checking and build caching.
- Real programming language — Kotlin or Groovy. Less repetitive than XML, easier to customise.
- Android Studio's default build tool.
- Better for multi-module projects.
**Cons:**
- Steeper learning curve — when something breaks, debugging the build script is harder than debugging Maven XML.
- Less stable — Gradle versions have meaningful breaking changes between majors.
- The flexibility cuts both ways: every project's build is a unique program.
**Pick Maven if:** you want stability, your team values consistency over flexibility, or you're working on enterprise Java where Maven is the default.
**Pick Gradle if:** you want fast incremental builds, you're doing Android work, you have multi-module projects, or you have complex custom build logic.
Either choice is defensible. The "Maven vs Gradle" debate is mostly preference; neither is fundamentally wrong.
Dependencies and version conflicts
Both Maven and Gradle download dependencies from **Maven Central** (the main public repository), Google's repo (for Android), and any private repositories you configure.
Each dependency has three coordinates: groupId:artifactId:version. Together they uniquely identify a library on Maven Central:
com.fasterxml.jackson.core : jackson-databind : 2.16.0
**Transitive dependencies.** When you depend on library X, you also get whatever X depends on. Maven/Gradle resolve the full tree. This is convenient until two of your dependencies need different versions of the same transitive library — the dreaded "dependency conflict."
Your code
├── library-A 1.0 (which needs guava 30.0)
└── library-B 2.0 (which needs guava 32.0)
Both Maven and Gradle pick ONE version of guava (usually the highest, or the closest in the tree). Sometimes this is fine. Sometimes it breaks library-A because it relied on a removed method.
**How to see what's happening:**
- Maven: mvn dependency:tree
- Gradle: ./gradlew dependencies
You'll get a tree showing every dependency and where it came from.
**Fixing conflicts:**
- Pin a specific version in your direct dependencies (force the version you want).
- Exclude problematic transitive dependencies.
- Update libraries to versions that agree.
**Dependency scopes** matter:
- compile (Maven) / implementation (Gradle) — needed at runtime and compile time. Default.
- test (Maven) / testImplementation (Gradle) — only for tests.
- provided (Maven) / compileOnly (Gradle) — needed at compile time but provided by the runtime environment (e.g., Servlet API on Tomcat).
- runtime (Maven) / runtimeOnly (Gradle) — needed at runtime but not compile time (e.g., JDBC drivers).
Get scopes right. Including test libraries in your production artifact bloats it unnecessarily.
Producing deployable artifacts
A bare JAR has your classes but not your dependencies. To deploy, you need either a "fat JAR" (everything bundled) or a directory with classpath setup.
**Fat JAR with Spring Boot's plugin:**
plugins {
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
java
}
./gradlew bootJar produces a single JAR with all dependencies inside, runnable as java -jar app.jar.
**Fat JAR with Maven Shade plugin:**
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.Application</mainClass>
</transformer>
</transformers>
</configuration>
</plugin>
**Container images with jib or Spring Boot:**
Spring Boot 2.3+: ./gradlew bootBuildImage produces a Docker image without writing a Dockerfile. Uses Cloud Native Buildpacks.
Google's jib (com.google.cloud.tools.jib) plugin: also produces images directly, faster than docker build for many cases.
**Native images with GraalVM:** ./gradlew nativeCompile (with the native-image plugin) produces a single executable binary. Smaller, starts instantly, but with reflection limitations and longer build times. Useful for serverless deployments where cold-start matters.
For typical server applications: fat JAR + simple Dockerfile remains the most boring, reliable option. Don't reach for GraalVM native or fancier setups unless you have a specific reason.
Multi-module projects
Once a project grows past one component, you'll want to split it into modules. Multi-module projects let you have:
my-app/
├── pom.xml or build.gradle.kts (parent)
├── api/
│ └── pom.xml or build.gradle.kts
├── service/
│ └── pom.xml
└── web/
└── pom.xml
service depends on api. web depends on service. The parent coordinates the build.
**Maven multi-module:**
<modules>
<module>api</module>
<module>service</module>
<module>web</module>
</modules>
In a submodule's pom, reference siblings:
<dependency>
<groupId>com.example</groupId>
<artifactId>api</artifactId>
<version>${project.version}</version>
</dependency>
**Gradle multi-module** (settings.gradle.kts):
rootProject.name = "my-app"
include("api", "service", "web")
In a submodule:
dependencies {
implementation(project(":api"))
}
Gradle handles multi-module builds noticeably better than Maven — parallel builds, incremental rebuilds, build caching across modules. If you're starting a project that you expect will grow beyond a handful of modules, that's a vote for Gradle.
**When to split into modules:**
- One natural unit of code has clear boundaries with the rest.
- You want to enforce architectural rules — module A can't accidentally depend on module B.
- You want separate build/deploy lifecycles.
**When NOT to:**
- The project is small enough to read in one sitting.
- You're splitting "just because." Premature modularisation is a real cost — every new module adds build complexity, IDE friction, and barrier to refactoring.
⁂ Back to all modules