Modular Java

By the end of this lesson you'll write a module-info.java , control exactly what your code exposes with requires / exports / opens , build a plugin system with provides...with , and ship a slim custom runtime with jlink .

Learn Modular Java in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.

Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

Before You Start

You should know Maven & Gradle (build tools package your modules), Interfaces (ServiceLoader is interface-driven), and ideally Reflection (which is what the opens directive controls).

A Real-World Analogy: A Module Is an Office Building

💡 Analogy: Picture each module as an office building with a security desk. The lobby ( exports ) is where visitors are allowed — the public API. The back offices are private; no badge gets you in, no matter how "public" the door looks. requires is the list of other buildings you have a pass to enter . opens is handing a trusted contractor (a framework like Jackson) a master key for one floor so they can do maintenance (reflection) — but only at runtime. Before Java 9 there was no security desk at all: every public room in every building was open to everyone, which is exactly how apps ended up depending on someone else's plumbing.

That "no security desk" world was the classpath. JPMS adds the desk, and the rules live in one file per building: module-info.java .

1️⃣ The Module Directives

A module is a named group of packages with an explicit contract: what it needs, and what it lets others use. You write that contract in module-info.java , placed at your source root. Each line is a directive :

Directive

What It Does

Example

requires

Depend on another module (else its packages are invisible)

requires java.sql;

requires transitive

Pass a dependency on to your consumers

requires transitive java.logging;

exports

Make a package compilable/usable by others

exports com.app.api;

exports...to

Qualified export — only named modules

exports com.app.x to com.app.tests;

opens

Allow runtime reflection (not compile access)

opens com.app.model to jackson;

provides...with

Register a service implementation

provides Svc with Impl;

uses

Consume a service via ServiceLoader

uses com.app.Plugin;

The key idea: public stops meaning "anyone can use it." Now a class is reachable from outside only if its package is exports ed. Everything else is hidden — that's strong encapsulation .

2️⃣ Strong Encapsulation vs the Classpath

On the old classpath , every JAR is dumped into one flat namespace and every public type is reachable. That's why projects accidentally call internal JDK classes like sun.misc.Unsafe and break on upgrade — nothing stops them.

On the module path , the JVM reads each module's module-info.java and enforces it. If a package isn't exported, you simply cannot import it, and a missing requires fails at startup rather than as a confusing runtime crash.

3️⃣ ServiceLoader — a Plugin System with provides / uses

A service is an interface; a provider is a class that implements it. ServiceLoader finds every provider on the module path at runtime, so your app can be extended by dropping in a new JAR — no code changes, no if / switch .

💡 Analogy: Think of a wall socket standard . The socket (interface) defines the shape. Any appliance (provider module) that fits plugs in. Your app just asks "what's plugged in?" and uses whatever it finds.

The consumer declares uses Service , each provider declares provides Service with Impl , and ServiceLoader.load(Service.class) ties them together.

🎯 Your Turn #1 — write a module-info.java

Fill in the three directives. Then check your answers against the // ✅ Expected comment at the bottom.

🎯 Your Turn #2 — register a service provider

Wire a new provider into the plugin system from worked example 2 without touching the consumer.

4️⃣ Running on the Module Path & Shipping with jlink

You run modular code by naming the module and main class: java -p out -m com.myapp.app/com.myapp.app.Main . The -p flag is the module path; -m picks the entry point.

jlink goes further: it builds a custom runtime image containing only the JDK modules your app actually uses, plus your own modules and a launcher script. The result runs on a machine with no JDK installed and is a fraction of the size — ideal for containers and installers. Use jdeps first to discover exactly which modules you depend on.

Below is a real shell session: compile on the module path, run, then trim it down with jdeps + jlink .

5️⃣ Migration: Automatic Modules

You don't modularize everything in one go. The migration path is the automatic module : drop a plain JAR (no module-info.java ) onto the module path and JPMS treats it as a module that exports all its packages and can read every other module.

