Packages, Modules and Build Tools
Organizing and building Java projects: packages and imports, the classpath and JAR files, the module system, and building with Maven and Gradle and their dependency management.
Packages
A
Java package is a named group of
related classes and interfaces, declared with a
package statement at the top of each source file; the
package name is part of every class's full name and mirrors the
folder in which the files are stored. Packages prevent name clashes
(two libraries can both have a class Tariff) and, with
the access modifiers, decide what code outside the package may use
(see Classes and Objects).
They are the Java counterpart of
Python packages
and C#
namespaces,
with one difference: in Java the folder structure must match.
Package names are written in lowercase and, by convention, start
with the organization's internet domain reversed, so they are unique
worldwide: com.kestrel.billing for Kestrel Energy's
billing code. A small project looks like this:
billing/
└── src/
└── com/
└── kestrel/
└── billing/
├── Main.java
└── Tariff.java
package com.kestrel.billing;
import java.math.BigDecimal;
import java.math.RoundingMode;
public record Tariff(String name, BigDecimal pricePerKwh) {
public BigDecimal priceFor(int kwh) {
return pricePerKwh.multiply(BigDecimal.valueOf(kwh)).setScale(2, RoundingMode.HALF_UP);
}
}
package com.kestrel.billing;
import java.math.BigDecimal;
public class Main {
public static void main(String[] args) {
Tariff day = new Tariff("Day", new BigDecimal("0.27"));
System.out.println("C-10042 owes EUR " + day.priceFor(412));
}
}
The full name of the class is now
com.kestrel.billing.Main. Main uses
Tariff without an import because both are in the same
package. Classes without a package line are in the
unnamed package, which is fine for experiments, like the
single-file examples on this site, but not for real code. Compact
source files (void main() without a class) always live
in the unnamed package and may not declare one.
Imports
An import lets you write a class's short name instead
of its full name. It does not load or include anything; it only
tells the compiler what the short name means. The package
java.lang (String, Math,
System, IO) is always imported. A
static import brings in static members, so you can write
max(…) instead of Math.max(…):
import java.math.BigDecimal;
import java.time.LocalDate;
import static java.lang.Math.max;
public class Main {
public static void main(String[] args) {
java.util.List<String> meters = java.util.List.of("E-001", "E-002"); // fully qualified, no import
BigDecimal price = new BigDecimal("0.27"); // imported
LocalDate due = LocalDate.of(2026, 11, 4); // imported
String team = "NOC"; // java.lang: always available
System.out.println(meters + " " + price + " " + due + " " + team);
System.out.println("highest reading: " + max(412, 388)); // static import
}
}
[E-001, E-002] 0.27 2026-11-04 NOC highest reading: 412
A wildcard import such as import java.util.*; imports
every class of one package (not of its subpackages: a package
java.util.concurrent is a separate package despite the
name). Two wildcards can make a name ambiguous. Both
java.util and java.awt contain a type
called List:
import java.util.*;
import java.awt.*;
public class Main {
public static void main(String[] args) {
List<String> teams = new ArrayList<>();
System.out.println(teams);
}
}
Main.java:6: error: reference to List is ambiguous
A single-type import always wins over a wildcard, so one extra line settles it:
import java.awt.*;
import java.util.*;
import java.util.List; // a single-type import wins over both wildcards
public class Main {
public static void main(String[] args) {
List<String> teams = new ArrayList<>(List.of("NOC", "Billing"));
System.out.println(teams);
}
}
[NOC, Billing]
Compiling and running: the classpath
javac -d out compiles the sources and writes the class
files into the folder out, in subfolders that match the
packages. To run the program you give the full class name and tell
the JVM where to look:
javac -d out src/com/kestrel/billing/*.java
find out -type f
java -cp out com.kestrel.billing.Main
out/com/kestrel/billing/Main.class out/com/kestrel/billing/Tariff.class C-10042 owes EUR 111.24
The
classpath is the list of folders
and JAR files in which the JVM and the compiler look for classes,
set with the option -cp (or -classpath, or
the environment variable CLASSPATH); entries are
separated by : on Linux and macOS and by
; on Windows. When a class is not on it, the error
depends on when the JVM notices:
java -cp wrong com.kestrel.billing.Main
Error: Could not find or load main class com.kestrel.billing.Main Caused by: java.lang.ClassNotFoundException: com.kestrel.billing.Main
Here the main class itself is missing. When the main class is found but a class it needs is gone, for example because a JAR is missing from the classpath after a deployment, the program starts and fails at the first line that uses it:
Exception in thread "main" java.lang.NoClassDefFoundError: com/kestrel/billing/Tariff at com.kestrel.billing.Main.main(Main.java:7) Caused by: java.lang.ClassNotFoundException: com.kestrel.billing.Tariff
ClassNotFoundException is also what code gets when it
looks a class up by name at run time, as frameworks and JDBC drivers
do:
public class Main {
public static void main(String[] args) throws Exception {
Class<?> found = Class.forName("java.time.LocalDate");
System.out.println("found " + found.getName());
Class.forName("com.kestrel.billing.TariffEngine");
}
}
found java.time.LocalDate Exception in thread "main" java.lang.ClassNotFoundException: com.kestrel.billing.TariffEngine
JAR files
A JAR file (Java Archive) is a ZIP
archive that holds compiled class files and resources, plus a
manifest file META-INF/MANIFEST.MF with metadata; it is
the usual way to ship a Java library or application. The
jar tool of the JDK creates one:
jar --create --file billing.jar --main-class com.kestrel.billing.Main -C out .
jar --list --file billing.jar
java -jar billing.jar
META-INF/ META-INF/MANIFEST.MF com/ com/kestrel/ com/kestrel/billing/ com/kestrel/billing/Main.class com/kestrel/billing/Tariff.class C-10042 owes EUR 111.24
The manifest that --main-class produced:
Manifest-Version: 1.0 Created-By: 25.0.4.1 (Eclipse Adoptium) Main-Class: com.kestrel.billing.Main
java -jar reads the Main-Class header to
know where to start; a JAR without it fails with
no main manifest attribute, in billing.jar. With
-jar, the -cp option is ignored; extra
JARs are listed in a Class-Path header in the manifest
instead. Because a JAR is a ZIP file, unzip -l works on
it too, which is handy on a server without a JDK.
Kinds of JAR files you meet in practice:
-
Library JARs, such as
jackson-databind-2.22.3.jar: classes for other programs to use, no main class. -
Executable "fat" JARs (also called uber JARs):
the application plus all its dependencies in one file. Spring Boot
builds such a JAR, which is why many services are started with
just
java -jar service.jar. - WAR files (web application archives): the same ZIP format with a different layout, deployed into an application server such as Apache Tomcat.
The module system
Packages organize classes, but before Java 9 nothing organized
packages: every public class on the classpath could be used by
everyone, and the JVM only found out at run time that a JAR was
missing. The
Java Platform Module System
(JPMS, Java 9) adds modules: named groups of packages with a
descriptor, module-info.java, that declares which other
modules the module requires and which of its packages
it exports; packages that are not exported cannot be
used from outside, even if their classes are public.
module com.kestrel.billing {
requires java.sql;
exports com.kestrel.billing;
}
This module uses JDBC from the JDK module java.sql and
offers its package com.kestrel.billing to other
modules. Its second package,
com.kestrel.billing.internal, stays private. Another
module that requires it and imports from the internal package does
not compile:
mods/com.kestrel.portal/com/kestrel/portal/App.java:4: error: package com.kestrel.billing.internal is not visible
import com.kestrel.billing.internal.RateCache;
^
(package com.kestrel.billing.internal is declared in module com.kestrel.billing, which does not export it)
1 error
Modules are found on the module path
(--module-path) instead of the classpath. The JVM
checks at start-up that every required module is present, so a
missing component fails immediately instead of halfway through a
request:
java --module-path mout --describe-module com.kestrel.billing
com.kestrel.billing file:///…/mout/com.kestrel.billing/ exports com.kestrel.billing requires java.base mandated requires java.sql contains com.kestrel.billing.internal
The modular JDK
Most application code still runs on the classpath, in the
unnamed module. The biggest effect of the module system is
on the JDK itself, which consists of about 70 modules (java --list-modules
lists 69 on Temurin 25). A program can ask which module a class
belongs to:
public class Main {
public static void main(String[] args) {
System.out.println("String: " + String.class.getModule().getName());
System.out.println("HttpClient: " + java.net.http.HttpClient.class.getModule().getName());
System.out.println("Connection: " + java.sql.Connection.class.getModule().getName());
System.out.println("Main named? " + Main.class.getModule().isNamed());
System.out.println("jdk.httpserver present? " + ModuleLayer.boot().findModule("jdk.httpserver").isPresent());
}
}
String: java.base HttpClient: java.net.http Connection: java.sql Main named? false jdk.httpserver present? true
Two consequences that operations people run into:
-
Strong encapsulation. Since JDK 17 (JEP 403),
code can no longer reach into the internals of the JDK with
reflection. Older libraries that did so fail with an
InaccessibleObjectException, and start scripts sometimes contain options such as--add-opens java.base/java.lang=ALL-UNNAMEDto open a package again. Each such option is a sign of a library that should be upgraded. -
Smaller runtimes.
jlinkbuilds a custom runtime with only the modules an application needs.jdeps --print-module-depstells you which those are. A runtime withjava.baseandjava.sql(which pulls injava.logging,java.transaction.xaandjava.xml) took 71 MB on our test machine, against 303 MB for the full JDK, which is useful for container images.
public class Main {
public static void main(String[] args) throws Exception {
var field = String.class.getDeclaredField("value");
try {
field.setAccessible(true);
System.out.println("opened");
} catch (RuntimeException e) {
System.out.println(e.getClass().getName());
System.out.println(e.getMessage().contains("does not \"opens java.lang\" to unnamed module"));
}
}
}
java.lang.reflect.InaccessibleObjectException true
The full message reads
Unable to make field private final byte[] java.lang.String.value
accessible: module java.base does not "opens java.lang" to unnamed
module @71a794e5; the part after @ differs on every run, which is why
the example only tests for the stable part.
Module import declarations
Java 25 added import module (JEP 511), which imports
all exported packages of a module at once:
import module java.base; makes List,
LocalDate, BigDecimal and the rest
available. Compact source files get exactly this import
automatically. As with wildcards, two modules can clash, and a
single-type import resolves it:
import module java.base;
import module java.desktop;
public class Main {
public static void main(String[] args) {
List<String> teams = new ArrayList<>();
System.out.println(teams);
}
}
Main.java:6: error: reference to List is ambiguous
import module java.base;
import module java.desktop;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> teams = new ArrayList<>(List.of("NOC", "Billing"));
System.out.println(teams + " " + LocalDate.of(2026, 10, 5));
}
}
[NOC, Billing] 2026-10-05
Why build tools
javac and jar are enough for one folder of
code. A real service has dozens of libraries, each with its own
dependencies, tests that must run before every release, and a build
that must give the same result on every developer's laptop and in
the CI/CD pipeline. A
build tool takes care of that: it downloads the right versions of
all libraries, compiles, runs the tests and packages the result, all
from one description of the project. Java has two dominant build
tools, Maven and Gradle. Both download libraries from the same
place: Maven Central, the public repository of
open-source Java libraries, where each library is identified by its
coordinates groupId:artifactId:version, for
example
com.fasterxml.jackson.core:jackson-databind:2.22.3.
Maven
Maven (Apache Maven) is a build tool
for Java that describes a project declaratively in an XML file,
pom.xml (Project Object Model): its coordinates, its
dependencies and its plugins. It builds every project the same way
through a fixed sequence of phases. As of October 2026 the
recommended version is Maven 3.10.0; Maven 4 is still a release
candidate (4.0.0-rc-7) and not meant for production yet.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.kestrel</groupId>
<artifactId>billing-service</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.3</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
</project>
maven.compiler.release compiles for Java 25, the
test scope keeps JUnit out of the shipped application,
and the Surefire plugin, which runs the tests, is pinned to a fixed
version (plugins should be pinned just like libraries; 3.6.0 is the
latest as of October 2026). Maven expects a standard folder layout,
so the POM does not have to list source folders:
billing-service/ ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/com/kestrel/billing/ application code │ │ └── resources/ configuration files, templates │ └── test/ │ └── java/com/kestrel/billing/ tests └── target/ build output (never commit it)
The build lifecycle
Maven builds in phases: validate, compile,
test, package, verify,
install, deploy. Asking for one phase runs
all phases before it, so mvn package compiles, runs the
tests, and only then builds the JAR:
mvn clean package # delete target/, then compile, test and build the JAR
mvn verify # also run integration tests and other checks
mvn install # copy the JAR into ~/.m2/repository for other local projects
./mvnw package # the same with the Maven Wrapper: the project's own Maven version
[INFO] --- surefire:3.6.0:test (default-test) @ billing-service --- [INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 [INFO] Building jar: /home/ravi/billing-service/target/billing-service-1.0.0.jar [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------
Downloaded libraries are cached in the local repository
~/.m2/repository, so the second build needs no network.
Companies usually put a repository manager in between (such as
Sonatype Nexus or JFrog Artifactory) that mirrors Maven Central and
hosts internal libraries. The Maven Wrapper (mvnw), a
small script committed with the project, downloads the Maven version
the project was written for, so builds do not depend on what is
installed on the build server.
| Scope | Needed to compile | Shipped with the application | Typical use |
|---|---|---|---|
compile (default) |
yes | yes | libraries the code uses |
provided |
yes | no, the server supplies it | the servlet API in a WAR |
runtime |
no | yes | a JDBC driver |
test |
only the tests | no | JUnit, Mockito |
Transitive dependencies
A
transitive dependency
is a library your project uses indirectly: you declare a dependency
on library A, A depends on B, so the build tool downloads B and puts
it on the classpath too, without B appearing in your own build file.
The pom above declares two libraries; the build uses many more.
mvn dependency:tree shows them all:
[INFO] com.kestrel:billing-service:jar:1.0.0 [INFO] +- com.fasterxml.jackson.core:jackson-databind:jar:2.22.3:compile [INFO] | +- com.fasterxml.jackson.core:jackson-annotations:jar:2.22:compile [INFO] | \- com.fasterxml.jackson.core:jackson-core:jar:2.22.3:compile [INFO] \- org.junit.jupiter:junit-jupiter:jar:6.1.3:test [INFO] +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test [INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test [INFO] | +- org.junit.platform:junit-platform-commons:jar:6.1.3:test [INFO] | +- org.apiguardian:apiguardian-api:jar:1.1.2:test [INFO] | \- org.jspecify:jspecify:jar:1.0.0:test [INFO] +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test [INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test [INFO] \- org.junit.platform:junit-platform-engine:jar:6.1.3:test
When two paths bring in different versions of the same library,
Maven picks the one nearest to your project in the tree
(and the first one declared when they are equally near); Gradle
picks the highest version. Either way the result can be a
version nobody chose on purpose. Declaring the version yourself, in
Maven in a <dependencyManagement> section or with
a BOM (bill of materials, a POM that only lists matching versions),
makes the choice explicit.
Gradle
Gradle is a build tool for the JVM
whose build scripts are code, in Kotlin
(build.gradle.kts) or Groovy
(build.gradle), organized as tasks; it uses the same
Maven repositories and coordinates, and is known for fast,
incremental builds. It is the standard build tool for Android apps
and common for back-end services. As of October 2026 the current
version is Gradle 9.8.0, which runs on Java 17 to 27.
plugins {
application
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.22.3")
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
application {
mainClass = "com.kestrel.billing.Main"
}
tasks.test {
useJUnitPlatform()
}
The ideas map one to one on Maven: implementation is
roughly the compile scope, testImplementation the test
scope, and platform(…) imports a BOM, so the JUnit
libraries need no version of their own. The
toolchain setting makes Gradle compile with a JDK 25 even
when Gradle itself runs on another version. Gradle projects are
built with the Gradle Wrapper, ./gradlew, committed
with the project:
./gradlew build # compile, test, package
./gradlew test # only the tests
./gradlew run # start the application
./gradlew dependencies --configuration runtimeClasspath # the dependency tree
| Maven | Gradle | |
|---|---|---|
| Build file | pom.xml (XML, declarative) |
build.gradle.kts or
build.gradle (code)
|
| Model | fixed lifecycle of phases | graph of tasks |
| Wrapper | ./mvnw |
./gradlew |
| Output folder | target/ |
build/ |
| Version conflicts | nearest wins | highest wins |
Neither is better for every project. Maven builds are predictable and look alike everywhere; Gradle is more flexible and usually faster on big projects. Use whatever the project already uses.
Keeping dependencies safe
Most code in a Java service is not written by its team: it comes in through dependencies, and most of those are transitive. That is where many serious vulnerabilities live. The best-known case is Log4Shell (CVE-2021-44228, published 10 December 2021, CVSS score 10.0, the maximum). In the popular logging library Apache Log4j 2 (versions 2.0-beta9 up to 2.14.1), a special lookup syntax inside a logged message made the library fetch and run code from a remote server. Any text an attacker could get into a log, such as a user name or a browser header, was enough. Fixed versions came within days (2.15.0, then 2.16.0 and backports 2.12.2 and 2.3.1).
pom.xml. Only the
full dependency tree shows it.
Many teams first had to find out whether they used Log4j at all, because it came in through other libraries and products. The lessons, in short:
- Know what you run. Keep an inventory per application: the dependency tree, or better a software bill of materials (SBOM), which build plugins can generate on every build.
- Scan continuously. Software composition analysis in the pipeline and tools such as Dependabot compare your dependencies with databases of known vulnerabilities.
- Patch libraries, not just the JDK. Updating Java does not update the JARs inside an application. A fix means a new build of the application.
- Pin versions. Fixed versions (no ranges), pinned plugins and wrappers make builds repeatable, so you know exactly what is in production. Gradle can also write lock files.
- Retire what is dead. Log4j 1.x has had no fixes since 2015, yet still turns up in old applications.
See Vulnerability Management for how such findings are prioritized and handled.