JAVA / PLATFORM, BUILDS AND TESTING
Maven project layout and dependency coordinates
Lay out a Maven project the way the build already expects, and read any groupId:artifactId:version coordinate as the exact file path it resolves to.
What you will learn
- Put production code in src/main/java and tests in src/test/java, packages mirrored
- Turn groupId:artifactId:version into its exact ~/.m2/repository file path
- Read the longer g:a:packaging:classifier:version form and name what each part picks
- Pick compile, provided, runtime or test scope by which classpath the jar belongs on
Understanding Maven project layout and dependency coordinates
Maven does not search for your code, it assumes where it is. src/main/java is the source root, src/test/java is the test source root, both mirror package names as nested directories, and the compiler writes to target/classes and target/test-classes respectively. Files under src/main/resources are copied into target/classes, which is why a file placed there is later loaded as /config.properties from the classpath root and not by the folder it came from. A pom that lists nothing but dependencies still builds because every one of those directories is a default the build carries already; rename one and you get no error, just zero compiled files.
A dependency is identified by coordinates, and the interesting part is that resolution is pure string arithmetic on them. groupId:artifactId:version becomes a path by replacing dots in the group with slashes and appending artifactId, version and the file name artifactId-version.packaging, so com.google.code.gson:gson:2.10.1 is exactly com/google/code/gson/gson/2.10.1/gson-2.10.1.jar under your local repository and under Maven Central. The longer forms add the type and a classifier: g:a:packaging:version chooses the extension, and g:a:packaging:classifier:version picks a sibling file such as the -sources or -javadoc jar built from the same project. Because nothing is searched for, a wrong coordinate is a missing file rather than a compile error, and the message you get names the path that did not exist.
Your own project has coordinates too. The groupId, artifactId and version at the top of your pom are what another build would write to depend on you, and they also decide the default artifact name in target, artifactId-version.jar. Each dependency then carries a scope that decides which classpaths it joins: compile joins all of them, test joins only test compilation and test execution, provided is on the compile classpath but assumed to exist at runtime, and runtime is absent at compile time and present when the code runs. The useful mental model is that the pom is a set of coordinates plus scopes, and the build turns that set into a handful of classpaths.
the pom is a set of coordinates plus scopes, and the build turns that set into classpaths
public class Coordinate {
static String repositoryPath(String coordinate) {
String[] p = coordinate.split(":");
String group, artifact, packaging, classifier, version;
switch (p.length) {
case 3:
group = p[0]; artifact = p[1]; packaging = "jar"; classifier = ""; version = p[2];
break;
case 4:
group = p[0]; artifact = p[1]; packaging = p[2]; classifier = ""; version = p[3];
break;
case 5:
group = p[0]; artifact = p[1]; packaging = p[2]; classifier = p[3]; version = p[4];
break;
default:
throw new IllegalArgumentException("not a Maven coordinate: " + coordinate);
}
String variant = classifier.isEmpty() ? "" : "-" + classifier;
return group.replace('.', '/') + "/" + artifact + "/" + version
+ "/" + artifact + "-" + version + variant + "." + packaging;
}
public static void main(String[] args) {
String[] coordinates = {
"com.google.code.gson:gson:2.10.1",
"org.junit.jupiter:junit-jupiter:jar:5.10.2",
"org.apache.commons:commons-lang3:jar:sources:3.14.0",
"org.springframework.boot:spring-boot-dependencies:pom:3.2.5"
};
for (String c : coordinates) {
System.out.println(c);
System.out.println(" ~/.m2/repository/" + repositoryPath(c));
}
}
}A Maven build is a fixed directory convention plus a set of coordinates, where each groupId:artifactId:version maps mechanically to one file path in a repository.
Worked examples
Seeing a scope from inside the JVM
Shows that a test-scoped library is simply not on the classpath of ordinary main code, run here outside a Maven test run.
public class ScopeCheck {
static String report(String className) {
try {
Class.forName(className);
return className + " -> present on this classpath";
} catch (ClassNotFoundException e) {
return className + " -> missing from this classpath";
}
}
public static void main(String[] args) {
System.out.println(report("java.util.ArrayList"));
System.out.println(report("org.junit.jupiter.api.Test"));
}
}Example explained
Line 1Class.forName asks the running classpath a yes-or-no question, so it reports what a scope actually produced rather than what a library exists somewhere.
Line 2java.util.ArrayList lives in java.base and is always reachable, so the first lookup succeeds no matter how the pom is written.
Line 3org.junit.jupiter.api.Test appears only on the test compile and test runtime classpaths that <scope>test</scope> feeds, which is why plain main code reports it missing.
Line 4Moving a class that imports JUnit from src/test/java into src/main/java therefore breaks compilation even though mvn test passed a moment earlier.
Coordinates turned into a pom fragment
Builds the dependency element for three libraries and shows which parts are mandatory and which are defaults you can leave out.
public class DependencyBlock {
static String block(String groupId, String artifactId, String version, String scope) {
StringBuilder xml = new StringBuilder();
xml.append("<dependency>\n");
xml.append(" <groupId>").append(groupId).append("</groupId>\n");
xml.append(" <artifactId>").append(artifactId).append("</artifactId>\n");
xml.append(" <version>").append(version).append("</version>\n");
if (!scope.equals("compile")) {
xml.append(" <scope>").append(scope).append("</scope>\n");
}
xml.append("</dependency>\n");
return xml.toString();
}
public static void main(String[] args) {
System.out.print(block("com.google.code.gson", "gson", "2.10.1", "compile"));
System.out.print(block("org.postgresql", "postgresql", "42.7.3", "runtime"));
System.out.print(block("org.junit.jupiter", "junit-jupiter", "5.10.2", "test"));
}
}Example explained
Line 1All three coordinate parts are emitted unconditionally because Maven refuses the build when a version is neither written here nor supplied by a parent's dependencyManagement.
Line 2The scope element is skipped for compile since compile is the default, so writing it out changes nothing about the resulting classpaths.
Line 3runtime on the PostgreSQL driver keeps it off the compile classpath, so no import of org.postgresql can slip into your code, while the jar is still there when the application runs.
Line 4test on junit-jupiter keeps it out of the packaged artifact, so projects that later depend on yours never inherit it.
Important notes
A version ending in -SNAPSHOT is mutable: the repository folder keeps timestamped files and Maven re-checks the remote for newer ones, so building against 1.0-SNAPSHOT twice does not guarantee the same bytes, while release versions are cached as if immutable.
The source directories can be reconfigured, but IDEs, plugins and CI all assume the defaults, and groupId is conventionally a reversed domain you control, which need not match your Java package names.
Common mistakes
Inferring the coordinate from the import: com.google.gson.Gson comes from com.google.code.gson:gson, so writing com.google.gson:gson gives a resolution failure naming a path that does not exist, not a compile error you can fix in the code.
Copying a snippet that has only groupId and artifactId: the build stops with a message about dependency.version missing, because a version is required unless a parent pom or dependencyManagement section supplies it.
Reading a resource with new File("src/main/resources/app.properties"): it works only while the working directory happens to be the project root and fails once the code runs from the packaged jar, where that copy is a classpath entry rather than a file.
Try it yourself
Change, predict, then run
Write a single class with a method that accepts a coordinate in any of the three colon forms and prints its repository path, then call it with org.slf4j:slf4j-api:2.0.13 and org.mockito:mockito-core:jar:javadoc:5.11.0 and make it throw when the version part is empty.
Open the Java workspaceCheck your understanding
You declare org.example:tools:3.4 with <scope>test</scope>, then add an import of one of its classes to a file in src/main/java. What happens?
- Compilation of src/main/java fails, because test scope only feeds the test classpaths
- It compiles and runs, because scope only controls what goes into the packaged jar
- It compiles but throws NoClassDefFoundError at runtime, because test scope is dropped after compiling
- Maven promotes the dependency to compile scope automatically, since main code needs it
Show answer
Test scope contributes the jar to test compilation and test execution only, so javac never sees it while compiling src/main/java and reports that the package does not exist. The runtime-failure option is tempting because it describes provided scope, which is on the compile classpath but not the runtime one; test scope fails earlier, and Maven never rewrites a scope you declared.