Introduction: Building with Blocks – Understanding Java Modules

Welcome back, future Java architect! Up until now, we’ve mostly worked with individual .java files, then grouped them into packages, and finally bundled them into JARs. This approach works wonderfully for smaller projects, but as applications grow, they can become behemoths of tangled dependencies, making them hard to manage, understand, and secure.

Enter Java Modules, also known as Project Jigsaw, a revolutionary feature introduced in Java 9 and refined in subsequent versions, including our current focus, Java 25. Modules provide a powerful new way to structure your applications, bringing strong encapsulation, reliable configuration, and improved maintainability. Think of it like building with LEGOs: instead of a pile of bricks, you have well-defined, interconnected blocks, each with a clear purpose and explicit connections to other blocks.

In this chapter, you’ll learn what Java Modules are, why they’re essential for modern, large-scale Java applications, and how to build your own modular projects step-by-step using Java Development Kit (JDK) 25. We’ll cover the core concepts of the Java Module System (JMS), including module declarations, exports, and requirements. By the end, you’ll be able to confidently structure your Java projects in a way that promotes clarity, security, and scalability. Ready to modularize? Let’s dive in!

Before we start, make sure you have a working JDK 25 installation. You should also be comfortable with basic Java syntax, packages, and compiling/running Java code from the command line, as covered in earlier chapters.

Core Concepts: The Building Blocks of Modularity

Before we write any code, let’s understand the fundamental ideas behind Java Modules. Don’t worry, we’ll break it down into digestible pieces!

The Problem: Before Modules (The “Classpath Hell”)

Imagine you’re building a massive Java application. You have hundreds of classes, organized into many packages, and you rely on dozens of third-party libraries (JAR files). This setup often leads to several headaches:

  1. Weak Encapsulation: By default, if a class is public within a JAR, any other code on the classpath can access it. This means you might accidentally depend on internal implementation details of a library that weren’t meant for public use. When the library updates, your code breaks.
  2. Classpath Hell: Managing a long list of JARs on the classpath can be tricky. You might have version conflicts (different libraries requiring different versions of the same dependency), or accidentally include duplicate JARs, leading to unpredictable behavior.
  3. Unreliable Configuration: It’s hard to know exactly what classes your application truly needs. The Java Virtual Machine (JVM) loads everything it finds on the classpath, even if it’s never used. This can lead to larger application sizes and slower startup times.
  4. Security Concerns: With everything accessible, it’s harder to restrict what parts of your application can access sensitive code or data.

These problems are what the Java Module System (JMS) was designed to solve!

What is a Module?

At its heart, a module is a named, self-describing collection of code and data. Think of it as a super-powered JAR file. Instead of just containing classes and resources, a module explicitly declares:

  • Its Name: A unique identifier (e.g., com.example.myapp).
  • What it Exports: Which of its packages are accessible to other modules. Everything else is strongly encapsulated and hidden by default.
  • What it Requires: Which other modules it needs to function.
  • What Services it Uses or Provides: (We’ll touch on this briefly later, but it’s for more advanced scenarios).

The magic happens in a special file called module-info.java.

The module-info.java File: Your Module’s Blueprint

Every module has a module-info.java file at its root. This file is called the module descriptor. It’s where you declare all the crucial information about your module.

Let’s look at a simple example:

// This is NOT actual code yet, just an illustration!
module com.example.myfeature {
    exports com.example.myfeature.api; // This package is public
    requires com.example.utility;      // This module needs 'utility' module
}

This snippet tells us:

  • This module is named com.example.myfeature.
  • It exposes only the com.example.myfeature.api package to other modules. All other packages within com.example.myfeature are hidden. This is strong encapsulation in action!
  • It depends on another module called com.example.utility. If com.example.utility isn’t present, this module won’t even compile or run. This is reliable configuration.

Key Keywords in module-info.java

