Modularization the easy way: Spring Modulith with Kotlin and Hexagonal Architecture
Modularization is a key concept in modern software development to make applications maintainable, testable and flexible. In this article we will see how Spring Modulith combined with hexagonal architecture and Kotlin provides a solid foundation for modularization. We will examine the challenges of existing monolithic applications and how clearly defined modules enable step-by-step decoupling.
What is a Modulith?
The name says it all. A Modulith refers to an architecture where a monolith is divided into clearly defined modules. These modules are designed to be as decoupled from each other as possible while minimizing internal dependencies. You can think of it as bundling multiple microservices into a single repository and managing them as one application. For example one module could manage user data and another could handle invoicing. Both modules running in the same application but remaining distinctly separated. The advantage is clear: there is no need for complex HTTP interfaces or Kafka brokers, no authentication for inter-module communication and yet the benefit of loose coupling is maintained.
Modularization can therefore be seen as a gentle introduction to the microservices world.
What Does Spring Modulith Do?
To divide an application into modules, there are various options. One approach is simply to organize the code into different packages. Another is to use Maven or Gradle to create modules with their own dependencies and build scripts. Additionally splitting an application into microservices is another form of modularization.
When comparing these three options it becomes apparent that their implementation effort increases progressively. While packages are easy to organize, Maven/Gradle modules already require separate dependency definitions. Deploying microservices and enabling their communication with each other adds even more complexity.
We actually want to start with packages initially. But here we run into a problem quickly. Imagine you have a legacy application where one domain model accesses another and vice versa. Even if you group logically related classes into packages it may look like different modules but it’s not really the case.
We aim for loose coupling. Ideally, we should ne able to extract a module later and deploy it as a microservice. In that sense, we want minimal coupling which reduces refactoring effort.
Unfortunately, this coupling is hard to detect as it often only becomes evident through detailed code analysis or when observing unexpected application behavior. In the worst case, it only becomes apparent when trying to extract a module and deploy it as a standalone microservice. Such coupling often hides in subtle interdependencies between modules arising from direct accesses. Even if you clean up and decouple classes in one big action, coupling will creep back during further development.
This is where Spring Modulith helps. It checks whether you have dependencies between modules that are not allowed.
Basics
Let’s assume we have the following structure:
1src
2├── Application.kt
3├── module-1
4│ ├── ModuleOneInterface.kt
5│ └── ModuleOneService.kt
6└── module-2
7 ├── ModuleTwoInterface.kt
8 └── ModuleTwoService.kt
In our src
package, there is an Application.kt
and alongside it two packages: module-1
and module-2
. Each module contains an interface and a service that implements the interface.
The first thing Spring Modulith does is to define all packages at the Application.kt
level. This happens automatically without requiring any additional configuration. All classes and interfaces in these packages are by default considered public by Spring Modulith allowing other modules to access them.
Now, we don’t want ModuleOneService
to directly access ModuleTwoService
and vice versa; access should only be possible through the interfaces. To achieve this, we simply place the services in a subpackage:
1src
2├── Application.kt
3├── module-1
4│ ├── ModuleOneInterface.kt
5│ └── services
6│ └── ModuleOneService.kt
7└── module-2
8 ├── ModuleTwoInterface.kt
9 └── services
10 └── ModuleTwoService.kt
If one service now tries to access another one, the Spring Modulith-Test will fail.
Spring Modulith Test
Since we’ve mentioned the test let’s briefly explore what these tests look like. Spring Modulith provides three main “test”-functionalities. First, there’s the actual test that analyzes our modules and verifies if we maintain the desired level of loose coupling.
1@Test
2fun `verifies modular structure`() {
3 val modules = ApplicationModules.of(SpringModulithApplication::class.java)
4 modules.verify()
5}
Next we have the option to inspect the modules and their definitions. This outputs the key contents of each module such as NamedInterfaces
, which we’ll discuss shortly.
1@Test
2fun `print module structure`() {
3 val modules = ApplicationModules.of(SpringModulithApplication::class.java)
4 modules.forEach(Consumer { module: ApplicationModule? -> println(module) })
5}
Also Spring Modulith can generate a documentation. This includes various PlantUML diagrams of the modules and their relationships as well as AsciiDoc documentation with diagrams and all publicly exposed classes and interfaces.
1@Test
2fun `create module documentation`() {
3 val modules = ApplicationModules.of(SpringModulithApplication::class.java)
4 Documenter(modules)
5 .writeDocumentation()
6 .writeIndividualModulesAsPlantUml()
7}
Hexagonal Architecture
Rarely will our modules have simple structures like the example above. Especially when working with an architectural style like hexagonal architecture. There we have many sub-packages. Some of which we may want to expose externally. Such a structure might look like this:
1src
2├── Application.kt
3├── module-1
4│ ├── adapter
5│ │ ├── inbound
6│ │ └── outbound
7│ ├── domain
8│ │ ├── services
9│ │ │ └── ModuleOneService.kt
10│ │ ├── model
11│ │ └── ports
12│ │ ├── inbound
13│ │ │ └── ModuleOneInterface.kt
14│ │ └── outbound
15│ └── services
16└── module-2
17 ├── adapter
18 │ ├── inbound
19 │ └── outbound
20 └── domain
21 ├── services
22 │ └── ModuleTwoService.kt
23 ├── model
24 └── ports
25 ├── inbound
26 │ └── ModuleTwoInterface.kt
27 └── outbound
Services still cannot access other services as they are in a subpackage. However, they also cannot access the interfaces as they are now in a subpackage as well. Here, we must explicitly define that for instance, the module-1.domain.ports.inbound
package should be externally accessible. In Java, this is done by creating a package-info.java
file in the package.
In Kotlin this approach doesn’t work. Instead, we create a class and annotate it. For example, we create a ModuleMetadata.kt
file and place it in the respective package. The class name is irrelevant. You could name it Drotbohm.kt
or SantaClaus.kt
. The content is what matters.
1└── ports
2 ├── inbound
3 │ ├── ModuleOneInterface.kt
4 │ └── ModuleMetadata.kt
Here are two annotations: @PackageInfo
and @NamedInterface
.
1@PackageInfo
2@NamedInterface(name = ["inbound-ports"])
3class ModuleMetadata
@PackageInfo
is unnecessary in the Java version since it is implicitly provided by the file type. In Kotlin it is required for Spring Modulith to recognize it as the package declaration file.
@NamedInterface
defines the name under which the package will be recognized by Spring Modulith. This annotation is also required but you can omit the name parameter. In this case the package name will be used. If you want to specify a name explicitly you must use named parameters and define it as an array.
The name is particularly important in hexagonal architecture. Looking at the project tree above we see two packages named inbound
, one in the domain and one in the adapters. Without a specified name both packages are exposed as they share the same name.
Explicit Module Access
Imagine we have several modules in our application, each exposing some packages to other modules.
Now any module can access all exposed packages from all other modules. Thats not what we want. Instead, we want to expose a package only for specific modules. This can also be defined.
Suppose we have module-1
, module-2
, and module-3
. Each has some exposed packages. We want module-1
to access only the packages from module-2
and within module-2
only the inbound-port
package.
To achieve this we create a ModuleMetadata.kt
file at the top level of module-1
:
1└── module-1 2 ├── ModuleMetadata.kt 3 ├── adapter 4 │ ├── ... 5 ├── domain 6 ├── ...
In ModuleMetadata.kt
we add the @ApplicationModule
annotation. Here we can declare the modules and packages the module is allowed to access using allowedDependencies
. The schema is Module :: Package
. The package name is either the name of the package containing the ModuleMetadata.kt
of the other module or the name defined as a NamedInterface in the ModuleMetadata.kt
.
1@ApplicationModule(
2 allowedDependencies = ["module-2 :: inbound-port"]
3)
4class ModuleMetadata
If you want to allow additional modules and/or NamedInterfaces, you can list them separated by commas. If you wish to allow a module but impose no restrictions on individual packages, you can use an asterisk (*
): allowedDependencies = ["module-2 :: *"]
.
Legacy Application
Now that we know how to expose specific packages and explicitly declare module access, let’s apply this to a legacy application. In this case the list of errors in the Spring Modulith-Test might be long. Fixing everything at once is often impractical.
However, Spring Modulith offers an option to support modularizing legacy applications.
In the module definition, where you’ve added allowedDependencies
, you can also add a type
parameter. This allows you to make the entire module public, meaning all packages are accessible to all other modules even without Module-Metadata definitions.
1@ApplicationModule(
2 type = ApplicationModule.Type.OPEN,
3 allowedDependencies = ["module-1 :: inbound-port"]
4)
5class ModuleMetadata
This makes it possible to create modules with their package structures while maintaining strong coupling temporarily. Your tests remain green, commits can proceed and your pipeline continues to run. This enables gradual decoupling of modules. However this approach should only remain until the module is being decoupled. Once the module is decoupled the type can be set to CLOSED or removed from the @ApplicationModule
annotation.
Conclusion
A Modulith is a monolith that is divided into clearly defined modules to achieve loose coupling and improved maintainability. While these modules run in a single application, they are structured so that they operate largely independently. Particularly for legacy applications with many tangled dependencies, this modular concept provides a gradual transition toward a more decoupled architecture.
Spring Modulith supports this approach by building on packaging within a Spring application and automatically detecting modules. By default, all packages of a module are public, but special “metadata” files can block unwanted dependencies or make only certain areas (such as interfaces) public. This ensures that access between modules only occurs if it is explicitly allowed.
To verify the modular structure, Spring Modulith provides three key test functions:
- Verification: A test that checks whether all defined module boundaries are respected.
- Module Overview: An output displaying the detected modules and their dependencies.
- Documentation: Automated generation of AsciiDoc files and PlantUML diagrams.
Especially in more complex structures, such as those found in hexagonal architecture (Ports & Adapters), services, interfaces and adapter layers are often split into multiple sub-packages. In Kotlin you mark the public areas with a special class (e.g., ModuleMetadata.kt
) that Spring Modulith recognizes through the @PackageInfo
and @NamedInterface
annotations. Additionally, you can explicitly control how other modules access these areas by using @ApplicationModule(allowedDependencies = […])
.
For legacy systems that do not allow an immediate separation into “clean” modules, individual modules can also be declared as open (type = ApplicationModule.Type.OPEN
). This way existing dependencies remain in place for the time being. You can proceed step by step: first defining modules only roughly and then gradually restricting certain parts until a solid modularization is achieved.
With these building blocks, you have everything you need to get started with modularization using Spring Modulith.
Beyond these features, Spring Modulith also offers other functionalities, such as Application Events—another exciting aspect worth exploring.
A small application where we have implemented some of these aspects can be found here: https://github.com/darthkali/spring-modulith-hex-test.
More articles
fromDanny Steinbrecher
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog author
Danny Steinbrecher
IT-Consultant & Developer
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.