1. When and why to split a project into modules
Why you shouldn’t put everything in a single module
If you’re writing a small lab exercise or "Hello, World!", the module system may seem excessive. But as the project grows—dozens and hundreds of classes, many packages, third-party libraries—chaos becomes inevitable. It’s like a library without shelves: while there are few books it’s tolerable, but later it’s hard to find anything. Modules are your shelves: they help bring order and hide the internals (implementation), leaving only the public façade (API) on display.
Why split into modules
- Separation of concerns: each module is responsible for its own area (for example, database, business logic, UI).
- Code reuse: a module can be used in another project.
- Improved testability: modules can be tested independently.
- Security and encapsulation: only the API is visible externally; the implementation is hidden.
- Easier maintenance: fewer “magic” couplings, a clear dependency map.
- Faster build and deploy: only changed modules are rebuilt.
When to split into modules
- The project becomes too big for one developer (or the IDE starts to lag).
- Clear parts emerge: core, ui, utils, api, impl.
- You plan to reuse code in other projects.
- There are external dependencies needed only by part of the project.
- You need to hide implementation details (algorithms, internal classes).
2. Common modularity patterns
Below are popular splitting patterns suitable for learning and production projects.
“Onion” architecture (Onion Architecture)
The outer layer depends on the inner one, but not the other way around.
[ app (UI) ]
↓
[ core (logic) ]
↓
[ utils (utilities) ]
- app — the outer module: GUI, web application, entry point (main).
- core — business logic, models, services.
- utils — helper classes.
Rule: the inner layer must not depend on the outer one. This way, core can be reused across different interfaces (console, web, desktop).
Separate modules for API and implementation
For libraries, it’s convenient to separate interfaces from their implementations:
[ mylib.api ] ← exports interfaces only
[ mylib.impl ] ← contains implementations, not exported
Test modules
Tests are often moved to a separate module so they don’t end up in the production artifact.
[ app ]
[ core ]
[ core.tests ]
Layout for a learning project
myeditor/
├─ app/ ← entry point, application startup
├─ core/ ← business logic (working with files, text)
└─ utils/ ← utilities (logging, parsing)
3. Dependencies between modules
In Java modules, dependencies are explicitly declared in module-info.java with the requires keyword. This improves readability and lets the compiler/JVM control API visibility via exports.
Dependency example
core/module-info.java
module myeditor.core {
exports myeditor.core.api; // only the api package is visible externally
requires myeditor.utils; // we use the utilities
}
app/module-info.java
module myeditor.app {
requires myeditor.core; // we use core
requires myeditor.utils; // we can use utilities directly
}
Rules and best practices
- Avoid cyclic dependencies. If A requires B and B requires A, that’s a design defect. Usually resolved by extracting a shared common/api module.
- Minimise dependencies. Don’t add a module unless it’s truly needed.
- Export the packages you use. Classes must be in packages declared via exports, otherwise you’ll get a compilation error.
- Utilities should be as independent as possible. utils must not depend on business logic.
4. Hands-on: example of splitting a learning project into 3 modules
Folder structure
myeditor/
├─ app/
│ ├─ src/
│ │ └─ myeditor/app/Main.java
│ └─ module-info.java
├─ core/
│ ├─ src/
│ │ ├─ myeditor/core/api/TextService.java
│ │ └─ myeditor/core/impl/TextServiceImpl.java
│ └─ module-info.java
└─ utils/
├─ src/
│ └─ myeditor/utils/Logger.java
└─ module-info.java
module-info.java examples
core/module-info.java
module myeditor.core {
exports myeditor.core.api;
requires myeditor.utils;
}
app/module-info.java
module myeditor.app {
requires myeditor.core;
requires myeditor.utils;
}
utils/module-info.java
module myeditor.utils {
exports myeditor.utils;
}
Code example (TextService)
myeditor/core/api/TextService.java
package myeditor.core.api;
public interface TextService {
String toUpperCase(String text);
}
myeditor/core/impl/TextServiceImpl.java
package myeditor.core.impl;
import myeditor.core.api.TextService;
public class TextServiceImpl implements TextService {
@Override
public String toUpperCase(String text) {
return text.toUpperCase();
}
}
myeditor/app/Main.java
package myeditor.app;
import myeditor.core.api.TextService;
import myeditor.core.impl.TextServiceImpl;
public class Main {
public static void main(String[] args) {
TextService service = new TextServiceImpl();
System.out.println(service.toUpperCase("hello, modules!"));
}
}
How this looks in IntelliJ IDEA
- Each directory is a separate Module in the project structure.
- Each module has its own module-info.java at the root of src.
- When you run main from app, the IDE will pick an appropriate module-path.
- An attempt to use a class from a non-exported package will result in a compilation error.
5. Impact on the build: Maven/Gradle and modules
Maven
A multi-module project consists of a “parent” project and several “child” modules.
myeditor/
├─ pom.xml ← parent
├─ app/
│ └─ pom.xml
├─ core/
│ └─ pom.xml
└─ utils/
└─ pom.xml
Example parent pom.xml
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>myeditor</groupId>
<artifactId>myeditor-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>app</module>
<module>core</module>
<module>utils</module>
</modules>
</project>
Highlights:
- Maven takes module-info.java into account during compilation.
- For running, use --module-path instead of --classpath.
- If you forget exports or requires, you’ll get a compilation error.
Gradle
Multi-module setup is configured via settings.gradle and per-module build.gradle files.
settings.gradle
rootProject.name = 'myeditor'
include 'app', 'core', 'utils'
build.gradle for a module
plugins {
id 'java'
}
java {
modularity.inferModulePath = true
}
IntelliJ IDEA
- IDEA can generate module-info.java when creating a Java module.
- With Maven/Gradle, the module structure is imported automatically.
- When running main from app, the IDE will configure the module-path.
- Import/export dialogs inform you about package and module visibility.
Common mistakes when splitting into modules
Error #1: Cyclic dependencies between modules. If two modules declare requires for each other, the compiler will report an error. Usually this is a sign of a drifting architecture. The solution is to extract a shared api module or reconsider the boundaries.
Error #2: Using classes from non-exported packages. A class can be public, but if its package isn’t listed in exports in module-info.java, another module won’t see it. Result: compilation error.
Error #3: Forgot to add requires for a used module. An import from another module without a corresponding entry in module-info.java won’t compile. Always declare dependencies explicitly.
Error #4: Duplicate module names. Module names must be unique within the build (especially with Maven/Gradle). Duplicates break the build.
Error #5: Wrong directory structure. The module-info.java file must be located at the root of the module’s src. Otherwise the compiler won’t find the module.
Error #6: Incorrect module-path at runtime. When launching manually, use --module-path instead of --classpath, or you’ll get “module not found”.
GO TO FULL VERSION