Let’s break down the most important keywords you’ll use:

  • module <module_name> { ... }: Declares a new module with a unique name. By convention, module names follow a reverse-domain naming pattern, similar to packages (e.g., com.mycompany.mymodule).

  • exports <package_name>;: This is how a module explicitly states which of its packages are available for other modules to use. If a package isn’t exported, it’s considered an internal implementation detail and is inaccessible from outside the module.

  • requires <other_module_name>;: This declares a dependency. Your module needs the specified <other_module_name> to compile and run. The Java Module System ensures that all required modules are present and correctly resolved at compile-time and run-time.

  • requires transitive <other_module_name>;: This is a special type of requires. If Module A requires transitive Module B, and Module C requires Module A, then Module C automatically gains access to Module B’s exported packages without explicitly requiring Module B itself. This is useful for library modules that expose functionality from their dependencies as part of their own API.

  • opens <package_name>;: While exports allows compilation and direct access to public types, opens allows runtime reflection access to all types (public and non-public) within the specified package. This is crucial for frameworks (like Spring or Hibernate) that often use reflection to inspect and manipulate objects. If you don’t open a package, reflection will fail for non-exported types.

  • uses <service_interface_name>;: Declares that this module uses a service defined by <service_interface_name>. This is part of the Java Service Provider Interface (SPI) mechanism.

  • provides <service_interface_name> with <service_implementation_class>;: Declares that this module provides an implementation of a service defined by <service_interface_name>.

For our initial steps, module, exports, and requires will be our primary focus.

Modular JARs vs. Traditional JARs

When you compile a modular project, the output is still a JAR file. However, this JAR now contains a module-info.class file (the compiled version of module-info.java) at its root. This makes it a modular JAR.

  • Modular JARs: Placed on the module path (--module-path or -p option for javac and java). The JMS reads their module-info.class descriptors to enforce strong encapsulation and reliable configuration.
  • Traditional JARs: Placed on the classpath (-classpath or -cp option). They don’t have module-info.class. When a traditional JAR is placed on the module path, it becomes an automatic module.

Automatic Modules and the Unnamed Module

  • Automatic Modules: If you place a traditional JAR (one without a module-info.java) on the module path, the Java Module System treats it as an “automatic module.” Its module name is derived from its JAR file name (e.g., my-library-1.0.jar becomes my.library). An automatic module exports all its packages and requires all other modules on the module path. This provides a backward-compatibility bridge, allowing you to gradually modularize your application while still using non-modular libraries.

  • Unnamed Module: Any code that is compiled and run without being explicitly part of a named module (e.g., .java files directly on the classpath, or traditional JARs on the classpath) belongs to the unnamed module. The unnamed module can “see” all other modules (named or automatic), but no named module can require the unnamed module. This is another compatibility mechanism.

Phew! That was a lot of theory, but now you have the foundational knowledge. Let’s get our hands dirty and build a modular application!

Step-by-Step Implementation: Building Our First Modular Application

We’re going to create a very simple multi-module application. We’ll have two modules:

  1. com.example.greeting: This module will provide a simple Greeter class to generate a greeting message.
  2. com.example.app: This module will be our main application, which uses the com.example.greeting module.

This structure will clearly demonstrate how exports and requires work.

Project Setup: Creating the Directory Structure

First, let’s set up our project directory. Open your terminal or command prompt.

mkdir java-modules-demo
cd java-modules-demo
mkdir -p src/com.example.greeting src/com.example.app

You should now have a structure like this:

java-modules-demo/
├── src/
│   ├── com.example.app/
│   └── com.example.greeting/

Module 1: com.example.greeting

This module will contain our Greeter class and its module-info.java file.

Step 1: Create the Greeter Class

Inside src/com.example.greeting, create the package directory com/example/greeting and then the Greeter.java file.

mkdir -p src/com.example.greeting/com/example/greeting
# For Linux/macOS
touch src/com.example.greeting/com/example/greeting/Greeter.java
# For Windows
# type nul > src\com.example.greeting\com\example\greeting\Greeter.java

Now, open src/com.example.greeting/com/example/greeting/Greeter.java in your code editor and add the following code:

// src/com.example.greeting/com/example/greeting/Greeter.java
package com.example.greeting;

/**
 * A simple class that provides greeting messages.
 */
public class Greeter {

    /**
     * Generates a personalized greeting message.
     * @param name The name of the person to greet.
     * @return A greeting string.
     */
    public String getGreeting(String name) {
        return "Hello, " + name + " from the Java Module System!";
    }

    // This method is internal and won't be exported
    private String getInternalMessage() {
        return "This is an internal message.";
    }
}

