1. Introduction to Java Modules

Java 9 introduced the Java Platform Module System (JPMS). It added a structural layer above packages so that a Java application or library can describe its dependencies and define which packages are available to other modules.

Before modules, Java programs commonly used a classpath: a list of directories and JAR files from which the compiler and runtime could find classes. A class declared public could generally be accessed by other code when it was available on the classpath. This made Java straightforward to start with, but large applications could become difficult to maintain because dependencies and internal implementation details were not strongly separated.

JPMS gives code a more explicit structure. A module can declare:

  • Its name, which identifies the module.
  • Its dependencies, using the requires directive.
  • Its public package boundaries, using exports.
  • Its reflective-access boundaries, where needed, using opens.
  • Services it uses or provides, using service-related directives such as uses and provides.

The compiler and runtime can use these declarations to validate the module relationships. Modules do not replace classes, packages, or JAR files. Instead, they add a layer for organizing and checking how code depends on other code.

2. Why Java Needed a Module System

The traditional classpath model is convenient, but it creates several challenges as applications grow.

2.1 Classpath conflicts and dependency problems

The classpath is an ordered list of locations. If different JAR files contain classes with the same fully qualified name, class loading can become confusing because the selected class may depend on classpath order and class-loader behavior. Libraries can also require incompatible versions of another dependency.

A missing dependency may not be discovered until the program reaches the code that needs it. The result can be a runtime failure far away from the configuration mistake that caused it.

JPMS makes module dependencies explicit. When an application is launched in module mode, the runtime resolves a graph of required modules. Missing required modules and certain module conflicts can be reported during resolution rather than appearing later as an unexpected missing-class error.

2.2 Weak encapsulation

In traditional Java, public makes a type or member accessible according to Java's access rules, but it does not by itself define a library-wide boundary between supported API packages and internal packages. A library might contain packages named api, impl, or internal, yet naming conventions alone do not prevent consumers from depending on public types in those packages when they are available.

A named module can export selected packages and leave other packages unexported. A public class in a package that is not exported is not ordinarily accessible to other named modules. This gives library authors a way to distinguish supported API from implementation details.

2.3 A large Java runtime

Before Java 9, the JDK was distributed and organized differently from the modular platform introduced in Java 9. The module system divided the Java platform into named modules, making dependencies between platform features more explicit. Tools such as jlink can assemble a custom runtime image containing the modules required by an application.

This can be useful for deployments where a smaller runtime image is desirable. It does not mean every Java application automatically becomes smaller or faster merely because modules exist; the result depends on the application's dependencies and deployment approach.

2.4 What JPMS contributes

<box></box>

ChallengeHow JPMS helps
Dependencies are implicit or hard to trackrequires declares module dependencies
Internal packages are exposed by convention onlyexports controls which packages a named module exposes
Missing required modules can surface lateModule resolution checks required modules at launch
Runtime contains platform features an application does not needModular platform and jlink support custom runtime images
Libraries need controlled reflective accessopens can grant reflective access to specified packages

JPMS is most valuable when explicit dependency boundaries and encapsulation are useful. It is not mandatory for every Java project, and the traditional classpath remains supported.

3. Understanding Packages, JARs, and Modules

The terms package, JAR, and module describe different things.

ConceptMeaningExample
PackageA namespace used to group related typescom.example.checkout
JARAn archive that can bundle compiled classes and resourcescheckout-1.2.jar
ModuleA named unit with a descriptor that declares dependencies and package boundariescom.example.checkout

A package groups classes and interfaces. A JAR packages files together for distribution. A module describes a unit of code and its relationships with other modules. A module can be distributed in a modular JAR, but a JAR without an explicit descriptor can also be treated as an automatic module when placed on the module path.

One module may contain several packages. For example, com.example.checkout might contain:

  • com.example.checkout — public checkout operations.
  • com.example.checkout.discount — discount-related types.
  • com.example.checkout.internal — implementation details.

The module descriptor decides which packages are exported. The module name often resembles the package-name prefix, but a module name and a package name are not the same thing.

A module boundary at a glance

Whiteboard
Loading diagram...


The important point is that declaring a class public is not the whole story for a class in a named module. The package must also be accessible through the module's exports and the consuming module's dependency relationship.

4. The module-info.java File

A module's metadata is written in a special source file named module-info.java. It contains the module declaration and directives. In a conventional module source tree, the descriptor sits at the root of the module's source directory, above the package directories.

A simplified project layout could look like this:

When the compiler is given a module source path, the directory com.example.checkout identifies the module's source root in this layout.

A basic descriptor could be written as:


This declares a module named com.example.checkout and exports the com.example.checkout package. It does not export every package in the module automatically. A package not listed in an exports directive remains unexported to other named modules.

4.1 The requires directive

requires declares that a module depends on another module. For example:

This indicates that the module depends on java.sql and exposes its own com.example.report package. The dependency declaration does not mean every package in java.sql is accessible without restriction; the target module's exports still matter.

The java.base module is special. Every ordinary named module implicitly depends on it, so a descriptor does not need to write requires java.base;. It can be written explicitly, but it is normally omitted.

4.2 The exports directive

exports makes a package available to other modules that can read the exporting module. It does not make every package in the module public. Exports can also be qualified to name specific recipient modules, allowing a package to be exported only to selected consumers.

4.3 The opens directive

opens is related to reflective access rather than ordinary source-level access. It allows deep reflection into a package for the specified recipient modules, or more broadly if declared without a recipient list. This can be important for frameworks that inspect private fields or constructors.

An exported package is not automatically the same as an opened package. exports is about access to public API; opens is about enabling reflective access that would otherwise be restricted by module boundaries.

4.4 Service directives

A module can declare that it uses a service with uses and that it provides an implementation with provides ... with .... These directives support Java's service-provider mechanism and allow modules to communicate through service interfaces rather than directly depending on every implementation.

For introductory module declarations, requires and exports are the most common starting points. The other directives become useful as an application adopts reflection control or service-based designs.

5. Classpath Versus Module Path

Java 9 did not remove the classpath. It introduced the module path as a separate way to locate modules. Both can appear in Java development, but they have different rules.

5.1 The classpath

The classpath is specified using -cp or --class-path. It points to directories and JAR files that contain classes. Classpath code is not organized into explicit named modules; it belongs to an unnamed module associated with its class loader.

Classpath-based applications continue to work on modern Java versions. A project does not need a module-info.java file simply to use Java 9 or later.

5.2 The module path

The module path is specified using -p or --module-path. It contains modules that the compiler or runtime can resolve by name. Named modules declare their dependencies and exported packages, and the runtime checks the module graph.

For named-module code, a public type in another module is not automatically available merely because the module's JAR is present. The consuming module generally needs to read the module through a dependency, and the package containing the type must be exported.

AspectClasspath (-cp)Module path (-p)
OrganizationDirectories and JARs on a classpathModules resolved by module name
DescriptorNot requiredNamed modules have module-info.class
DependenciesCommonly supplied through build configuration and classpathDeclared using module directives for named modules
Package boundariesNo JPMS export boundary for ordinary classpath codeNamed modules can export selected packages
Missing dependenciesMay appear when affected code is loaded or usedRequired-module resolution can fail at launch
Typical roleExisting and non-modular applicationsApplications and libraries using JPMS

Visual comparison

Whiteboard
Loading diagram...


The two mechanisms can coexist, but mixing modular and non-modular code requires understanding which rules apply to each part. Code on the classpath is treated differently from code in a named module.

6. The Three Kinds of Modules

Java module discussions commonly distinguish three kinds: named modules, automatic modules, and the unnamed module. They differ in whether they have an explicit descriptor and how they participate in module resolution.

6.1 Named modules

A named, explicit module has a module descriptor, usually written as module-info.java and compiled to module-info.class. The descriptor identifies the module and declares dependencies, exports, and any other module directives.

The standard Java platform modules, such as java.base, java.sql, and java.xml, are named modules. Application developers can also create named modules by adding a descriptor to their source tree.

Named modules provide the most explicit form of JPMS boundaries. Their dependencies and exported packages are declared instead of being inferred solely from the presence of JAR files.

6.2 Automatic modules

An automatic module is a regular JAR placed on the module path without an explicit module descriptor. The runtime assigns it a module name so that named modules can depend on it.

The name is taken from the Automatic-Module-Name attribute in the JAR manifest when that attribute is present. Otherwise, the runtime derives a name from the JAR filename using the automatic-module naming rules. A filename such as gson-2.10.1.jar commonly yields the module name gson, though filename-derived names should not be treated as a substitute for a deliberately chosen stable name.

Automatic modules are a migration bridge for libraries that have not adopted explicit module descriptors. They are permissive compared with explicit modules: they generally expose all their packages and read other resolved modules. This convenience comes with less precise encapsulation than an explicit descriptor provides.

