Separating contract and implementation
The previous article showed how to hide implementation details using internal packages.
The core module still mixes two concerns, though: it exports both the domain model (Document, Statistics) and the service implementation (TextAnalyzer).
Consumers that only need the data types must depend on the entire implementation.
Java Modules provide an elegant solution: separate the domain types into their own module and use requires transitive to keep things convenient.
The problem: Mixed concerns
After the previous article, the core module looks like this:
module net.aschemann.maven.demos.analyzer.core {
requires org.apache.logging.log4j;
exports net.aschemann.maven.demos.analyzer.core.model;
exports net.aschemann.maven.demos.analyzer.core.service;
}
The module exports both model — domain types — and service — implementation.
Any module that needs Document or Statistics must depend on core and transitively pulls in Log4j and the internal implementation.
This represents a common antipattern in modular design: mixing the contract — what the module promises — with the implementation — how it fulfills that promise.
Introducing a contract module
The solution is a classic layering pattern: extract the domain types into a dedicated API module. The implementation module then depends on the API and provides concrete service classes.
The project now has three Java modules:
- analyzer.api
-
Pure contract — domain types, no dependencies
- analyzer.core
-
Implementation — depends on API transitively, provides service classes
- analyzer.cli
-
Consumer — depends on core, gains access to API types automatically via
requires transitive
The contract module
The new module contains the domain records Document and Statistics, moved from core.model.
Module descriptor
module net.aschemann.maven.demos.analyzer.api {
exports net.aschemann.maven.demos.analyzer.api; (1)
}
| 1 | Single export — all API types live in one package |
The API module has no dependencies. It is a pure contract that any module can depend on without pulling in implementation details.
The Document and Statistics types moved from net.aschemann.maven.demos.analyzer.core.model to net.aschemann.maven.demos.analyzer.api.
|
Moving
DocumentReader logic into DocumentIn the first and previous articles, a separate A pragmatic solution: move the file-reading logic into
This keeps the API module self-contained without introducing additional classes or service interfaces. One trade-off: the old |
The updated core module
The core module now uses the API types rather than defining them.
Module descriptor
module net.aschemann.maven.demos.analyzer.core {
requires transitive net.aschemann.maven.demos.analyzer.api; (1)
requires org.apache.logging.log4j; (2)
exports net.aschemann.maven.demos.analyzer.core.service; (3)
// Note: net.aschemann.maven.demos.analyzer.core.internal is NOT exported (4)
}
| 1 | requires transitive — any module that requires core automatically reads api |
| 2 | Log4j is an implementation detail, required but not transitive |
| 3 | Only the service package is exported |
| 4 | The internal package remains encapsulated |
The key change is requires transitive net.aschemann.maven.demos.analyzer.api.
This means the API types appear in core’s exported signatures — TextAnalyzer.analyze(Document) returns Statistics — so consumers of core automatically need access to the API module.
The transitive keyword makes this explicit and automatic.
The TextAnalyzer class itself is unchanged — it still delegates to the internal TextNormalizer encapsulated in the previous article.
Only its imports changed from core.model.Document to api.Document, and likewise for Statistics.
How requires transitive works
The command-line module’s descriptor has not changed from the previous article:
module net.aschemann.maven.demos.analyzer.cli {
requires net.aschemann.maven.demos.analyzer.core;
requires info.picocli;
requires org.apache.logging.log4j;
opens net.aschemann.maven.demos.analyzer.cli to info.picocli;
}
The command-line module declares requires net.aschemann.maven.demos.analyzer.core — and because core declares requires transitive net.aschemann.maven.demos.analyzer.api, it can use Document and Statistics without an explicit requires api directive.
This is called implied readability: the transitive keyword propagates the dependency through the module graph.
Without transitive, the command-line module would need to declare:
module net.aschemann.maven.demos.analyzer.cli {
requires net.aschemann.maven.demos.analyzer.core;
requires net.aschemann.maven.demos.analyzer.api; (1)
requires info.picocli;
requires org.apache.logging.log4j;
opens net.aschemann.maven.demos.analyzer.cli to info.picocli;
}
| 1 | Would be required without transitive on core’s dependency |
What breaks without transitive?
If you remove the transitive keyword from core’s module-info.java:
module net.aschemann.maven.demos.analyzer.core {
requires net.aschemann.maven.demos.analyzer.api; // no transitive!
// ...
}
The command-line module will fail to compile:
error: package net.aschemann.maven.demos.analyzer.api is not visible
(package net.aschemann.maven.demos.analyzer.api is declared in module
net.aschemann.maven.demos.analyzer.api, which is not in the module graph)
The compiler tells you exactly what’s wrong: the API module is not in the command-line module’s graph because core no longer transitively exports it.
The updated project structure
With three modules, the directory structure looks like this:
pom.xml (1)
src/
├── net.aschemann.maven.demos.analyzer.api/ (2)
│ └── main/java/
│ ├── module-info.java
│ └── net/aschemann/maven/demos/analyzer/api/
│ ├── Document.java
│ └── Statistics.java
├── net.aschemann.maven.demos.analyzer.core/ (3)
│ └── main/java/
│ ├── module-info.java
│ └── net/aschemann/maven/demos/analyzer/core/
│ ├── internal/
│ │ └── TextNormalizer.java
│ └── service/
│ └── TextAnalyzer.java
└── net.aschemann.maven.demos.analyzer.cli/ (4)
└── main/java/
├── module-info.java
└── net/aschemann/maven/demos/analyzer/cli/
└── AnalyzerCommand.java
| 1 | Still one and only Maven POM — now with three Java modules declared via <sources>.
No extra per-module POMs even as the project grows. |
| 2 | API module — domain types, no dependencies |
| 3 | Core module — implementation, depends on API transitively |
| 4 | Command-line module — consumer, unchanged module descriptor |
Updated POM configuration
The Maven POM now declares three module sources:
<sources>
<source>
<module>net.aschemann.maven.demos.analyzer.api</module> (1)
</source>
<source>
<module>net.aschemann.maven.demos.analyzer.core</module> (2)
</source>
<source>
<module>net.aschemann.maven.demos.analyzer.cli</module> (3)
</source>
</sources>
| 1 | The API module — domain types |
| 2 | The core module — implementation |
| 3 | The command-line module |
Maven compiles them in dependency order: api first — no dependencies — then core — depends on api — then cli — depends on core.
Source Code
The above changes are committed to the sample source code repository on GitHub.
Clone it and switch to branch blog-3-api-impl:
git clone https://github.com/aschemaven/maven-modular-sources-showcases # unless already done
cd maven-modular-sources-showcases
git checkout blog-3-api-impl
Building and running
As described in the first article, compile and prepare the dependencies:
./mvnw prepare-package
Then run the application:
java --module-path "target/classes:target/lib" \
--module net.aschemann.maven.demos.analyzer.cli/net.aschemann.maven.demos.analyzer.cli.AnalyzerCommand \
README.*
The output is unchanged from the previous article — the API extraction is an internal restructuring that does not affect runtime behavior.
Summary
This article covered:
-
How to separate domain types into a dedicated API module
-
The
requires transitivedirective provides implied readability — consumers of core automatically get access to API types -
Domain types (
Document,Statistics) belong in the API module — along with file-reading logic viaDocument.fromPath() -
Service classes (
TextAnalyzer) remain in the core module -
The command-line module’s descriptor is unchanged —
requires transitivehandles the wiring
This separation brings a clear architectural benefit: any future module can depend on just the API without pulling in the implementation.
However, you may have noticed that the command-line module still directly depends on the core module to instantiate TextAnalyzer.
The next article addresses this by introducing the Service Provider Interface pattern.
Using uses, provides, and ServiceLoader, the command-line module will depend only on the API module — achieving true inversion of control where the consumer no longer needs to know the implementation at all.
Homework
- Remove
transitiveand fix the build -
Remove the
transitivekeyword from core’srequires apideclaration and observe the compilation error. Then add an explicitrequires net.aschemann.maven.demos.analyzer.api;to the command-line module to fix it. Which approach do you prefer, and why? - Add a second consumer module
-
Create a test module that imports only
DocumentandStatisticsfrom the API. Does it need to depend on core? What happens if it does — does it also get access toTextAnalyzer? - Preview: Inversion of Control
-
Right now the command-line module still instantiates
new TextAnalyzer(…)directly, coupling it to the implementation. Can you imagine a way to discover the analyzer at runtime so the command-line module only needsrequires api? The next article explores this withServiceLoader.
Apache Maven and Maven are trademarks of the Apache Software Foundation.