Blog

Packages, Modules and JARs in Java Without Maven

Build a two-module Java project with nothing but javac, jar and java. See how packages, imports, the classpath, JAR files and module-info.java fit together, and what Maven and Gradle add on top.

Every program so far in this series fit in one file called Main.java. Real Java code is split into packages, compiled into a folder of .class files, packed into JAR files and started from a classpath or a module path. Build tools do all of that for you, which is why most developers have never seen it done by hand.

This post does it by hand. It covers packages and imports, the classpath and its two classic errors, JAR files, and the module system with module-info.java. Every program below was run on Java 25, and its output is pasted from the run. To run one of the single-file programs yourself, save it as Main.java and run java Main.java. The terminal sessions come from a small two-module project, checked by a script that uses only the JDK.

A package is a name and a folder

A package gives a class a longer, unique name, so List in java.util never clashes with a List class somebody else wrote. The full name, package plus class, is the fully qualified name, and you can always write it out in full. This program uses no imports at all. It’s a classic public class Main, because a compact source file imports java.base for you and would hide the point:

public class Main {
    public static void main(String[] args) {
        java.util.List<String> words = java.util.List.of("cat", "sat", "mat");
        IO.println(words.getFirst() + " is one of " + words.size() + " words");
        IO.println(java.util.List.class.getName());
        IO.println(java.util.List.class.getPackageName());
        IO.println("[" + Main.class.getPackageName() + "]");
    }
}

It prints:

cat is one of 3 words
java.util.List
java.util
[]

java.util.List is the class’s real name. List on its own is a shorthand. The last line shows that Main has an empty package name. It lives in the default package, also called the unnamed package, because the file has no package line.

In a project, the first line of a source file names its package, and the folders on disk follow the same dots. A class in package com.example.text; goes in com/example/text/. Package names usually start with a domain you control, written backwards, so two companies don’t pick the same one.

A package name looks like a tree, but Java doesn’t treat it as one. com.example.text and com.example.text.internal are two unrelated packages that happen to share a prefix. Importing one doesn’t import the other, and neither gets special access to the other’s code.

import and import static

An import lets you write a class’s short name instead of its fully qualified name. It doesn’t load anything or copy any code. The compiler just rewrites List to java.util.List for you. import static does the same for static members, such as methods and constants:

import java.util.ArrayList;
import java.util.List;

import static java.lang.Math.max;
import static java.util.Comparator.reverseOrder;

public class Main {
    public static void main(String[] args) {
        List<Integer> scores = new ArrayList<>(List.of(4, 9, 2));
        scores.sort(reverseOrder());
        IO.println(scores);
        IO.println(max(scores.getFirst(), 10));
    }
}

It prints:

[9, 4, 2]
10

Without the static imports, you’d write Math.max and Comparator.reverseOrder(). Use it sparingly, or readers hunt for where each bare name comes from.

Nothing in java.lang needs an import. That’s why String, Math and IO always work.

A wildcard import, import java.util.*;, brings in every class in a package. It gets you in trouble when two packages have a class with the same name:

import java.util.*;
import java.sql.*;

public class Main {
    public static void main(String[] args) {
        Date today = new Date(0);
        IO.println(today);
    }
}

The build fails with:

Main.java:6: error: reference to Date is ambiguous
        Date today = new Date(0);
        ^
  both class java.sql.Date in java.sql and class java.util.Date in java.util match

The fix is one single-class import, import java.util.Date;. A single-class import always wins over a wildcard. Or write the fully qualified name where you use it.

The default package, and why compact source files have none

The default package is fine for a one-file program and wrong for anything bigger. A class in a named package can’t import a class from the default package at all, because there’s no name to write after import.

A compact source file, the void main() form this series uses, always lives in the default package. The compiler wraps it in a class you never named, so there’s nothing for other code to refer to, and a package line is refused:

package com.example;

void main() {
    IO.println("hello");
}

The build fails with:

Main.java:1: error: compact source file should not have package declaration

So compact source files suit scripts and learning. Code that others use goes in a named package.

import module: every exported package at once

A module import, import module java.base;, imports every class in every package that the module exports. It became a final feature in Java 25, and javac --release 24 rejects it with module imports are not supported in -source 24.