6.3 The unnamed module

Code placed on the classpath belongs to an unnamed module for its class loader. It has no declared module name and no module-info.java descriptor.

The unnamed module can read the named modules available to it, which helps older classpath applications continue to use the modular JDK. However, a named module cannot declare a normal requires dependency on the unnamed module because that module has no name to reference.

This asymmetry is important during migration. A codebase can mix classpath and module-path elements, but simply moving one library to a named module does not mean every part of the application can declare a dependency on all remaining classpath code.

Comparison of module kinds

PropertyNamed moduleAutomatic moduleUnnamed module
Explicit module-info.javaYesNoNo
Where it is commonly foundModule pathJAR on module pathClasspath code
Module nameDeclared explicitlyManifest attribute or derived from JAR nameNo module name
Package exportsExplicitly declaredGenerally all packages are exportedJPMS exports are not declared
Dependency declarationsExplicitNot explicitly declared in a descriptorNo explicit module descriptor
Typical purposeStrongly modular application or libraryTransition for older JAR librariesCompatibility for classpath applications

Whiteboard
Loading diagram...


7. The Java Platform Modules

The Java platform itself is modular. A JDK installation contains many named modules, including modules for core language functionality, database connectivity, XML processing, desktop APIs, management tools, and development utilities.

Examples include:

  • java.base — foundational classes such as java.lang and core utilities.
  • java.sql — APIs related to JDBC and SQL database access.
  • java.xml — XML-related APIs.
  • java.desktop — desktop-related APIs, including AWT and Swing.
  • jdk.compiler — compiler implementation and related development functionality.

These examples illustrate the range of platform modules; the exact set available depends on the JDK distribution and version.

7.1 Listing modules

The Java launcher provides a command to list observable modules:

The output lists module names and associated version information where applicable. It is useful when investigating which platform modules are available in a particular Java installation.

7.2 Describing one module

To inspect a module's descriptor, use:


The output summarizes the module's exports, dependencies, and other descriptor information. This is a practical way to discover the module that contains an API and understand how that module is exposed.

7.3 Why java.base matters

java.base is the foundation of the Java platform module graph. It contains essential types used by nearly every Java program. Every ordinary named module depends on it implicitly, so it normally does not need to appear in a requires clause.

8. The Module Graph and Resolution

When a Java application starts in module mode, the runtime resolves a module graph. The graph describes the modules involved and their dependency relationships.

The application module containing the entry point is commonly the root module. The runtime follows its declared dependencies and the dependencies of those modules to determine the modules required by the application. Each named module also has its implicit dependency on java.base.

Suppose an application module depends on checkout and catalog modules, and those modules use java.sql. The graph includes those dependencies and the core module. The exact graph depends on the descriptors and the modules available to the runtime.

Whiteboard
Loading diagram...


8.1 What happens when a dependency is missing?

If a named module declares a required module that cannot be resolved, the application generally fails during module resolution or startup. This makes the dependency problem more visible than a failure that appears only after a rarely used code path executes.

8.2 Duplicate module names and split packages

Module resolution also checks for certain structural conflicts. Two observable modules with the same module name cannot both be resolved as distinct modules in the same configuration. JPMS also restricts split packages in a resolved set of named modules: the same package cannot simply be spread across multiple named modules in the way some older classpath arrangements allowed.

These checks encourage each module to have a clearer and more consistent ownership of its packages.

9. Strong Encapsulation: What public Means in a Module

One of the most important benefits of JPMS is strong encapsulation. Within a module, Java's familiar access modifiers still apply. Across module boundaries, however, package exports add another requirement.

Imagine a module containing:

  • com.example.checkout.Cart
  • com.example.checkout.internal.PriceTable

If the descriptor exports only com.example.checkout, consumers that read the module can use the public API in the exported package. They cannot ordinarily import com.example.checkout.internal.PriceTable from another named module merely because the class is declared public.

This lets a library maintain implementation classes without making them part of its supported external API. A library can refactor an internal package with less risk of breaking consumers who should never have depended on it.

9.1 Exports are package-level

An exports directive names a package, not a single class. Exporting com.example.checkout does not automatically export com.example.checkout.internal or every subpackage. Each package that should be part of the external API needs to be considered separately.

9.2 Qualified exports

A module can export a package only to specified modules. This is useful when a package should be accessible to a small set of trusted modules but should not form part of the general public API.

9.3 Reflection and opens

