The Java Platform Module System (JPMS), delivered in JDK 9 by Project Jigsaw, gives a group of packages a name, a list of the modules it depends on and a list of the packages it exposes. The compiler, the launcher and the JVM then enforce those declarations. That sounds like bookkeeping, but it removes three failures every large Java codebase eventually meets: a missing dependency that only shows up as NoClassDefFoundError in production, two jars shipping the same package where whichever comes first on the class path wins, and internal classes marked public for convenience that become someone else's API.

This page builds the model from first principles, then walks a small service through modularisation: writing module-info.java, how the resolver builds the module graph, exports versus opens, services, migrating with automatic modules, the command-line escape hatches, building a trimmed runtime with jlink, and the errors you will actually see. Modules sit on top of class loaders; JVM class loading architecture covers the loaders underneath.

Advertisement

What the class path gets wrong

Before JDK 9 the class path was a flat list of jars. The JVM treated them as one namespace and resolved a class by searching the list in order. Three consequences follow. There is no notion of a dependency: if orders-app.jar needs Jackson and nobody put Jackson on the class path, the application starts and fails when the first request touches a missing class. Packages from different jars merge silently, so two versions of one library produce whichever class the search finds first, a combination neither author tested. And the only access control is per class and per member, so a public class in com.acme.orders.internal is exactly as visible as one in com.acme.orders.api. Internal code gets used, and then it cannot change.

A module answers each problem with a declaration. It names itself, lists what it requires, and lists what it exports. The JDK itself was split into about seventy modules this way, which is why its internals stopped being accessible by default.

Anatomy of module-info.java

A module is a jar (or directory) with a compiled module-info.class at its root. The source file uses a handful of directives. Here is the three-module service used throughout this page: an API module with interfaces and records, a store module with a JDBC implementation, and an application module.

// src/com.acme.orders.api/module-info.java
module com.acme.orders.api {
    exports com.acme.orders.api;            // public types here are the API
    // com.acme.orders.api.internal is not exported: invisible to other modules
}

// src/com.acme.orders.store/module-info.java
module com.acme.orders.store {
    requires transitive com.acme.orders.api; // our public methods return api types
    requires java.sql;
    provides com.acme.orders.api.OrderStore
        with com.acme.orders.store.JdbcOrderStore;
}

// src/com.acme.orders.app/module-info.java
module com.acme.orders.app {
    requires com.acme.orders.api;
    requires com.fasterxml.jackson.databind;
    requires static com.acme.annotations;    // needed to compile, optional at run time
    uses com.acme.orders.api.OrderStore;     // found through ServiceLoader
    opens com.acme.orders.app.dto to com.fasterxml.jackson.databind;
}
DirectiveMeaningUse it when
requires MThis module reads M at compile time and run time.You call M's exported types.
requires transitive MAnything that reads this module also reads M (implied readability).M's types appear in your exported method signatures.
requires static MRequired to compile, optional at run time.Annotations or optional integrations.
exports PPublic types in P are accessible to every module, at compile and run time.P is API.
exports P to MQualified export: only M may access P.Friend modules inside one product.
opens PRun-time reflective access to all members of P, including private ones; no compile-time access.A framework reflects over your classes.
uses S / provides S with CDeclares a service consumer or a provider for ServiceLoader.Plug-in style decoupling.

Exports are per package, never per class, so package layout becomes your API boundary. open module opens every package at once: convenient for applications, too broad for libraries.

Advertisement

Resolution: how the module graph is built

When you run java --module-path mods -m com.acme.orders.app, the launcher resolves the module graph before any application code executes. It starts from the root modules (the one named by -m plus any listed in --add-modules), locates each in the module path or the runtime image, and follows requires edges transitively. Then it performs service binding: for every uses S in the graph, any observable module that provides S is resolved too, along with its own dependencies. The result is a configuration, a directed readability graph, which becomes the boot layer with each module mapped to a class loader.

com.acme.orders.appexplicit module, rootcom.acme.orders.storeprovides OrderStorecom.acme.orders.apiexports ...orders.apijackson.databindexplicit module in lib/java.sqlplatform modulejava.baseread by every modulelegacy-util.jarautomatic moduleunnamed module: everything on --class-pathreads every module; no named module can require itrequiresrequiresservice binding (uses / provides)transitiverequiresimplicitrequires
The resolved graph for the orders service. Solid edges are readability created by requires; the store is pulled in by service binding because app uses OrderStore. The automatic module and the unnamed module show the two migration shapes, and every module reads java.base.

