Icon for gfsd IntelliJ IDEA

What are Java modules and how to use them?

A guide to Java modules

In this post, we dive into the details of modules in Java, covering the most important types and their differences.

What are modules in Java?

Modules are used to create separate units of software and group code together with its tests and resources. Modules can be compiled separately and can be integrated into larger programs by declaring dependencies to each other and importing them into the code.

Over the years, multiple module concepts for Java have been created in different domains before Java 9 introduced the Java Platform Module System. We cover the most important modules in this post, explain the differences, and list some key considerations to help you pick the right one for your Java project.

What are the different kinds of Java modules?

The most important module concepts in Java are the following:

  • Modules from a build management perspective by Maven and Gradle
  • Modules from a code visibility perspective from the Java Platform
  • Various other concepts for specific frameworks or applications

We will introduce the first two kinds in detail and briefly highlight some examples of the third type of modules in Java.

Build management modules by Maven and Gradle

Both tools offer robust build automation and dependency management for Java projects, in addition to their respective module concepts. They streamline the process of separating projects into smaller artifacts while ensuring correct build order and allowing all that to be done with a single command.

Maven and Gradle enable the declaration of configurations at the root level of the project, reducing duplication. These configurations can be reused across separate modules, promoting consistency and efficiency in development.

Maven submodules

Terminology:

  • Maven Multi-Module Projects: Consist of a common aggregator POM and individual POMs for each module.
  • Maven Reactor: Analyzes module dependencies to determine the correct build order.

Modules function like independent Maven projects, allowing separate versioning and management. Maven handles the compilation, testing, and other tasks of the entire application with a single command, simplifying submodule management.

Gradle subprojects

Terminology:

  • Subprojects: Equivalent to Maven modules, representing individual components within a larger project.
  • Multi-project build: Refers to the structure where a project consists of multiple interconnected subprojects.

Subprojects serve as the Gradle counterpart to Maven modules, facilitating modular project organization. Unlike Maven, where multi-module projects are explicitly defined, Gradle treats all builds as multi-project builds by default, simplifying project setup and management.

Java Platform Module System (JPMS)

The Java Platform Module System follows a completely different concept from Maven and Gradle. The usage scenarios are also different.

Instead of managing modules during the building of the application, JPMS provides strong encapsulation by using new mechanisms for grouping Java packages.

Terminology:

  • Java Platform Module System (JPMS): Formerly known as Project Jigsaw, JPMS introduces a new module path concept alongside the traditional classpath.
  • Module-aware: Refers to utilizing the module path instead of the classpath for Java applications.

JPMS was introduced in Java 9 to enhance modularity and encapsulation in Java applications. It allows for making implementation inaccessible from outside the module, improving code security and maintainability. This strong encapsulation affects reflection, which impacts the use of frameworks like JUnit and Spring.

JPMS is utilized by JDK versions 9 and above, enabling modular development within the Java ecosystem. Each package can only be exported by one module, enforcing strict module boundaries. Concepts like automatic and unnamed modules allow the integration of existing JARs with the module system, enabling you to use the module systems regardless of whether the libraries you depend on also use it.

Using modules helps minimize the JRE size by bundling only necessary modules, reducing the application’s footprint, which is especially useful for containerized applications.

Other module concepts in Java

There are other, more specialized module concepts within Java, which we will just briefly mention here:

OSGi (formerly Open Services Gateway initiative)

OSGi provides a comprehensive framework for dynamic loading and unloading of modules, known as “Bundles,” during runtime. This dynamic modularity enables greater flexibility and scalability in Java applications.

Spring Modulith

Spring Modulith is a concept specifically designed for modularizing Spring Boot applications. It offers guidelines and best practices for organizing large-scale Spring Boot projects into manageable and maintainable modules.

IntelliJ Modules

IntelliJ Modules represent an IDE-specific project organization concept, historically grown within the IntelliJ IDEA ecosystem. While not Java-specific, these modules facilitate internal project organization within the IntelliJ IDEA IDE.

