Design strategy for Java module decoupling

Java Platform Module System (JPMS) provides stronger encapsulation, higher reliability and better separation of concerns, which some students may not notice.

However, there are pros and cons. Because modular applications are built on a network of modules that rely on other modules to work properly, in many cases the modules are tightly coupled to each other.

This might lead us to think that modularity and loose coupling are properties that cannot coexist in the same system. But, they can!

Next, let's delve into two well-known design patterns that we can use to easily decouple Java modules.

1. Create a project

Let's make a multi-module Maven project to demonstrate.

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

The first module will include a service interface and two implementations - service providers.

The second module will use the provider to parse the string value.

Let's create a project root directory named Demoproject, and then 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.8.1</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

2. Service module

For demonstration purposes, let's use a quick and dirty approach to implementing the servicemodule module so that we can clearly spot the flaws in this design.

Let's make the service interface and service provider public, put them in the same package and export them all. This seems like a pretty good design choice, but as we'll see later, it greatly increases the degree of coupling between project modules.

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

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

The TextService interface is very simple, then we define the service provider.

In the same package, we 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, in the servicemodule/src/main/java directory, include the module descriptor module-info.java:

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

3. Consumer module

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

Let's add the 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 in the source root directory, which should be consumermodule/src/main/java:

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

Finally, run it.

But there is an important caveat worth noting: we are unnecessarily coupling the service provider to the consumer module .

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

Furthermore, this discourages making software components dependent on abstractions.

4. Service provider factory

We can easily remove the coupling between modules by exporting only the service interface. In contrast, service providers are not exported and therefore remain hidden from consumer modules. Consumer modules can only see service interface types.

To achieve this we need:

1. Put the service interface in a separate package and export it to the outside world.

2. Place the service provider in a different package and the package will not be exported.

3. Create a factory class and export it. Consumer modules use factory classes to find service providers

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

4.1 Public service interface

To clearly understand how this pattern works, let's put the service interface and service provider in different packages. The interface will be exported, but the provider implementation will not.

So let's move the TextService to a new package called com.baeldung.servicemodule.external.

4.2 Private server provider

Then, we also move LowercaseTextService and UppercaseTextService to com.baeldung.servicemodule.internal.

4.3 Public Service Provider Factory

Since the service provider class is now private and cannot be accessed from other modules, we will use a public factory class to provide a simple mechanism that consumer modules can use to obtain an instance of the service provider.

In the com.baeldung.servicemodule.external package, we define the TextServiceFactory class:

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

Of course, we can make the factory class a little more complex. For simplicity, the service provider is simply created based on the string value passed to the getTextService() method.

Now, let's replace the module-info.java file to export only external packages:

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

Note that we only export the service interface and factory class. These implementations are private, so they are not visible to other modules.

4.4 Application category

Now, let's refactor the Application class so that 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!"));
}

Then, run it.

By making the service interface public and the service provider private, we can effectively decouple the service and consumer modules through a simple factory class.

Of course, no model is a panacea. As always, we should first analyze whether our use case is suitable.

5. Service and consumer modules

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

Therefore, we can use this feature to decouple modules without creating additional factory classes.

In order for the service and consumer modules to work together, we need to do the following:

1. Place the service interface in the module, and the module exports the interface

2. Place the service provider in another module - the provider is exported

3. Specify in the provider's module descriptor that we want to provide a TextService implementation using the provides...with directive

4. Place the Application class in its own module - the consumer module

5. Specify in the module descriptor of the consumer module that the module is a consumer module with usage instructions.

6. Use the Service Loader API in the consumer module to find service providers

This approach is very powerful because it takes advantage of all the functionality brought by the service and consumer modules. But it's also a little tricky.

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

5.1 Parent module

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

Since the service interface, service provider and consumer will now be in different modules, we first need to modify the parent POM's sections to reflect this new structure:

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

5.2 Service module

Our TextService interface will return com.baeldung.servicemodule.

We will change the module descriptor accordingly:

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

5.3 Provider module

As mentioned before, the provider module is used for our implementation, so now let's put LowerCaseTextService and UppercaseTextService here. We put them into a package called com.baeldung.providermodule.

Finally, we add a module-info.java file:

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

5.4 Consumer module

Now, let's refactor the consumer module. First, we put the application back into the com.baeldung.consumermodule package.

Next, we'll refactor the Application class's main() method so that 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 will refactor the module-info.java file:


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

Then, run it!

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

Consumer modules rely on abstractions and can easily plug in different service providers at runtime.

6. Summary

We learned how to implement two patterns to decouple Java modules.

Both approaches make the consumer module dependent on abstractions, which is always a desired functionality in the design of software components.

Of course, each has its advantages and disadvantages. For the first one, we get nice decoupling, but we have to create an additional factory class.

For the second one, in order to decouple the modules, we have to create an additional abstract module and add a new level of indirection using the Service Loader API.

Guess you like

Origin blog.csdn.net/qq_41340258/article/details/132331129