Resolution fails fast, which is the point. It stops with an error if a required module is missing, if the same package appears in two modules in the layer mapped to the same class loader (a split package), or if requires edges form a cycle. Those are exactly the problems the class path used to defer to run time.

Once the graph exists, access needs two things. Code in module A can use a public type in package P of module B only if A reads B and B exports P to A. javac, the JVM's linker (IllegalAccessError) and reflection (InaccessibleObjectException) all enforce both.

There are three kinds of module. Explicit modules have a module-info. An automatic module is a plain jar placed on the module path: it gets a name from the Automatic-Module-Name manifest attribute or, failing that, from the file name, exports and opens every package, and reads every other module, including the unnamed one. The unnamed module is everything on the class path: it reads every module and exports all its packages, but no named module can declare a dependency on it. That asymmetry drives migration: explicit modules cannot see the class path, so modularise from the bottom of the dependency graph up, or wrap the class path through automatic modules.

exports versus opens: API versus reflection

exports grants compile-time and run-time access to public types and their public members. It deliberately does not allow setAccessible(true) on private fields. Deep reflection, which is how serialisers, dependency injectors and ORMs populate objects, needs opens, which grants run-time access to everything but nothing at compile time. Keeping the two separate lets a module offer a small compile-time API while still letting one framework reflect over its entity classes.

Since JEP 396 in JDK 16, the JDK's own internals are strongly encapsulated by default, and since JEP 403 in JDK 17 the old --illegal-access switch has no effect. The only ways into a JDK internal are now explicit flags such as --add-opens. Prefer qualified opens, opens com.acme.orders.app.dto to com.fasterxml.jackson.databind, so only the framework gets in. One catch: you can only name a module in to. If the framework is still on the class path, it lives in the unnamed module and cannot be named, so you must open the package to everyone or move the framework to the module path.

Services: decoupling with uses and provides

Services let a module depend on an interface without knowing the implementation. The API module owns OrderStore; the store module provides an implementation; the application declares uses and looks providers up at run time.

// In com.acme.orders.app: no compile-time dependency on the store module at all.
ServiceLoader<OrderStore> loader = ServiceLoader.load(OrderStore.class);
OrderStore store = loader.stream()
        .filter(p -> p.type().getSimpleName().startsWith("Jdbc"))
        .map(ServiceLoader.Provider::get)
        .findFirst()
        .orElseThrow(() -> new IllegalStateException("no OrderStore provider resolved"));

A provider is either a public class with a public no-argument constructor or a class with a public static provider() method, which lets you return a configured singleton. In an explicit module, META-INF/services files are ignored for that module: the provides directive is the only registration. Automatic modules still have their META-INF/services entries translated into providers, which is why many older libraries keep working.

Worked example: modularising the orders service

The service started as one fat jar plus a lib/ directory. The app constructed JdbcOrderStore directly, and a util package was used by everything. The migration took four moves: split the code into api, store and app source trees; move util into api as a non-exported package; replace the direct constructor call with the ServiceLoader lookup; and put Jackson, which ships explicit module descriptors in recent releases, on the module path. The commands:

# 1. What does the old jar really depend on?
jdeps --print-module-deps --ignore-missing-deps --class-path 'lib/*' target/orders-app.jar

# 2. Compile the three modules in one javac invocation
javac -d out --module-source-path src --module-path lib \
      --module com.acme.orders.api,com.acme.orders.store,com.acme.orders.app

# 3. Package modular jars (repeat for api and store, without --main-class)
jar --create --file mods/orders-app.jar \
    --main-class com.acme.orders.app.Main -C out/com.acme.orders.app .

# 4. Run from the module path (use ; instead of : as the separator on Windows)
java --module-path mods:lib -m com.acme.orders.app

# 5. See what the resolver did, and what a module declares
java --module-path mods:lib --show-module-resolution -m com.acme.orders.app
jar --describe-module --file mods/orders-store.jar

# 6. Link a trimmed runtime; the store is added explicitly because jlink
#    does not follow uses/provides unless you pass --bind-services
jlink --module-path mods:lib \
      --add-modules com.acme.orders.app,com.acme.orders.store \
      --strip-debug --no-header-files --no-man-pages \
      --launcher orders=com.acme.orders.app --output build/orders-runtime
build/orders-runtime/bin/orders

The first run failed at startup with an InaccessibleObjectException saying that module com.acme.orders.app does not open com.acme.orders.app.dto to com.fasterxml.jackson.databind. Jackson was reflecting over the DTOs to deserialise requests. The qualified opens line in the descriptor fixed it. The second surprise was jlink: it refuses automatic modules, so a small legacy jar with no descriptor either needs one written for it (jdeps --generate-module-info produces a starting point) or has to stay out of the image. A trimmed runtime also pairs well with class data sharing archives for faster startup.