Comparing Java modules: Maven, Gradle, and JPMS (with code examples)

We want to highlight the differences between how Maven submodules, Gradle subprojects, and JPMS Modules look using a simple code example. You can follow the steps in our example to find out how to implement the respective module concept yourself.

We will be using the same simple source code each time: a class Person containing getters and setters for first and last name, and a class Greeter that uses Person to generate a personalized greeting. Both classes will get a separate module so that we can showcase the interaction between multiple modules.

Person.java:

package com.symflower;

public class Person {
    private String firstName;
    private String lastName;

    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
}

Greeter.java:

package com.symflower;

public class Greeter {
    public String Greet(Person p) {
        return "Hello " + p.getFirstName() + " " + p.getLastName() + "!";
    }
}

How to adopt Maven modules in Java?

Make sure you have Maven installed to follow these steps. You can do that by running mvn –version in your command line. It should print out information on your Maven installation.

1) Create Maven project

First, we need to create a Maven project with an aggregator POM. As we don’t need much of a structure for that yet, we can do that manually:

  • Create a new directory for your project, e.g. greeter-maven, and create a file pom.xml inside that directory.
  • Copy the following content into pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.symflower</groupId>
    <artifactId>greeter-maven</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.5.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M8</version>
            </plugin>
        </plugins>
    </build>
</project>

Adapt the groupId, artifactId, and version at the top of the file according to how you want to name your project. Important note: the packaging property has to be set to pom for our next steps to work properly.

You may also adapt the properties <maven.compiler.source> and <maven.compiler.target> to your liking.

This code already provides the necessary configurations for running tests on our code: the dependency on JUnit 5 as our testing framework, and the maven-surefire-plugin for properly running the tests through Maven.

2) Create our submodules

Next, we want to add the submodules where we will place our code. Make sure that your current directory contains pom.xml, so in our structure, that is greeter-maven. Generate the submodules by running:

mvn archetype:generate -DgroupId=com.symflower -DartifactId=greeter  -DarchetypeArtifactId=maven-archetype-simple -DarchetypeVersion=1.4 -DinteractiveMode=false
mvn archetype:generate -DgroupId=com.symflower -DartifactId=person -DarchetypeArtifactId=maven-archetype-simple -DarchetypeVersion=1.4 -DinteractiveMode=false

Adapt the groupId to match the one in the aggregator pom.xml.

These commands will create the correct file system structure for the submodules and extend the aggregator POM with the <modules> configuration for you:

<modules>
    <module>person</module>
    <module>greeter</module>
</modules>

3) Make sure the submodules are created correctly

  • Check that the configuration <modules> is present in the aggregator POM.
  • The names of the directories for the submodules, and the artifactIds within the pom.xml in the submodules' directory have to match.
  • The groupId, artifactId, and version of the aggregator POM has to match the info in the <parent> configuration within the submodules' pom.xml.

You can delete any site directory from the modules as we won’t need them for this example. You can also delete any JUnit dependencies and compiler versions from the submodules' pom.xml while you’re at it.

4) Add your sources to the respective source directories

Now we can add our sources to their respective directories. You will have to create the subdirectories beneath the directory java yourself:

  • Put Person.java in person/src/main/java/com/symflower/
  • Put Greeter.java in greeter/src/main/java/com/symflower/

Because we use Person within Greeter, we have to declare the dependency in greeter/pom.xml:

<dependencies>
    <dependency>
        <groupId>com.symflower</groupId>
        <artifactId>person</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

5) Build application either from within submodules (just module) or level of parent pom (all modules)

Now the application can already be compiled. You can compile each module separately, for example, the module Person: cd person mvn compile

But you can also compile all modules in one step, with Maven figuring out the correct order to compile them automatically. Make sure you are in the greeter-maven directory, and run

mvn compile

At the end of the output, you should see that it compiled all our modules, and our aggregator POM:

[INFO] ------------------------------------------------------------------------
[INFO] Reactor Summary for greeter-maven 1.0-SNAPSHOT:
[INFO]
[INFO] greeter-maven ...................................... SUCCESS [  0.003 s]
[INFO] person ............................................. SUCCESS [  0.815 s]
[INFO] greeter ............................................ SUCCESS [  0.106 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

The “Reactor Summary” tells you which modules Maven touched, and in which order. As you can see here, Maven correctly built greeter after person, following the dependency we specified. If there are no dependencies between modules, Maven uses the order of declaration in <modules> in the aggregator POM.

6) Add tests to src/test/java/…

Now we want to make sure our code works. For that, we are going to add unit tests to our project.

First of all, make sure the necessary configurations are present:

  • Make sure the JUnit 5 dependency is there. It is sufficient if it’s in the aggregator POM.
  • Also, check that the maven-surefire-plugin is added to the <build>. Same here, it’s OK if it’s in the aggregator POM.

Now we can add our unit tests. If you have Symflower installed in VS Code or your IntelliJ IDE, you can use the “create test” functionality, or “Symflower: Create Test Template for Function” to create the test file and a test function, so you just need to adapt the values used in the test.

🦾 Why write unit tests when you can generate them?

Symflower lets you generate test templates on the fly, right in your IDE. Save time on boilerplate code and focus on defining test scenarios and doing some actual value-added development work!

Alternatively, add test classes in the src/test/java/... directories of the modules yourself.

The project structure will now look like this:

greeter-maven
β”œβ”€β”€ greeter
β”‚   β”œβ”€β”€ pom.xml
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   β”œβ”€β”€ java
β”‚       β”‚   β”‚   └── com
β”‚       β”‚   β”‚       └── symflower
β”‚       β”‚   β”‚           β”œβ”€β”€ greeter
β”‚       β”‚   β”‚           β”‚   └── GreeterNotInSamePackage.java
β”‚       β”‚   β”‚           └── Greeter.java
β”‚       β”‚   └── resources
β”‚       └── test
β”‚           └── java
β”‚               └── com
β”‚                   └── symflower
β”‚                       β”œβ”€β”€ greeter
β”‚                       β”‚   └── GreeterNotInSamePackageTest.java
β”‚                       └── GreeterTest.java
β”œβ”€β”€ person
β”‚   β”œβ”€β”€ pom.xml
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   └── java
β”‚       β”‚       └── com
β”‚       β”‚           └── symflower
β”‚       β”‚               └── Person.java
β”‚       └── test
β”‚           └── java
β”‚               └── com
β”‚                   └── symflower
β”‚                       └── PersonTest.java
└── pom.xml

Our PersonTest.java looks like this:

package com.symflower;

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

public class PersonTest {
	@Test
	public void getFirstName() {
		Person p = new Person("Serena", "Everhart");
		String expected = "Serena";
		String actual = p.getFirstName();

		assertEquals(expected, actual);

		p.setFirstName("Prudence");
		expected = "Prudence";
		actual = p.getFirstName();

		assertEquals(expected, actual);
	}

	@Test
	public void getLastName() {
		Person p = new Person("Serena", "Everhart");
		String expected = "Everhart";
		String actual = p.getLastName();

		assertEquals(expected, actual);

		p.setLastName("Flinch");
		expected = "Flinch";
		actual = p.getLastName();

		assertEquals(expected, actual);
	}
}

You will notice that our structure has two separate Greeter implementations. Maven allows you to use the same package in multiple modules, so the Greeter.java implementation as introduced earlier works fine. But you can also reference subpackages without problems, so we copied Greeter.java into GreeterNotInSamePackage.java within a new package. The only other difference is the now necessary import of Person.

Our GreeterTest.java using the same package looks like this:

package com.symflower;

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

public class GreeterTest {
	@Test
	public void Greet() {
		Greeter g = new Greeter();
		Person p = new Person("Serena", "Everhart");
		String expected = "Hello Serena Everhart!";
		String actual = g.Greet(p);

		assertEquals(expected, actual);
	}
}

Our GreeterNotInSamePackageTest.java looks like this, mainly differing in the additional import:

package com.symflower.greeter;