Strong encapsulation also affects reflection. Frameworks that use reflection to inspect or access non-public members may need packages to be opened to them. Without appropriate access, reflective operations such as attempting to suppress access checks can fail with InaccessibleObjectException.

Use opens deliberately. Opening a package for reflection is a different decision from exporting its public API, and overly broad reflective access weakens encapsulation.

DirectiveMain purposeTypical effect
requiresDeclare a dependencyThe module reads another named module
exportsExpose a package's public APIOther reading modules can access public types in that package
opensAllow deep reflection into a packageReflection can access members otherwise blocked by module boundaries
usesDeclare use of a serviceThe module can locate service implementations
provides ... with ...Declare a service implementationThe module offers an implementation of a service

10. Creating a Minimal Modular Java Application

A small command-line application is a useful way to see how a module descriptor participates in compilation and execution. The following example is deliberately minimal; it demonstrates the structure without introducing a large set of classes.

10.1 Source layout

10.2 Module descriptor

The file src/com.example.hello/module-info.java contains:

The descriptor gives the module a name. The module does not export any packages because the example does not need another module to call its types. It also does not explicitly require java.base, because that dependency is implicit.

10.3 Main class

The file src/com.example.hello/com/example/hello/Main.java contains:

The class uses the standard Java entry-point signature. The call to Main.class.getModule().getName() displays the name of the module in which the class is running.

10.4 Compile the module

From the project directory, compile the source files into an output directory:

Here, --module-source-path src tells the compiler that the source tree is organized by module. The -d out option chooses the destination for compiled output. The shell expression $(find src -name "*.java") collects the Java source files under src; this syntax is suitable for common Unix-like shells. On systems where that shell syntax is unavailable, pass the source filenames using the appropriate command prompt or build tool.

After compilation, the output is organized under the module name in the out directory.

10.5 Run the module

Use the module path and specify the module and main class:

The short forms are -p for --module-path and -m for --module. The value com.example.hello/com.example.hello.Main identifies the module first and then the main class.

The output is similar to:


The important difference from a simple javac Main.java and java Main workflow is that the compiler and runtime are now explicitly working with a named module.

11. Reading a Module Name at Runtime

Java's reflection API provides module information through Class.getModule(). For a class in a named module, the module object reports the module's name. For a class loaded from the classpath, the class belongs to the unnamed module and the module name is null.

This can help when diagnosing whether a class was loaded as part of a named module or through the classpath. It is a diagnostic technique, not a replacement for understanding the launch configuration.

12. How Modular JARs Are Used

A modular JAR contains compiled classes and resources along with a compiled module descriptor, usually module-info.class. The descriptor is the compiled form of module-info.java.

When such a JAR is placed on the module path, the runtime can identify the module by its declared name and use the descriptor to resolve dependencies and exports. A plain JAR without an explicit descriptor may instead be treated as an automatic module when placed on the module path.

A JAR is a packaging format; being a JAR does not by itself mean that it is an explicit named module. The descriptor and the way the JAR is used determine how it participates in JPMS.

13. Custom Runtime Images and jlink

One of the practical capabilities enabled by the modular Java platform is the ability to build a custom runtime image. The jlink tool combines the necessary platform modules and their dependencies into a runtime image suitable for an application.

This can help application teams create a more controlled deployment bundle instead of requiring the full JDK to be present on the target system. The result depends on the module graph and the modules selected for the image. If an application uses reflection or dynamically discovers functionality, the required modules must still be accounted for correctly.

A custom runtime image is not the same thing as compiling a Java application into a native executable. jlink assembles a Java runtime image; it does not, by itself, convert Java bytecode into a native machine-code application.

14. Benefits and Trade-Offs of JPMS

JPMS provides meaningful structure, but adopting it also introduces configuration and migration work.

Benefits

BenefitExplanation
Explicit dependenciesrequires makes module relationships visible in the descriptor
Stronger API boundariesexports helps separate public packages from internal implementation packages
Earlier structural checksMissing required modules and certain conflicts can be detected during resolution
Modular platformJava platform functionality is organized into named modules
Custom runtime imagesjlink can assemble runtime images from required modules
Clearer library contractsConsumers can see which packages a module intentionally exposes

Trade-offs

Trade-offWhy it matters
Migration effortExisting applications may depend on many libraries that have different levels of module support
Reflection configurationFrameworks may require explicit opens directives or launch-time options
Split-package restrictionsLegacy libraries that distribute one package across multiple JARs may need restructuring
Mixed classpath/module-path behaviorThe two mechanisms have different visibility and dependency rules
Build and IDE configurationBuild scripts, plugins, and tooling must correctly handle module source trees and descriptors

