Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

The Java Platform Module System (JPMS) provides stronger encapsulation, more reliability and better separation of concerns.

But all these handy features come at a price. Since modularized applications are built upon a network of modules that depend on other modules to properly work, in many cases, the modules are tightly-coupled to each other.

This might lead us to think that modularity and loose-coupling are features that just can’t co-exist in the same system. But actually, they can!

In this tutorial, we’ll look in depth at two well-known design patterns that we can use for easily decoupling Java modules.

2. The Parent Module

To showcase the design patterns that we’ll use for decoupling Java modules, we’ll build a demo multi-module Maven project.

To keep the code simple, the project will contain initially two Maven modules, and each Maven module will be wrapped into a Java module.

The first module will include a service interface, along with two implementations – the service providers. The second module will use the providers for parsing a String value.

Let’s start by creating the project’s root directory named demoproject, and we’ll define the project’s parent POM:

<packaging>pom</packaging>

<modules>
    <module>servicemodule</module>
    <module>consumermodule</module>
</modules>
    
<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.12.1</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

There are a few details worth stressing in the definition of the parent POM.

First off, the file includes the two child modules that we mentioned above, namely servicemodule and consumermodule (we’ll discuss them in detail later).

Next, since we’re using Java 11, we’ll need at least Maven 3.5.0 on our system, as Maven supports Java 9 and higher from that version onward.

Finally, we’ll also need at least version 3.8.0 of the Maven compiler plugin. So, to make sure we’re up to date we’ll check Maven Central for the latest version of the Maven compiler plugin.

3. The Service Module

For demo purposes, let’s use a quick-and-dirty approach to implement the servicemodule module, so we can clearly spot the flaws that arise with this design.

Let’s make the service interface and the service providers public, by placing them in the same package and by exporting all of them. This seems to be a fairly good design choice, but as we’ll see in a moment, it highly increases the level of coupling between the project’s modules.

Under the project’s root directory, we’ll create the servicemodule/src/main/java directory. Then, we need to define the package com.baeldung.servicemodule, and place in it the following TextService interface:

public interface TextService {
    
    String processText(String text);
    
}

The TextService interface is really simple, so let’s now define the service providers.

In the same package, let’s add a Lowercase implementation:

public class LowercaseTextService implements TextService {

    @Override
    public String processText(String text) {
        return text.toLowerCase();
    }
    
}

Now, let’s add an Uppercase implementation:

public class UppercaseTextService implements TextService {
    
    @Override
    public String processText(String text) {
        return text.toUpperCase();
    }
    
}

Finally, under the servicemodule/src/main/java directory, let’s include the module descriptor, module-info.java:

module com.baeldung.servicemodule {
    exports com.baeldung.servicemodule;
}

4. The Consumer Module

Now we need to create a consumer module that uses one of the service providers that we created before.

Let’s add the following com.baeldung.consumermodule.Application class:

public class Application {
    public static void main(String args[]) {
        TextService textService = new LowercaseTextService();
        System.out.println(textService.processText("Hello from Baeldung!"));
    }
}

Now, let’s include the module descriptor, module-info.java, at the source root, which ought to be consumermodule/src/main/java:

module com.baeldung.consumermodule {
    requires com.baeldung.servicemodule;
}

Finally, let’s compile the source files and run the application, either from within our IDE or from a command console.

As we might expect, we should see the following output:

hello from baeldung!

Definitively this works, but with one important caveat worth noting: we’re unnecessarily coupling the service providers to the consumer module.

Since we’re making the providers visible to the outside world, consumer modules are aware of them.

Moreover, this fights against making software components depend on abstractions.

5. Service Provider Factory

We can easily remove the coupling between the modules by exporting only the service interface. By contrast, the service providers are not exported, thus remaining hidden from the consumer modules. The consumer modules only see the service interface type.

To accomplish this, we need to:

  1. Place the service interface in a separate package, which is exported to the outside world
  2. Place the service providers in a different package, which is not exported
  3. Create a factory class, which is exported. The consumer modules use the factory class to lookup the service providers

We can conceptualize the steps above in the form of a design pattern: public service interface, private service providers, and public service provider factory.

5.1. Public Service Interface

To clearly see how this pattern works, let’s place the service interface and the service providers in different packages. The interface will be exported, but the provider implementations won’t.

So, let’s move TextService to a new package we’ll call com.baeldung.servicemodule.external.

5.2. Private Service Providers

Then, let’s similarly move our LowercaseTextService and UppercaseTextService to com.baeldung.servicemodule.internal.

5.3. Public Service Provider Factory

Since the service provider classes are now private and can’t be accessed from other modules, we’ll use a public factory class to provide a simple mechanism that consumer modules can use for getting instances of the service providers.

In the com.baeldung.servicemodule.external package, let’s define the following TextServiceFactory class:

public class TextServiceFactory {
    
    private TextServiceFactory() {}
    
    public static TextService getTextService(String name) {
        return name.equalsIgnoreCase("lowercase") ? new LowercaseTextService(): new UppercaseTextService();
    }
    
}

Of course, we could have made the factory class slightly more complex. To keep things simple though, the service providers are simply created based on the String value passed to the getTextService() method.

Now, let’s replace our module-info.java file to export only our external package:

module com.baeldung.servicemodule {
    exports com.baeldung.servicemodule.external;
}

Notice that we’re only exporting the service interface and the factory class. The implementations are private, hence they’re not visible to other modules.

5.4. The Application Class

Now, let’s refactor the Application class, so it can use the service provider factory class:

public static void main(String args[]) {
    TextService textService = TextServiceFactory.getTextService("lowercase");
    System.out.println(textService.processText("Hello from Baeldung!"));
}

As expected, if we run the application, we should see the same text printed out to the console:

hello from baeldung!

By making the service interface public and the service providers private effectively allowed us to decouple the service and the consumer modules via a simple factory class.

No pattern is a silver bullet, of course. As always, we should first analyze our use case for fit.

6. Service and Consumer Modules

The JPMS provides support for service and consumer modules out of the box, through the provides…with and uses directives.

Therefore, we can use this functionality for decoupling modules, without having to create additional factory classes.

To put service and consumer modules to work together, we need to do the following:

  1. Place the service interface in a module, which exports the interface
  2. Place the service providers in another module – the providers are exported
  3. Specify in the provider’s module descriptor that we want to provide a TextService implementation with the provides…with directive
  4. Place the Application class in its own module – the consumer module
  5. Specify in the consumer module’s module descriptor that the module is a consumer module with the uses directive
  6. Use the Service Loader API in the consumer module to lookup the service providers

This approach is very powerful as it leverages all the functionality that service and consumer modules bring to the table. But it’s somewhat tricky too.

On the one hand, we make the consumer modules depend only on the service interface, not on the service providers. On the other hand, we can even not define service providers at all, and the application will still compile.

6.1. The Parent Module

To implement this pattern, we’ll need to refactor the parent POM and the existing modules too.

Since the service interface, the service providers and the consumer will now live in different modules, we first need to modify the parent POM’s <modules> section, to reflect this new structure:

<modules>
    <module>servicemodule</module>
    <module>providermodule</module>
    <module>consumermodule</module>
</modules>

6.2. The Service Module

Our TextService interface will go back into com.baeldung.servicemodule.

And we’ll change the module descriptor accordingly:

module com.baeldung.servicemodule {
    exports com.baeldung.servicemodule;
}

6.3. The Provider Module

As stated, the provider module is for our implementations, so let’s now place LowerCaseTextService and UppercaseTextService here instead. We’ll put them in a package we’ll call com.baeldung.providermodule.

Finally, let’s add a module-info.java file:

module com.baeldung.providermodule {
    requires com.baeldung.servicemodule;
    provides com.baeldung.servicemodule.TextService with com.baeldung.providermodule.LowercaseTextService;
}

6.4. The Consumer Module

Now, let’s refactor the consumer module. First, we’ll place Application back into the com.baeldung.consumermodule package.

Next, we’ll refactor the Application class’s main() method, so it can use the ServiceLoader class to discover the appropriate implementation:

public static void main(String[] args) {
    ServiceLoader<TextService> services = ServiceLoader.load(TextService.class);
    for (final TextService service: services) {
        System.out.println("The service " + service.getClass().getSimpleName() + 
            " says: " + service.parseText("Hello from Baeldung!"));
    }
}

Finally, we’ll refactor the module-info.java file:

module com.baeldung.consumermodule {
    requires com.baeldung.servicemodule;
    uses com.baeldung.servicemodule.TextService;
}

Now, let’s run the application. As expected, we should see the following text printed out to the console:

The service LowercaseTextService says: hello from baeldung!

As we can see, implementing this pattern is slightly more complex than the one that uses a factory class. Even so, the additional effort is highly rewarded with a more flexible, loosely-coupled design.

The consumer modules depend on abstractions, and it’s also easy to drop in different service providers at runtime.

7. Conclusion

In this tutorial, we learned how to implement two patterns for decoupling Java modules.

Both approaches make the consumer modules depend on abstractions, which is always a desired feature in the design of software components.

Of course, each one has its pros and cons. With the first one, we get a nice decoupling, but we have to create an additional factory class.

With the second one, to get the modules decoupled, we have to create an additional abstraction module and add a new level of indirection with the Service Loader API.

As usual, all the examples shown in this tutorial are available on GitHub.

Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
2 Comments
Oldest
Newest
Inline Feedbacks
View all comments
Comments are closed on this article!