import com.symflower.Person;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

public class GreeterNotInSamePackageTest {
	@Test
	public void Greet() {
		GreeterNotInSamePackage g = new GreeterNotInSamePackage();
		Person p = new Person("Serena", "Everhart");
		String expected = "Hello Serena Everhart!";
		String actual = g.Greet(p);

		assertEquals(expected, actual);
	}
}

7) Run tests

We can now run our tests using Maven.

  • To only run tests of one module, navigate into the modules' directory (to the same level as its pom.xml is located) and run mvn test
  • To run all tests of the project, navigate to the aggregator POM’s directory and run mvn test there

You should again see all your modules and the aggregator POM in the Reactor summary, and the separate test results above that in the Maven output.

And that’s it! You can now extend your project with additional modules, sources, and tests, and use all the Maven lifecycle phases and goals either on the modules separately or on all modules at the same time.

How to adopt Gradle subprojects?

Make sure you have Gradle installed to follow these steps. You can do that by running gradle -v in your command line. It should print out information on your Gradle installation.

1) Create Gradle application (gradle init with subprojects)

First, we need to generate the structure for our Gradle project. For that, we create the directory greeter-gradle. Then we initialize a Gradle application using the following steps:

cd greeter-gradle gradle init

This will now take you through the process of selecting a few fundamental things for your project:

Select type of project to generate:

1: basic
2: application
3: library
4: Gradle plugin
Enter selection (default: basic) [1..4] 2

Choose 2: application as this will create the basic project structure for a Java application.

Select implementation language:

1: C++
2: Groovy
3: Java
4: Kotlin
5: Scala
6: Swift

Pick 3: Java for a Java application to follow this example.

Split functionality across multiple subprojects?:

1: no - only one application project
2: yes - application and library projects

Go with 2: yes - application and library projects since we want to split our application into subprojects.

Select build script DSL:

1: Groovy
2: Kotlin

Go with 1: Groovy for a build script DSL. While there are advantages to using Kotlin, Groovy is the more common domain-specific language used to define build scripts.

Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no]

no is the default choice and it will do for now, so type “no” or just hit enter to continue.

Project name (default: greeter-gradle):

The next step in the gradle init task is to name your project. For our example, we’ll just use greeter-gradle.

Source package (default: greeter.gradle):

Finally, select a source package. The default greeter.gradle will be just fine.

Once you’re done with all these steps, your new Gradle project is set up with a standard project structure, but also a lot of sample code that we won’t need, so we will do some cleanup next.

Gradle generated the projects app, list, and utilities. Delete app as well as buildSrc and rename the remaining, so list and utilities to person and greeter. You can also remove all the sources, leaving you with the following structure:

greeter-gradle
β”œβ”€β”€ gradle
β”‚   └── wrapper
β”‚       β”œβ”€β”€ gradle-wrapper.jar
β”‚       └── gradle-wrapper.properties
β”œβ”€β”€ gradlew
β”œβ”€β”€ gradlew.bat
β”œβ”€β”€ greeter
β”‚   β”œβ”€β”€ build.gradle
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   β”œβ”€β”€ java
β”‚       β”‚   β”‚   └── greeter
β”‚       β”‚   └── resources
β”‚       └── test
β”‚           β”œβ”€β”€ java
β”‚           β”‚   └── greeter
β”‚           └── resources
β”œβ”€β”€ person
β”‚   β”œβ”€β”€ build.gradle
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   β”œβ”€β”€ java
β”‚       β”‚   β”‚   └── greeter
β”‚       β”‚   └── resources
β”‚       └── test
β”‚           └── resources
└── settings.gradle

2) Make sure subprojects are correctly specified in settings.gradle

We just renamed some subprojects, so we need to adapt settings.gradle to reflect that. The names of the directories of our subprojects have to match what we list in include.

rootProject.name = 'greeter-gradle'
include ('greeter', 'person')

We also need to make sure that our dependencies between the subprojects are set correctly. We can also add our testing dependencies while we’re at it.