Explanation:

  • We define a package com.example.greeting; which is standard Java.
  • The Greeter class has a public method getGreeting(String name) that returns a string.
  • Notice the getInternalMessage() method is private. This is already encapsulated at the class level. Modules take encapsulation a step further at the package level.

Step 2: Create the module-info.java for com.example.greeting

Now, let’s define our module descriptor. Create the module-info.java file directly inside src/com.example.greeting.

# For Linux/macOS
touch src/com.example.greeting/module-info.java
# For Windows
# type nul > src\com.example.greeting\module-info.java

Open src/com.example.greeting/module-info.java and add:

// src/com.example.greeting/module-info.java
module com.example.greeting {
    exports com.example.greeting; // Export the package containing Greeter
}

Explanation:

  • module com.example.greeting { ... }: This declares our module and gives it the name com.example.greeting.
  • exports com.example.greeting;: This is crucial! It tells the Java Module System that the com.example.greeting package (which contains our Greeter class) is accessible to other modules that require com.example.greeting. Any other packages we might add to this module (e.g., com.example.greeting.internal) would remain hidden unless explicitly exported.

Module 2: com.example.app

This module will be our main application, which requires com.example.greeting and uses its Greeter class.

Step 1: Create the Main Application Class

Inside src/com.example.app, create the package directory com/example/app and then the Main.java file.

mkdir -p src/com.example.app/com/example/app
# For Linux/macOS
touch src/com.example.app/com/example/app/Main.java
# For Windows
# type nul > src\com.example.app\com\example\app\Main.java

Open src/com.example.app/com/example/app/Main.java and add:

// src/com.example.app/com/example/app/Main.java
package com.example.app;

import com.example.greeting.Greeter; // We need to import Greeter

public class Main {
    public static void main(String[] args) {
        Greeter greeter = new Greeter();
        String message = greeter.getGreeting("Modular World");
        System.out.println(message);
    }
}

Explanation:

  • We attempt to import com.example.greeting.Greeter;. This import will only work if com.example.greeting module exports the com.example.greeting package AND our com.example.app module explicitly requires com.example.greeting.
  • We then create an instance of Greeter and use its getGreeting method.

Step 2: Create the module-info.java for com.example.app

Now, create the module-info.java file directly inside src/com.example.app.

# For Linux/macOS
touch src/com.example.app/module-info.java
# For Windows
# type nul > src\com.example.app\module-info.java

Open src/com.example.app/module-info.java and add:

// src/com.example.app/module-info.java
module com.example.app {
    requires com.example.greeting; // This module needs com.example.greeting
}

Explanation:

  • module com.example.app { ... }: Declares our main application module.
  • requires com.example.greeting;: This is the explicit dependency declaration. It tells the Java Module System that com.example.app needs com.example.greeting to compile and run. Without this, the compiler would complain that it cannot find the Greeter class, even if com.example.greeting exported it!

Compiling and Running Our Modular Application

Now for the exciting part: compiling and running this multi-module project! We’ll use the javac and java commands with their module-specific options.

Step 1: Compile the Modules

We need to compile both modules. The javac command needs to know where to find the source code for all modules and where to place the compiled .class files.

Navigate to the java-modules-demo directory in your terminal.

# Create an 'out' directory for our compiled modules
mkdir out

# Compile both modules
# --module-source-path src: Tells javac where to find module source code (in the 'src' directory)
# -d out: Tells javac to put compiled output into the 'out' directory
# --module com.example.app,com.example.greeting: Specifies which modules to compile
javac --module-source-path src -d out --module com.example.app,com.example.greeting

If everything is correct, you should see no output (which is good!) and a new out directory created with the compiled modules:

java-modules-demo/
├── out/
│   ├── com.example.app/
│   │   ├── com/example/app/Main.class
│   │   └── module-info.class
│   └── com.example.greeting/
│       ├── com/example/greeting/Greeter.class
│       └── module-info.class
└── src/
    ├── com.example.app/
    │   ├── com/example/app/Main.java
    │   └── module-info.java
    └── com.example.greeting/
        ├── com/example/greeting/Greeter.java
        └── module-info.java