import module java.base;

public class Main {
    public static void main(String[] args) {
        List<String> words = List.of("b", "a");
        IO.println(words);
    }
}

It prints:

[b, a]

That one line covers java.util, java.io, java.time and the rest of java.base. It’s exactly what a compact source file gets without asking. Name clashes work the same way as with wildcards: a single-class import settles them.

The project: a word counter in two modules

The rest of this post uses one small project: a library that counts words, and a command that prints the three most frequent ones. Here is the whole project, apart from its .gitignore:

14-wordcount/
├── run-checks.sh
├── sample.txt
├── expected-output.txt
├── checks/
│   └── Peek.java
└── src/
    ├── com.example.text/
    │   ├── module-info.java
    │   └── com/example/text/
    │       ├── WordCount.java
    │       ├── WordCounter.java
    │       └── internal/
    │           └── Tokenizer.java
    └── com.example.app/
        ├── module-info.java
        └── com/example/app/
            └── Main.java

The folders directly under src are named after modules, which come later in the post. Below each one, the folders follow the package names.

The library keeps its word splitting in a package called internal:

package com.example.text.internal;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

/** Splits text into lower-case words. Public, but its package is not exported. */
public final class Tokenizer {
    private Tokenizer() {
    }

    public static List<String> words(String text) {
        var words = new ArrayList<String>();
        for (String part : text.split("[^\\p{L}\\p{N}']+")) {
            String word = part.replaceAll("^'+|'+$", "");
            if (!word.isEmpty()) {
                words.add(word.toLowerCase(Locale.ROOT));
            }
        }
        return words;
    }
}

Tokenizer has to be public, because WordCounter lives in a different package and needs to call it. Before modules, that meant anyone could call it. Hold on to that thought.

The library’s real API is one record and one class:

package com.example.text;

/** One word and how many times it appeared. */
public record WordCount(String word, int count) {
}
package com.example.text;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import com.example.text.internal.Tokenizer;

/** Counts how often each word appears in a piece of text. */
public final class WordCounter {
    private WordCounter() {
    }

    /** Returns each word and its count, sorted by word. */
    public static Map<String, Integer> count(String text) {
        var counts = new TreeMap<String, Integer>();
        for (String word : Tokenizer.words(text)) {
            counts.merge(word, 1, Integer::sum);
        }
        return counts;
    }

    /** Returns the n most frequent words. Ties are broken alphabetically. */
    public static List<WordCount> top(Map<String, Integer> counts, int n) {
        return counts.entrySet().stream()
                .map(e -> new WordCount(e.getKey(), e.getValue()))
                .sorted(Comparator.comparingInt(WordCount::count).reversed()
                        .thenComparing(WordCount::word))
                .limit(n)
                .toList();
    }
}

And the command reads standard input, prints the top three words, and then says which module it’s running in:

package com.example.app;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

import com.example.text.WordCount;
import com.example.text.WordCounter;

public class Main {
    public static void main(String[] args) throws IOException {
        String text = new String(System.in.readAllBytes(), StandardCharsets.UTF_8);
        var counts = WordCounter.count(text);
        for (WordCount wc : WordCounter.top(counts, 3)) {
            IO.println(wc.word() + " " + wc.count());
        }

        Module module = Main.class.getModule();
        IO.println("module: " + (module.isNamed() ? module.getName() : "unnamed"));
    }
}

That last line will change depending on how you start the program, which is the whole point of it.

The classpath: where java looks for classes

The classpath is a list of folders and JAR files where javac and java look for compiled classes. You compile the library first, then compile the app with the library on its classpath, and then run the app with both on the classpath:

$ javac -d classes/lib src/com.example.text/com/example/text/*.java src/com.example.text/com/example/text/internal/*.java
$ javac -cp classes/lib -d classes/app src/com.example.app/com/example/app/Main.java
$ find classes -name "*.class" | sort
classes/app/com/example/app/Main.class
classes/lib/com/example/text/WordCount.class
classes/lib/com/example/text/WordCounter.class
classes/lib/com/example/text/internal/Tokenizer.class
$ java -cp classes/app:classes/lib com.example.app.Main < sample.txt
the 3
cat 2
and 1
module: unnamed

sample.txt holds the line the cat sat on the mat and the cat slept. -d sets the output folder, and javac builds the package folders inside it for you. -cp takes a list separated by : on Linux and macOS, and by ; on Windows. The java command names a class by its fully qualified name, not a file.

These javac commands skipped module-info.java, so there are no modules here. Everything on the classpath lands in one big unnamed module, and that’s what the last line says.

The classpath goes wrong in two ways, and they look alike. If java can’t find the class you asked it to start, you get this:

$ java -cp classes/lib com.example.app.Main < sample.txt
Error: Could not find or load main class com.example.app.Main
Caused by: java.lang.ClassNotFoundException: com.example.app.Main

If it finds Main but not a class Main needs, the program starts and then fails at the first line that uses the missing class. The stack trace is trimmed here:

$ java -cp classes/app com.example.app.Main < sample.txt
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/text/WordCounter
	at com.example.app.Main.main(Main.java:12)
Caused by: java.lang.ClassNotFoundException: com.example.text.WordCounter

ClassNotFoundException means a class was looked up by name and wasn’t there. NoClassDefFoundError means a class that existed when the code was compiled is missing now. Both come down to the same fix: put the missing folder or JAR on the classpath.

Notice when the second one failed. Nothing checked the classpath at startup. main ran, and line 12 was where the JVM first needed WordCounter.

JAR files: a zip with a manifest

A JAR file is a zip file of .class files with a small text file, the manifest, in META-INF/. The jar tool builds one, and --main-class writes the class to start into the manifest:

$ jar --create --file wordcount.jar --main-class com.example.app.Main -C classes/app . -C classes/lib .
$ jar --list --file wordcount.jar
META-INF/
META-INF/MANIFEST.MF
com/
com/example/
com/example/app/
com/example/app/Main.class
com/example/text/
com/example/text/WordCount.class
com/example/text/WordCounter.class
com/example/text/internal/
com/example/text/internal/Tokenizer.class
$ unzip -p wordcount.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: 25.0.4 (Ubuntu)
Main-Class: com.example.app.Main

$ java -jar wordcount.jar < sample.txt
the 3
cat 2
and 1
module: unnamed

-C classes/app . means “change into classes/app and add everything in it”. The paths inside the JAR are the package folders, the same layout as on disk. java -jar reads Main-Class from the manifest, so you don’t name the class. It also ignores any -cp you pass: the JAR, plus whatever its manifest’s Class-Path line lists, is the whole classpath.

This JAR holds both the app and the library, so it runs on its own. Real applications depend on dozens of library JARs, which is where build tools come in.

Modules: module-info.java

A module is a set of packages with a name and a descriptor, module-info.java, that says which modules it needs and which of its packages other modules may use. Modules arrived in Java 9. The library’s descriptor exports one package and says nothing about internal:

/** Counts words in text. */
module com.example.text {
    exports com.example.text;
}

The app exports nothing and requires the library:

/** A command that prints the most frequent words from standard input. */
module com.example.app {
    requires com.example.text;
}

requires is about modules and exports is about packages. Every module also reads java.base without asking. javac can compile both modules in one command, using the folder layout under src:

$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.text,com.example.app
$ find out -type f | sort
out/com.example.app/com/example/app/Main.class
out/com.example.app/module-info.class
out/com.example.text/com/example/text/WordCount.class
out/com.example.text/com/example/text/WordCounter.class
out/com.example.text/com/example/text/internal/Tokenizer.class
out/com.example.text/module-info.class
$ java --module-path out -m com.example.app/com.example.app.Main < sample.txt
the 3
cat 2
and 1
module: com.example.app

--module-source-path src tells javac that each folder under src is a module named after the folder. -m picks the modules to compile. javac worked out the order from the requires line.

--module-path out, or -p out, is the module path. java treats each folder under out as a named module. -m module/class says which module to start and which class holds main. The same Main.class now reports module: com.example.app.

module com.example.app com.example.app class Main exports nothing module com.example.text com.example.text WordCounter, WordCount exports com.example.text com.example.text.internal public class Tokenizer not exported requires can use not visible module java.base, read by every module

com.example.app requires com.example.text, so Main can use the exported package. The internal package is inside the same module, and its class is public, but it isn’t exported, so nothing outside the module can use it.