Here’s greeter/build.gradle:

plugins {
    id 'java'
}

group 'com.symflower'

repositories {
    mavenCentral()
}

dependencies {
    implementation project(':person')
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}

test {
    useJUnitPlatform()
    testLogging {
        events "failed", "passed", "skipped", "standardError","standardOut", "started"
    }
}

Note that in dependencies, we added a dependency to our other subproject person. We are using implementation for this dependency, check out the Gradle documentation on the different kinds of dependencies that Gradle offers.

This code already contains the configuration we will later need for running unit tests.

person/build.gradle:

plugins {
    id 'java'
}

group 'com.symflower'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}

test {
    useJUnitPlatform()
    testLogging {
        events "failed", "passed", "skipped", "standardError","standardOut", "started"
    }
}

3) Add our sources to the respective source directories

Now we can add our sources to their respective directories. You will have to create the subdirectories beneath the directory java yourself:

  • Put Person.java in person/src/main/java/com/symflower/
  • Put Greeter.java in greeter/src/main/java/com/symflower/

We already added our dependency configuration to greeter/build.gradle in the previous step.

4) Build application either from within submodules (just module) or the parent level (all modules)

The application can now be compiled:

  • You can compile each module separately, for example, the module Person: cd person. gradle build
  • Or you can choose to compile all modules in one step, letting Gradle figure out the correct order to compile them automatically. Make sure you are in the greeter-gradle directory and use the command gradle build.

Your output should say BUILD SUCCESSFUL in order for you to be able to continue.

5) Add tests to src/test/java/…

Now we want to make sure our code works. For that, we are going to add unit tests to our project.

First of all, make sure the necessary configurations are present:

  • The required JUnit dependencies should be present in every build.gradle file including in the root project (if you have one there) and all your subprojects.
  • Make sure testLogging is configured in every build.gradle file. This also has to be added to the root project (if you have a build.gradle file there) and all the subprojects.

Now, we can add our unit tests. If you have Symflower installed in your VSCode or IntelliJ, you can use the “create test” functionality, or “Symflower: Create Test Template for Function” to create the test file and a test function, so you just need to adapt the values used in the test.

Try Symflower in your IDE to generate unit test templates & test suites
Alternatively, add test classes in the src/test/java/… directories of the modules yourself.

The project structure will now look like this:

greeter-gradle
β”œβ”€β”€ gradle
β”‚   └── wrapper
β”‚       β”œβ”€β”€ gradle-wrapper.jar
β”‚       └── gradle-wrapper.properties
β”œβ”€β”€ gradlew
β”œβ”€β”€ gradlew.bat
β”œβ”€β”€ greeter
β”‚   β”œβ”€β”€ build.gradle
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   β”œβ”€β”€ java
β”‚       β”‚   β”‚   └── com
β”‚       β”‚   β”‚       └── symflower
β”‚       β”‚   β”‚           β”œβ”€β”€ greeter
β”‚       β”‚   β”‚           β”‚   └── GreeterNotInSamePackage.java
β”‚       β”‚   β”‚           └── Greeter.java
β”‚       β”‚   └── resources
β”‚       └── test
β”‚           β”œβ”€β”€ java
β”‚           β”‚   └── com
β”‚           β”‚       └── symflower
β”‚           β”‚           β”œβ”€β”€ greeter
β”‚           β”‚           β”‚   └── GreeterNotInSamePackageTest.java
β”‚           β”‚           └── GreeterTest.java
β”‚           └── resources
β”œβ”€β”€ person
β”‚   β”œβ”€β”€ build.gradle
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   └── java
β”‚       β”‚       └── com
β”‚       β”‚           └── symflower
β”‚       β”‚               └── Person.java
β”‚       └── test
β”‚           └── java
β”‚               └── com
β”‚                   └── symflower
β”‚                       └── PersonTest.java
└── settings.gradle

As in the Maven section above, you’ll notice that our structure has 2 separate Greeter implementations. Gradle also allows you to use the same package in multiple modules, so the Greeter.java implementation as introduced earlier works fine. But you can also reference subpackages without problems, so we copied Greeter.java into GreeterNotInSamePackage.java within a new package. The only other difference is the now necessary import of Person.