Explanation of the javac command:

  • --module-source-path src: This tells the compiler that the src directory contains the root of our module source code. It will look for module-info.java files directly under src/<module_name>.
  • -d out: This specifies the output directory for the compiled classes. Each module will get its own subdirectory within out.
  • --module com.example.app,com.example.greeting: This explicitly lists the modules we want to compile. For a small project like this, it’s fine. For larger projects, you might just specify the root module (com.example.app in this case), and the compiler will find its dependencies automatically.

Step 2: Run the Modular Application

Now, let’s run our com.example.app module.

# Run the application
# --module-path out: Tells the JVM where to find compiled modules (in the 'out' directory)
# --module com.example.app/com.example.app.Main: Specifies the main module and its main class
java --module-path out --module com.example.app/com.example.app.Main

You should see the following output:

Hello, Modular World from the Java Module System!

Explanation of the java command:

  • --module-path out: This is similar to the classpath, but for modules. It tells the Java Virtual Machine (JVM) where to find the compiled modules (.class files and module-info.class). The JVM will then use the module descriptors to resolve dependencies and enforce encapsulation.
  • --module com.example.app/com.example.app.Main: This specifies two things:
    • com.example.app: The name of the module that contains our main application entry point.
    • com.example.app.Main: The fully qualified name of the class within that module that contains the main method.

Congratulations! You’ve successfully compiled and run your first multi-module Java 25 application. You’ve now experienced the benefits of strong encapsulation and reliable configuration firsthand.

Mini-Challenge: Adding a Farewell Module

Let’s put your new knowledge to the test!

Challenge: Add a new module called com.example.farewell.

  1. This module should contain a FarewellGreeter class with a public method getFarewell(String name) that returns a farewell message (e.g., “Goodbye, [name]! See you later.”).
  2. The com.example.farewell module must export its package com.example.farewell.
  3. Modify the com.example.app module to also require com.example.farewell and use the FarewellGreeter class to print a farewell message after the greeting.

Hint:

  • Follow the same steps as you did for com.example.greeting.
  • Remember to update the module-info.java for com.example.app to reflect the new requires dependency.
  • You’ll need to recompile all relevant modules after making changes.

What to observe/learn:

  • How adding a new module impacts existing modules.
  • Reinforce your understanding of exports and requires.
  • The modular system will catch any missing requires statements during compilation.

Take your time, try to solve it independently, and remember, trial and error is part of the learning process!

Click for Solution (if you get stuck!)

Solution Steps:

  1. Create com.example.farewell module directory:

    mkdir -p src/com.example.farewell/com/example/farewell
    
  2. Create FarewellGreeter.java: src/com.example.farewell/com/example/farewell/FarewellGreeter.java

    package com.example.farewell;
    
    public class FarewellGreeter {
        public String getFarewell(String name) {
            return "Goodbye, " + name + "! See you later.";
        }
    }
    
  3. Create module-info.java for com.example.farewell: src/com.example.farewell/module-info.java

    module com.example.farewell {
        exports com.example.farewell;
    }
    
  4. Update Main.java in com.example.app: src/com.example.app/com/example/app/Main.java

    package com.example.app;
    
    import com.example.greeting.Greeter;
    import com.example.farewell.FarewellGreeter; // New import
    
    public class Main {
        public static void main(String[] args) {
            Greeter greeter = new Greeter();
            String greetingMessage = greeter.getGreeting("Modular World");
            System.out.println(greetingMessage);
    
            FarewellGreeter farewellGreeter = new FarewellGreeter(); // New usage
            String farewellMessage = farewellGreeter.getFarewell("Modular World");
            System.out.println(farewellMessage);
        }
    }
    
  5. Update module-info.java for com.example.app: src/com.example.app/module-info.java

    module com.example.app {
        requires com.example.greeting;
        requires com.example.farewell; // New requirement
    }
    
  6. Recompile all modules:

    # Make sure you are in the 'java-modules-demo' directory
    javac --module-source-path src -d out --module com.example.app,com.example.greeting,com.example.farewell
    
  7. Run the application:

    java --module-path out --module com.example.app/com.example.app.Main
    

Expected Output:

Hello, Modular World from the Java Module System!
Goodbye, Modular World! See you later.

Common Pitfalls & Troubleshooting