Strong encapsulation: public is no longer enough

A public class in a package that its module doesn’t export can’t be used from outside the module. That’s called strong encapsulation. checks/Peek.java tries anyway:

import com.example.text.internal.Tokenizer;

public class Peek {
    public static void main(String[] args) {
        IO.println(Tokenizer.words("reaching inside"));
    }
}

Compile it against the module path. --add-modules adds the library to the build, the way a requires line would:

$ javac -p out --add-modules com.example.text -d peek checks/Peek.java
checks/Peek.java:1: error: package com.example.text.internal is not visible
import com.example.text.internal.Tokenizer;
                       ^
  (package com.example.text.internal is declared in module com.example.text, which does not export it)
1 error

The error names both the rule and the reason. Tokenizer is public, and it doesn’t matter. Its module didn’t export the package.

Now the same file, compiled against the classpath build from earlier:

$ javac -cp classes/lib -d peek checks/Peek.java
$ java -cp peek:classes/lib Peek
[reaching, inside]

It compiled and ran. On the classpath there’s no module-info.class in play, so there’s nothing to enforce. Strong encapsulation only exists when the library is on the module path. That surprised us the first time we ran it, and it’s worth remembering whenever someone says a library’s internals are protected by modules.

Explain it like I’m ten

Packages are folders in a filing cabinet. Each folder has a label, like com.example.text, and papers with the same label go in the same folder. The label is how you ask for a paper: “the WordCounter sheet from the com.example.text folder”.

A module is a filing cabinet with a lock. Its drawers hold the folders. The owner puts a sticker saying “exported” on some drawers. Anyone from another cabinet can open those. The drawers without a sticker stay locked from the outside, even if the paper inside says “public” at the top. People who work at that cabinet can still open every drawer.

The classpath is what happens when you tip all the cabinets out onto one big table. Every paper is lying there, and anyone can pick up any of them.

The precise version

A module is a named set of packages, described by module-info.class. A requires line makes one module read another. Code in module A can use a type from module B only when three things are true: A reads B, B exports the type’s package to A, and the type itself is public. Anything less is a compile error, and the JVM enforces the same rule at run time. Reflection on private members needs the package opened as well.

Everything on the classpath goes into the unnamed module. The unnamed module reads every module the JVM or compiler has resolved, which is why Peek needed --add-modules, but it still sees only their exported packages. Classes from JARs on the classpath have no module boundaries between them, so the old rule applies: public means anyone.

Where the analogy breaks: a locked drawer sounds like security, and it isn’t. Anyone who starts the JVM can pass --add-exports or --add-opens on the command line to unlock a package, or move the JAR to the classpath, as Peek just did. Encapsulation protects a library’s authors from accidental dependencies on their internals. It doesn’t protect secrets from someone who controls the command line.

opens: letting reflection in

exports controls compile-time access and normal calls, while opens controls deep reflection, the kind that reads private fields. Frameworks that fill objects from JSON or inject dependencies need it. A line like opens com.example.text.model; lets reflection at run time reach every member of that package, private ones included, without exporting it for compilation.

The JDK’s own modules don’t open their internals, and you can see the refusal from a one-file program:

void main() {
    try {
        var field = String.class.getDeclaredField("value");
        field.setAccessible(true);
        IO.println("opened");
    } catch (NoSuchFieldException | InaccessibleObjectException e) {
        IO.println(e.getClass().getSimpleName());
        String message = e.getMessage();
        // The message ends with " @" and a hash code that changes on every run.
        IO.println(message.substring(0, message.lastIndexOf(" @")));
    }
}

It prints:

InaccessibleObjectException
Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module

getDeclaredField worked, so the field exists. setAccessible(true) is the step that failed. From Java 9 to 15, the same call succeeded with a warning, and plenty of older libraries relied on that. Java 16 made it fail by default, and Java 17 removed the switch that brought the old behaviour back.

Modular JARs and the module path

A modular JAR is a normal JAR with module-info.class at its root. You build one per module, and --main-class on the app’s JAR records the class to start inside its module descriptor:

$ mkdir mods
$ jar --create --file mods/com.example.text.jar -C out/com.example.text .
$ jar --create --file mods/com.example.app.jar --main-class com.example.app.Main -C out/com.example.app .
$ java -p mods -m com.example.app < sample.txt
the 3
cat 2
and 1
module: com.example.app

This time -m names only the module, because the JAR already knows its main class. jar --describe-module prints the descriptor. After a first line with the module’s name and the JAR’s full path, it printed this for each JAR:

$ jar --describe-module --file mods/com.example.text.jar
exports com.example.text
requires java.base mandated
contains com.example.text.internal
$ jar --describe-module --file mods/com.example.app.jar
requires com.example.text
requires java.base mandated
contains com.example.app
main-class com.example.app.Main

mandated marks the requires java.base you never wrote. contains lists packages that are in the JAR but not exported.

The module path also checks what the classpath didn’t. Delete the library’s JAR and run the app again:

$ rm mods/com.example.text.jar
$ java -p mods -m com.example.app < sample.txt
Error occurred during initialization of boot layer
java.lang.module.FindException: Module com.example.text not found, required by com.example.app

Not a line of main ran. The JVM read every requires before starting, found one it couldn’t satisfy, and stopped. Compare that with the NoClassDefFoundError from line 12 earlier.

One more surprise. With both JARs back in mods, java -jar on the modular app JAR fails:

$ java -jar mods/com.example.app.jar < sample.txt
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/text/WordCounter
	at com.example.app.Main.main(Main.java:12)
Caused by: java.lang.ClassNotFoundException: com.example.text.WordCounter

java -jar always puts the JAR on the classpath. The module-info.class inside is ignored, so the requires line means nothing, and the library isn’t on the classpath. To run a modular JAR as a module, use -p and -m.

java --list-modules, and when modules are worth it

The JDK itself is split into modules, and java --list-modules prints them with their versions. On this machine it listed 69, and they start like this:

$ java --list-modules | head -4
java.base@25.0.4
java.compiler@25.0.4
java.datatransfer@25.0.4
java.desktop@25.0.4

Add -p mods and your own modules appear at the end of the list, each with the file it came from.

Here’s the honest part. Most Java applications today still run on the classpath, and they run fine. Many popular frameworks and libraries were built before modules, and some rely on reflection in ways that make the module path awkward. If you write an application, you can ignore module-info.java and lose very little.

Modules earn their keep in two places:

  • Libraries. An unexported package lets you change your internals without breaking the people who use your library, as long as they’re on the module path. It’s the internal folder idea, enforced by the compiler.
  • Custom runtimes. jlink builds a trimmed JDK that holds only the modules your program needs. It needs to know those modules, so your code must be modular. The part on testing and shipping without a build tool uses it.

What Maven and Gradle add

Everything above used three JDK tools, and for two modules with no dependencies that was enough. The script that checks this project lists source folders and build steps by hand. Maven and Gradle replace that with a declared project. You list your dependencies by name and version, and the tool downloads them from a repository such as Maven Central, along with everything those dependencies need, and settles version conflicts between them. It then builds the classpath or module path for you, compiles in the right order, runs the tests and packages the JARs. The same declared versions give you the same build on every machine, which is the part a hand-written script gets wrong first. None of it is magic: underneath, the tools still call javac with a -cp or -p, and jar, much as this post did.

What to remember

  • A package is a namespace and a folder. import is only a shorthand for the fully qualified name, import static does the same for static members, and a single-class import beats a wildcard.
  • Compact source files live in the default package and can’t declare one. import module java.base; is final in Java 25 and is what those files get automatically.
  • The classpath is a list of folders and JARs. A missing start class gives ClassNotFoundException, and a class missing later gives NoClassDefFoundError only when that line runs.
  • A JAR is a zip with a manifest. --main-class sets Main-Class, and java -jar always runs it on the classpath, even when it’s a modular JAR.
  • module-info.java uses requires for modules, exports for compile-time access to packages, and opens for deep reflection. A public class in an unexported package is invisible outside its module.
  • Encapsulation and startup checks exist only on the module path. On the classpath, everything is one unnamed module.
  • Most applications are fine on the classpath. Modules matter most for libraries and for jlink.

Packages name your code, JARs ship it, and modules decide who outside may use it.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.