You can refer to the Maven section above for the exact test sources we used.

6) Run tests

We can now run our tests using Gradle.

  • To only run the tests of one subproject, navigate into the subprojects' directory (to the same level as its build.gradle is located) and run gradle cleanTest test
  • To run all tests of the project, navigate to the project directory (where the settings.gradle is located) and run gradle cleanTest test there.

The output will then show you which tests have been executed and what the results are, thanks to our added testLogging configurations. Make sure to include cleanTest into your command when running the command repeatedly to force Gradle to re-run the tests even if nothing changed since the last execution.

And that’s it! You can now extend your project with additional subprojects, sources, and tests, and use all the functionalities of Gradle either on the subprojects separately, or on the whole project at once.

How to adopt Java Platform Module System?

To follow the steps below, you’ll need to use Java version 9 or up. Use java -version to check if your CLI automatically uses a JDK version >= 9.

1) Create plain Java project

In this example, we don’t need standardized project structures but have to create our structure manually.

Replicate the following directory structure using the Greeter.java and Person.java implementations from above. You can leave the other Java files empty for now, we will go over them in the following steps.

greeter-jpms
β”œβ”€β”€ build-and-run.sh
β”œβ”€β”€ greeter
β”‚   └── src
β”‚       β”œβ”€β”€ com
β”‚       β”‚   └── symflower
β”‚       β”‚       └── greeter
β”‚       β”‚           └── Greeter.java
β”‚       └── module-info.java
β”œβ”€β”€ main
β”‚   └── src
β”‚       β”œβ”€β”€ com
β”‚       β”‚   └── symflower
β”‚       β”‚       └── sample
β”‚       β”‚           └── Main.java
β”‚       └── module-info.java
└── person
    └── src
        β”œβ”€β”€ com
        β”‚   └── symflower
        β”‚       └── Person.java
        └── module-info.java

You might also notice that in this structure, Greeter.java is located inside the package greeter. When using the JPMS, it is not allowed to export the same package multiple times, so we only use the implementation that uses a separate package. Make sure to adapt the package in Greeter.java accordingly:

package com.symflower.greeter;

import com.symflower.Person;

public class ...

2) Add a main implementation

As it will likely become very clear in the following steps, running a bare JPMS application is not so trivial, so we will skip unit testing in this example. To show that our implementation works nonetheless, we will be adding an implementation to the module main.

Main.java:

package com.symflower.sample;

import com.symflower.Person;
import com.symflower.greeter.Greeter;

public class Main {
    public static void main(String[] args) {
        Person person = new Person("Serena", "Everhart");
        Greeter greeter = new Greeter();
        System.out.println(greeter.Greet(person));
    }
}

3) Add module-info.java to each source directory

So far, nothing we did was particularly special and you could just use the source code like you do using the Java classpath. What enables us to use the JPMS is the addition of the module-info.java files and using the modulepath.

The content of a module-info.java can start very simple. You’ll just need to declare the module, any exports or requires are optional. You’ll need to create a module-info.java for every module you want to specify, so in our example, we will need 3 of them. You can add as many packages as you want to a module, but can’t distribute a package across multiple modules.

Let’s look at our module-info.java files.

person/src/module-info.java:

module person {
    exports com.symflower;
}

In this module declaration, we are naming our module person and specify that it exports the public symbols of its package com.symflower so that it is accessible from other modules. Any package not exported in any way is only accessible within the module. This also affects access via reflection, which is the key difference to not using the JPMS.

greeter/src/module-info.java:

module greeter {
    requires person;
    exports com.symflower.greeter;
}

As Greeter has a dependency on Person, the module declaration has to declare that dependency on a module level. After specifying the requires property, public symbols that were exported by the module person can now be used within greeter. The module greeter also exports a package, com.symflower.greeter. Note that it would not be possible for greeter to export the package com.symflower, because that is already exported by the module person. Trying to export the same package in multiple different modules will lead to build errors.