Migration strategy for real codebases

Most teams migrate in stages rather than modularising everything at once.

  1. Run on the class path with a modern JDK first. Fix --add-opens needs and the Java EE APIs removed in JDK 11 by JEP 320, such as JAXB, before touching descriptors.
  2. Reserve names. Libraries add Automatic-Module-Name to their manifest so downstream modules can require a stable name instead of one derived from a file name like commons-util-2.3.jar.
  3. Bottom-up for code you own. Give leaf libraries real descriptors first; their dependents can then require them.
  4. Top-down around third-party jars. Move your application to the module path and let unmodularised dependencies become automatic modules until they ship descriptors.
  5. Break split packages. If two jars contribute to one package, merge them or rename a package. There is no flag that makes a split package legal among named modules.

Escape hatches, tests and build tools

FlagEffectTypical use
--add-modules MAdds root modules to resolution.Modules nothing requires, or ALL-MODULE-PATH.
--add-exports M/P=TExports P from M to T at compile or run time.Compiling against a JDK internal temporarily.
--add-opens M/P=TOpens P for deep reflection.java.base/java.lang=ALL-UNNAMED for an older framework.
--add-reads M=TAdds a readability edge.Tests reading a test-only module.
--patch-module M=dirAdds classes into an existing module.White-box tests inside the module.

Executable jars can carry Add-Opens and Add-Exports manifest attributes so users do not have to type flags. Every flag is a record of debt: write each one down next to the dependency that needs it, and remove it when that dependency is upgraded.

Maven's compiler plugin switches to the module path automatically when a module-info.java is present, and its test plugin patches test classes into the module under test. Gradle infers the module path the same way. JDK 25 adds module import declarations (JEP 511), import module java.sql;, which import every package a module exports. That is a source convenience and does not make your code a module. Native code is also gated per module: the Foreign Function and Memory API uses --enable-native-access with module names.

Failure modes and what they mean

SymptomCauseFix
Module not found, required by XJar missing from the module path, wrong separator, or an automatic name different from what was required.Check --show-module-resolution and jar --describe-module.
Package P found in both module A and module BSplit package.Merge or rename; keep one side on the class path only as a stopgap.
IllegalAccessError or 'does not export'Code uses a non-exported package.Export it deliberately or stop depending on internals.
InaccessibleObjectExceptionDeep reflection into a package that is not opened.Qualified opens to the framework module.
ServiceLoader finds no providerProvider module not resolved or not linked.Add it as a root, or jlink with --bind-services.
jlink rejects an automatic modulejlink needs explicit descriptors.Generate a descriptor or ship on a full JDK.

Trade-offs

  • Gains: reliable configuration at startup, enforced API boundaries, smaller runtime images, and a cleaner security story because internals cannot be reached by accident.
  • Costs: descriptors to maintain, friction with reflection-heavy frameworks, and tooling that still sometimes assumes the class path.
  • Where modules pay most: libraries and platforms with many consumers, and services that ship their own runtime image. For a single application with a large, unmodularised framework stack, class path plus a few --add-opens flags is a defensible end state.
  • Relationship to startup work: jlink images are a natural input for Project Leyden style ahead-of-time optimisation, but neither requires the other.

What to do next

  1. Run jdeps --print-module-deps on your main artifact and record which JDK modules it truly needs.
  2. Inventory every --add-opens and --add-exports flag in your launch scripts and tie each to the dependency that requires it.
  3. Add Automatic-Module-Name to every library you publish.
  4. Pick one leaf library, write its module-info.java, and export only the packages that are really API.
  5. Move the application to the module path, fix the InaccessibleObjectExceptions with qualified opens, and check the graph with --show-module-resolution.
  6. Build a jlink image in CI and compare its size and startup time against a full JDK before deciding to ship it.
Key takeaway: JPMS turns three silent class-path failures (missing dependencies, split packages and accidental API) into errors at compile time or startup. A module names itself, declares requires, exports and opens, and the resolver builds a readability graph before any code runs; access then needs both readability and an export. Opens is the separate door for reflection, services decouple interfaces from implementations, automatic modules and the unnamed module carry you through migration, and jlink turns an explicit graph into a trimmed runtime. Adopt it bottom-up, keep every escape-hatch flag on a list, and stop where the framework stack makes the cost exceed the benefit.