Build Tools & Modules

Creating & Using Modules

15 min Lesson 8 of 13

Creating & Using Modules

The previous lesson introduced the why behind the Java Platform Module System (JPMS). This lesson is hands-on: you will create modular JARs, write and read module-info.java declarations, and understand the critical difference between the module path and the legacy classpath. These mechanics are what you actually deal with daily in a JPMS-based project.

Anatomy of module-info.java

Every module is defined by a single file at the root of the source tree: module-info.java. It declares the module's name, what it exposes, and what it depends on. Here is a realistic example for a library module:

// src/com.example.payments/module-info.java module com.example.payments { // packages this module makes visible to other modules exports com.example.payments.api; exports com.example.payments.model; // internal package — NOT exported; invisible to everyone else // com.example.payments.internal stays private // compile-time and runtime dependency requires com.example.logging; // compile-time only (annotation processors, code generators) requires static com.example.annotations; // re-export a dependency so callers do not need to declare it requires transitive com.example.currency; // open for deep reflection (e.g. Jackson, Hibernate) opens com.example.payments.model to com.fasterxml.jackson.databind; }

Key directives and their meanings:

  • exports — makes a package's public types accessible to code in other modules. Packages that are not exported are completely hidden at compile time and runtime, even if their types are public.
  • requires — declares a compile-time and runtime dependency on another module.
  • requires static — optional at runtime; used for annotation processors or APIs that are only needed during compilation.
  • requires transitive — implies the dependency for any module that depends on yours. If your API returns types from com.example.currency, callers need those types too, so you re-export them.
  • opens … to — grants qualified deep reflection access (bypassing encapsulation) to a specific module. Use this for frameworks that rely on reflection at runtime.
Encapsulation is the main benefit. Before JPMS, any class anywhere on the classpath could access any other class by name — including Sun internal APIs. With modules, only what you explicitly export is visible. This is enforced by the JVM, not just a convention.

Project Layout for a Multi-Module Build

A typical multi-module project groups each module in its own directory, each containing its module-info.java:

my-project/ ├── com.example.payments/ │ ├── src/ │ │ └── com/example/payments/ │ │ ├── api/PaymentService.java │ │ ├── model/Payment.java │ │ └── internal/PaymentProcessor.java │ └── module-info.java ├── com.example.logging/ │ ├── src/ │ │ └── com/example/logging/ │ │ └── Logger.java │ └── module-info.java └── com.example.app/ ├── src/ │ └── com/example/app/ │ └── Main.java └── module-info.java

Each module compiles to its own modular JAR. The module name (in module-info.java) and the JAR filename are independent — but by convention they match, and Maven/Gradle automatically produce the correct artifacts.

Compiling and Packaging a Modular JAR by Hand

Understanding the manual process helps you debug build-tool problems. Assume you are compiling com.example.logging first (no dependencies), then com.example.payments which depends on it:

# Step 1 — compile the logging module javac -d out/com.example.logging \ com.example.logging/module-info.java \ com.example.logging/src/com/example/logging/Logger.java # Step 2 — package it as a modular JAR jar --create \ --file=mods/com.example.logging.jar \ --module-version=1.0 \ -C out/com.example.logging . # Step 3 — compile the payments module, declaring the module path javac --module-path mods \ -d out/com.example.payments \ com.example.payments/module-info.java \ $(find com.example.payments/src -name "*.java") # Step 4 — package it jar --create \ --file=mods/com.example.payments.jar \ --module-version=1.0 \ -C out/com.example.payments .
Always compile module-info.java together with the module's source files in a single javac invocation. Compiling it separately or in the wrong order causes confusing "module not found" errors at compile time.

The Module Path vs. the Classpath — the Critical Distinction