main/src/module-info.java:

module main {
    requires person;
    requires greeter;
}

Our main module only consumes other modules, so we don’t need to export anything. But we need to declare dependencies with both modules used in our source code.

4) Build modules one by one with javac

Because we need to take care of the compilation ourselves, it’s recommended to create a script that collects the necessary build commands.

First, we need to compile our modules person and greeter using the following commands:

javac -d person/out --module-source-path "./*/src" $(find person -name "*.java")

javac -d greeter/out --module-source-path "./*/src" $(find greeter -name "*.java")

If the module-source-path does not match the name in the module-info.java, like in our structure, the module-source-path has to contain a placeholder (*) for it to be matched correctly. The find command at the end of the commands replaces having to manually specify all source files for the module. Instead, we’ll need to list all source files and module-info.java. The option -d allows us to specify where the generated class files are placed (which are also the binary format of our module).

5) Build main application with javac

After making sure our modules are available in binary format, we can move on to building our main module.

Use the following command:

javac --module-source-path "./*/src" --module-path person/out:greeter/out -d main/out $(find main -name "*.java")

The main application needs the information where the binaries for the modules are located, specified with the module-path. This matches what we specified as the output directory in the commands before. Apart from that, the compilation is the same as for the other modules.

6) Run the main application with java

After generating the binaries for all our sources, we can finally run our example. We do that by using:

java --module-path person/out:greeter/out:main/out --module main/com.symflower.sample.Main

Let’s break down this last command:

  • --module-path person/out:greeter/out:main/out: Just as with javac, this tells Java where to find the compiled module files (*.class) for our modules. In this case, it specifies the three directories person/out, greeter/out, and main/out. The Java runtime will search these directories for the required module files.
  • --module main/com.symflower.sample.Main: This option specifies the main module and the main class to execute. It tells Java to run the main class com.symflower.sample.Main from the module main. When launching a modularized application, you specify the main module and main class using this syntax.

As the last few steps show, the JPMS does require specific CLI options for compiling and executing the code, but in contrast to module systems in Maven or Gradle, the JPMS does not provide automation for compilation and execution. Because of that, trying to run JUnit tests without the help of Maven or Gradle requires managing all the required dependencies in the CLI by yourself.

The option --module-path replaces the usage of --class-path. When encountering a project with module declarations, you can make Java ignore those by deliberately using --class-path instead. This allows you to circumvent any issues potentially caused by insufficient access to symbols within the modules.

However, choosing not to circumvent the module system while testing and doing “module-aware” testing should be preferred. That way, any bugs caused by insufficient access due to the module declaration can already be caught in the unit tests.

Combining JPMS with Maven

The commands necessary to run our JPMS example show that building and running Java applications using the Java Platform Module System via the CLI is not sensible and scalable. Usually, Gradle or Maven are used to let them take over managing the build process and take care of dependencies on external modules. This allows for larger-scale applications with the JPMS and also helps navigate the intricacies of module-aware testing.

Nowadays, both Gradle and Maven do properly support the JPMS. The following example will show you how Maven helps you handle the nitty-gritty of working with JPMS.

Make sure you have a current version of Maven and a JDK >= 9 installed to follow these steps.

1) Adapt the Maven example to get started

We can use a similar structure to what we used in the plain Maven sample. Make sure you are only using the Greeter that is located in a separate package as the module definitions don’t allow exporting the same package multiple times.

Then, we need to add the module definitions in module-info.java to our implementation sources. The actual source directory is src/main/java so we’ll have to place them there. We do not need module definitions for the unit tests. We will see later why that is the case.