Working with modules introduces a few new types of errors. Here are some common ones and how to resolve them:

  1. “Package is not visible” or “Package not found” errors:

    • Symptom: Your code tries to import a class from another module, but the compiler complains it can’t find the package.
    • Cause:
      • The module providing the package hasn’t explicitly exported that package in its module-info.java.
      • The module requiring the package hasn’t explicitly required the providing module in its module-info.java.
      • You’re trying to access an internal package that was never meant to be public.
    • Solution: Double-check both module-info.java files. Ensure the providing module exports the package and the consuming module requires the providing module.
  2. “Module not found” errors during compilation or runtime:

    • Symptom: javac or java reports that it cannot find a module, even though you know it exists.
    • Cause:
      • The module isn’t on the module path.
      • A typo in the module name in your requires statement or the command line.
      • The module’s directory structure or module-info.java is incorrect (e.g., module-info.java is not at the root of the module source).
    • Solution: Verify your --module-source-path (for compilation) and --module-path (for runtime) settings. Ensure the module name in module-info.java exactly matches the name used in requires statements and command-line arguments. Check your directory structure.
  3. Reflection Issues (InaccessibleObjectException):

    • Symptom: Your application uses reflection (e.g., Class.forName().getDeclaredMethod()) to access fields or methods of a class in another module, and you get an InaccessibleObjectException or similar error, even if the package is exported.
    • Cause: While exports allows direct access to public types, it does not automatically grant reflective access to non-public members, or even public members if the reflective access is considered “deep.” For frameworks that heavily rely on reflection to modify private fields or call non-public methods, exports isn’t enough.
    • Solution: The module whose internals are being reflectively accessed needs to explicitly opens <package_name>; in its module-info.java. If you only want to open it to specific modules, you can use opens <package_name> to <target_module_name>;.
  4. Circular Dependencies:

    • Symptom: The compiler reports a circular dependency between modules (e.g., Module A requires Module B, and Module B requires Module A).
    • Cause: This indicates a design flaw where two modules are too tightly coupled. The module system prevents this to ensure a clear, directed acyclic graph of dependencies.
    • Solution: Refactor your code. Extract the common functionality or the parts causing the circular dependency into a third, independent module that both original modules can then require. This often leads to better, more cohesive module design.

Remember to leverage the detailed error messages provided by the Java compiler and runtime. They are usually quite descriptive about what went wrong in the module system. The official Oracle JDK 25 documentation on the module system is an excellent resource for deeper dives and specific error codes: JDK 25 Documentation - Project Jigsaw (Navigate to “Guides” -> “Module System”).

Summary: Your Modular Journey Begins

Phew, that was a comprehensive dive into Java Modules! You’ve taken a significant step towards understanding how to build robust, scalable, and maintainable Java applications.

Here are the key takeaways from this chapter:

  • Java Modules (Project Jigsaw) address challenges like “classpath hell” and weak encapsulation in large Java applications.
  • A module is a named, self-describing collection of code and data, defined by its module-info.java descriptor.
  • The module-info.java file uses keywords like module, exports, requires, and opens to declare a module’s identity, public API, dependencies, and reflective access permissions.
  • exports makes packages visible to other modules, enforcing strong encapsulation by default.
  • requires declares a module’s explicit dependencies, ensuring reliable configuration.
  • requires transitive allows a module’s dependencies to be implicitly required by modules that depend on it.
  • opens is used to grant runtime reflective access to packages, essential for many frameworks.
  • You learned how to compile and run multi-module applications using javac --module-source-path -d out --module ... and java --module-path out --module ....
  • You tackled a mini-challenge, reinforcing your understanding of module declarations and dependencies.
  • You’re now aware of common pitfalls like “package not visible” errors and reflection issues, and how to troubleshoot them.

By adopting the Java Module System, you’re embracing modern Java best practices for structuring complex applications. This not only improves maintainability and reliability but also opens doors for more advanced deployment scenarios like custom runtime images.

What’s next? In the upcoming chapters, we’ll continue building on this foundation. We might explore more advanced module features like services, or we’ll transition to how popular frameworks like Spring Boot leverage modularity (and sometimes automatic modules) to manage dependencies effectively. Keep up the great work!