This is the most important concept to nail. Both the module path and the classpath are ways to tell the JVM where to find code, but they behave fundamentally differently:

  • Classpath (-classpath / -cp): a flat list of JARs and directories. All code on the classpath belongs to the unnamed module. There is no encapsulation: every public type in every JAR is accessible from everywhere else. This is how Java has worked since 1995.
  • Module path (--module-path / -p): a list of directories or JARs where the JVM looks for named modules. Each JAR that contains a module-info.class is treated as a named module with its own encapsulation rules. JARs without module-info.class become automatic modules (see below).
# Old classpath launch — no module encapsulation java -cp mods/com.example.logging.jar:mods/com.example.payments.jar \ com.example.app.Main # Module path launch — full JPMS encapsulation enforced java --module-path mods \ --module com.example.app/com.example.app.Main

The --module flag specifies the root module and its main class as moduleName/fullyQualifiedClassName. The JVM resolves the module graph from there, loading only what is required.

Mixing classpath and module path is a recipe for confusion. Code on the classpath is in the unnamed module, which can read all named modules but cannot be read by named modules (named modules cannot declare a dependency on the unnamed module). When migrating a large codebase, move code to the module path incrementally — but always understand which side a dependency is on.

Automatic Modules — Bridging Old JARs

Legacy JARs (no module-info.class) placed on the module path become automatic modules. The JVM derives their module name from the JAR filename (hyphens become dots, version suffixes are stripped — e.g. jackson-databind-2.17.0.jarjackson.databind). Automatic modules:

  • Export all their packages to all other modules.
  • Can read the unnamed module (i.e., code still on the classpath).
  • Can be declared in requires clauses of named modules.

Many libraries publish an explicit Automatic-Module-Name entry in their MANIFEST.MF to guarantee a stable module name before they add full module support. Always prefer that name over the derived one when it exists.

Running with Maven (Practical)

When using Maven, the compiler plugin automatically detects module-info.java and switches to module-aware compilation. You only need to ensure the Java release is 9 or higher:

<properties> <maven.compiler.release>17</maven.compiler.release> </properties>

For multi-module Maven projects, each module is a separate Maven sub-module. Maven passes the compiled siblings on the --module-path automatically during compilation. At runtime (e.g., via exec-maven-plugin), pass the main module explicitly:

<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>exec-maven-plugin</artifactId> <configuration> <executable>java</executable> <arguments> <argument>--module-path</argument> <argument>${project.build.directory}/dependency</argument> <argument>--module</argument> <argument>com.example.app/com.example.app.Main</argument> </arguments> </configuration> </plugin>

Qualified Exports and Targeted Opens

Sometimes you want a package visible only to specific trusted modules — for example, a testing helper that should only be accessible from your test module:

module com.example.payments { exports com.example.payments.api; // only the test module can see this package exports com.example.payments.testkit to com.example.payments.test; // allow the Jackson module to reflect into model classes opens com.example.payments.model to com.fasterxml.jackson.databind; }

This is qualified export / qualified open. It gives you fine-grained control: internal helpers remain invisible to third-party code while still being accessible where legitimately needed.

Service Loader Integration

JPMS has first-class support for the ServiceLoader pattern, cleanly replacing manual classpath scanning:

// Provider module declares the service it provides module com.example.payments.stripe { requires com.example.payments; provides com.example.payments.api.PaymentGateway with com.example.payments.stripe.StripeGateway; } // Consumer module declares what it uses module com.example.app { requires com.example.payments; uses com.example.payments.api.PaymentGateway; }

At runtime, ServiceLoader.load(PaymentGateway.class) discovers all providers in the module graph without the consumer knowing the implementation class name. This is how the JDK itself wires its own extensible services (e.g., java.sql.Driver).

Summary

A module is defined by module-info.java, which declares exports, requires, opens, and provides/uses directives. The module path enforces encapsulation; the classpath does not — understanding which mechanism is active for each JAR is the single most important debugging skill in a JPMS project. Legacy JARs become automatic modules when placed on the module path. Maven handles module-aware compilation transparently once a module-info.java is present. Qualified exports and opens let you expose implementation details only to trusted consumers.