greeter-maven-jpms
β”œβ”€β”€ greeter
β”‚   β”œβ”€β”€ pom.xml
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   β”œβ”€β”€ java
β”‚       β”‚   β”‚   β”œβ”€β”€ com
β”‚       β”‚   β”‚   β”‚   └── symflower
β”‚       β”‚   β”‚   β”‚       └── greeter
β”‚       β”‚   β”‚   β”‚           └── Greeter.java
β”‚       β”‚   β”‚   └── module-info.java
β”‚       β”‚   └── resources
β”‚       └── test
β”‚           └── java
β”‚               └── com
β”‚                   └── symflower
β”‚                       └── greeter
β”‚                           └── GreeterTest.java
β”œβ”€β”€ person
β”‚   β”œβ”€β”€ pom.xml
β”‚   └── src
β”‚       β”œβ”€β”€ main
β”‚       β”‚   β”œβ”€β”€ java
β”‚       β”‚   β”‚   β”œβ”€β”€ com
β”‚       β”‚   β”‚   β”‚   └── symflower
β”‚       β”‚   β”‚   β”‚       └── Person.java
β”‚       β”‚   β”‚   └── module-info.java
β”‚       β”‚   └── resources
β”‚       └── test
β”‚           └── java
β”‚               └── com
β”‚                   └── symflower
β”‚                       ── PersonTest.java
└── pom.xml

However, we do need to make sure that the plugins used by Maven are up-to-date so they properly handle modules. To do that, we specify a current version of the maven-compiler-plugin for compiling as well as the maven-surefire-plugin for running the tests in the aggregator POM.

/pom.xml:

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.0.0-M8</version>
        </plugin>
    </plugins>
</build>

2) Prepare the correct module descriptors

The content of the module descriptors is identical to the JPMS example, only their locations have changed:

greeter/src/main/java/module-info.java:

module greeter {
    exports com.symflower.greeter;
    requires person;
}

person/src/main/java/module-info.java:

module person {
    exports com.symflower;
}

3) Maven takes care of the rest

Now you can already compile the code and run the tests using Maven. Run mvn test and your tests should be executed successfully!

If you run mvn -X test, you get additional output including the exact arguments that Maven provides for Java. This tells you how Maven manages to run the tests and why you don’t need module descriptors for your unit test.

You’ll find lines containing [DEBUG] argos file content:, after which you can find the arguments to run the tests. Some of the important ones are:

  • --module-path contains the location of the compiled modules we defined, just as in our JPMS example.
  • --class-path contains the library dependencies needed for test execution such as the JUnit dependencies. The arguments following it make it possible for these libraries to interact with our modules.
  • --patch-module extends the module with our test sources that we didn’t put into any module. This allows them to access everything within the module while allowing us to separate test code from implementation code.
  • --add-opens greeter/com.symflower.greeter=ALL-UNNAMED then opens the module up to all libraries in the classpath for this execution only. This is what allows JUnit to access all necessary symbols without having to compromise the encapsulation during the remaining usage of the modules.

Modules in Java: why should you use them and which one should you use?

Using one of the module concepts available for Java allows you to modularize your application. Doing that gives you several benefits:

  • Structuring the code into smaller, independent chunks where code, tests, and resources can be easily assigned to each other.
  • Improving reusability when all necessary resources and the build instructions of a module are separated from other modules.
  • Easier maintainability because dependencies between parts of the project are explicitly defined.
  • Enables the use of tools to manage dependencies when you are choosing modules instead of making your components fully independent libraries. These tools can, for example, take care of the correct compile order and offer you one command for compiling all modules at once.

Set up new projects with modules to access these benefits immediately, and be more flexible as your application grows.

If you already use either Gradle or Maven in your projects, this dictates which of those two module systems you should use. We suggest you start using one of those so that you will have taken care of build and dependency management for your modularized application.

If you want to use another module system because of the specific benefits it offers, we suggest using it in addition to the build management, not on its own. For example, if you want to use the JPMS to be able to minimize a bundled JRE, we recommend using it in combination with Gradle or Maven and their respective module systems.

πŸ€” What are the top Java unit testing frameworks & tools in 2024?

Check out our post for a comparison of JUnit and TestNG and a list of some useful Java tools!

Make sure you never miss any of our upcoming content by signing up for our newsletter and by following us on Twitter, LinkedIn or Facebook!

| 2024-06-10