Its module name comes from the Automatic-Module-Name manifest entry if present, otherwise from the JAR filename. So you can write requires gson; today even though Gson isn't modular yet, modularize your own code first, and convert dependencies later as they publish real module descriptors.

Common Errors (and the Fix)

Pro Tips

🧗 Mini-Challenge — build a greeter plugin system

No starter code this time — just an outline. Build the three modules, then prove a new provider works without touching the app.

📋 Quick Reference

Task

Syntax

Purpose

Depend on a module

Make its exported packages visible

Re-export a dependency

requires transitive M;

Consumers get M for free

Publish a package

Public API

Friend access

exports com.app.x to M;

Qualified export

Allow reflection

opens com.app.model to J;

Runtime deep reflection

Register a service

ServiceLoader provider

Consume a service

ServiceLoader consumer

Run on module path

java -p out -m M/Main

Module path + entry point

Find dependencies

jdeps --print-module-deps

List required modules

Custom runtime

jlink --add-modules ...

Slim self-contained image

❓ Frequently Asked Questions

🎉 Lesson Complete!

You can now write a module-info.java , enforce strong encapsulation with exports / opens , build a ServiceLoader plugin system with provides...with , run on the module path, and ship a slim jlink runtime — migrating gradually via automatic modules.

Next up: Deployment — packaging, Docker images, and shipping Java apps to production.

Practice quiz

Which directive declares a dependency on another module?

  • exports
  • opens
  • requires
  • uses

Answer: requires. requires names another module; without it, even built-in modules like java.sql are invisible to your code.

Which directive makes a package compilable and usable by other modules?

  • exports
  • opens
  • provides
  • requires

Answer: exports. exports publishes a package as part of your public API. Only exported packages are visible to other modules.

A framework like Jackson needs deep reflection (setAccessible(true)) into your model package. Which directive grants that?

  • exports
  • requires transitive
  • uses
  • opens

Answer: opens. opens grants runtime reflection (including private members) but NOT compile-time access. exports alone is not enough.

What does 'requires transitive M;' do?

  • Hides M from your consumers
  • Passes the dependency on M to anyone who requires you
  • Opens M for reflection
  • Registers M as a service

Answer: Passes the dependency on M to anyone who requires you. requires transitive re-exports the dependency: any module that requires you also gets M for free.

Once a module-info.java exists, what makes a class reachable from other modules?

  • Its package being exported
  • Marking it public
  • Placing it in the default package
  • Adding a main method

Answer: Its package being exported. public is no longer enough — a class is reachable only if its package is exported. That is strong encapsulation.

Which pair wires a ServiceLoader plugin together?

  • exports on both sides
  • opens on the provider and requires on the consumer
  • provides...with on the provider and uses on the consumer
  • requires transitive on both sides

Answer: provides...with on the provider and uses on the consumer. The provider declares 'provides Service with Impl;' and the consumer declares 'uses Service;'. ServiceLoader.load ties them.

Which flag runs code on the module path instead of the classpath?

  • -cp
  • -p (--module-path)
  • -jar
  • -classpath

Answer: -p (--module-path). -p (--module-path) reads each JAR as a module and enforces its module-info.java; -cp is the old flat classpath.

What does jlink produce?

  • A fat JAR with all dependencies
  • A module-info.java file
  • A list of split packages
  • A self-contained custom runtime image of only the modules you need

Answer: A self-contained custom runtime image of only the modules you need. jlink links your modules plus only the JDK modules they use into a slim runtime that needs no separate JDK.

What is an automatic module?

  • A module the JVM generates from your source
  • A plain JAR (no module-info.java) placed on the module path
  • A module that opens every package automatically
  • A module created by jlink

Answer: A plain JAR (no module-info.java) placed on the module path. A plain JAR on the module path becomes an automatic module: it reads every module and exports all its packages, easing migration.

Why does the build fail with a 'split package' error?

  • A package is exported twice
  • A module has no main class
  • The same package name appears in two modules
  • An import is missing

Answer: The same package name appears in two modules. JPMS requires a package to belong to exactly one module. Merge the package into one module or rename one side.

Continue this course

Related lessons