The module system is not automatically the best choice for every application. For a small program or an older application with a large dependency tree, staying on the classpath may be the simplest practical option. Modules are particularly useful when library authors need enforceable API boundaries, when teams want explicit dependency contracts, or when a deployment benefits from a custom runtime image.

15. Common Misunderstandings About Java Modules

15.1 A module is not the same as a package

A package groups types under a namespace. A module can contain multiple packages and defines relationships across package boundaries.

15.2 A JAR is not always an explicit module

A JAR is an archive. A JAR with an explicit module descriptor is an explicit named module; a JAR without one can be treated as an automatic module when placed on the module path.

15.3 public does not override module exports

A public type in a non-exported package is not ordinarily accessible to other named modules. Java access modifiers and module exports work together.

15.4 The classpath was not removed

Modern Java continues to support classpath-based applications. JPMS adds a module path and module descriptors rather than forcing every existing project to migrate.

15.5 exports and opens are not interchangeable

exports exposes a package's public API to other modules. opens allows deep reflection into a package. A package may need one, the other, both, or neither depending on its role.

15.6 A module descriptor does not bundle every dependency

The descriptor declares relationships. Dependencies still need to be available through the appropriate runtime or build configuration, and the module graph must resolve successfully.

16. Best Practices for Using JPMS

  1. Choose stable, distinctive module names. Reverse-domain naming conventions help reduce collisions between unrelated libraries.
  2. Export only intended API packages. Avoid exporting implementation packages simply because they contain public classes.
  3. Declare dependencies explicitly. Use requires to document which named modules your module needs.
  4. Keep reflection access narrow. Use opens only where frameworks or application requirements genuinely need deep reflection.
  5. Be deliberate when mixing classpath and module path. Understand which code is in named, automatic, and unnamed modules.
  6. Inspect module descriptors when troubleshooting. java --list-modules and java --describe-module can help explain what the runtime sees.
  7. Use build tools consistently. Maven, Gradle, and IDEs can manage compilation and module-path settings, but their configuration still needs to match the intended module structure.
  8. Adopt modules for a concrete reason. Strong encapsulation, explicit dependencies, or custom runtime images are useful motivations; modularization should solve a real maintenance or deployment problem.

17. A Practical Decision Flow

The decision to adopt JPMS should be based on the needs of the codebase, not just the Java version.

Whiteboard
Loading diagram...


This is a planning guide rather than a universal rule. The right approach depends on dependency compatibility, deployment requirements, and the value of stronger boundaries.

18. Quick Revision

  • JPMS stands for Java Platform Module System and was introduced in Java 9.
  • A package groups related types; a JAR packages files; a module declares dependencies and package boundaries.
  • module-info.java is the source descriptor for an explicit named module.
  • requires declares a module dependency.
  • exports makes a package's public API available to other modules that can read the module.
  • opens permits reflective access to a package.
  • uses and provides support service-based designs.
  • java.base is implicitly required by ordinary named modules.
  • The classpath uses -cp or --class-path; the module path uses -p or --module-path.
  • A named module has an explicit descriptor.
  • An automatic module is commonly a regular JAR on the module path without an explicit descriptor.
  • The unnamed module contains classpath code and has no module name.
  • Module resolution builds a graph from the application's root module and its dependencies.
  • JPMS helps enforce API boundaries and supports custom runtime images through jlink.
  • The classpath remains supported; not every project needs to become modular.

19. Conclusion

Java Modules (JPMS) provide a way to describe a Java application's structure beyond the package level. A module identifies itself, declares the modules it depends on, and controls which of its packages are exposed to other modules. The compiler and runtime can then validate module relationships and enforce stronger boundaries than package naming conventions alone.

Understanding the difference between packages, JARs, and modules is the foundation. From there, module-info.java, requires, exports, and opens explain most of the everyday module behavior. Named modules provide explicit contracts, automatic modules help bridge older JARs into the module path, and the unnamed module keeps traditional classpath applications working.

JPMS does not make the classpath obsolete, and adopting modules can require effort—especially when frameworks depend on reflection or libraries use legacy packaging patterns. The best approach is to use modules when explicit dependencies, stronger encapsulation, or modular runtime packaging provide a real benefit, and to introduce them with a clear understanding of the application's dependency graph.