Designing Modular Applications with Multiple Modules in Java: Best Practices and Real Examples

Illustration for Designing Modular Applications with Multiple Modules in Java: Best Practices and Real Examples
By Last updated:

A common mistake developers make when modularizing large Java applications is treating all code as a single giant module. This defeats the purpose of modularization and leads to the same problems the classpath world had—tight coupling, unclear boundaries, and hidden dependencies.

In the Java Platform Module System (JPMS), well-designed applications should be composed of multiple smaller modules with clear responsibilities and boundaries. This ensures encapsulation, easier maintenance, and the ability to deploy leaner custom runtimes using tools like jlink.

In this tutorial, we’ll explore how to design applications with multiple modules, using practical examples, pitfalls to avoid, and best practices for enterprise-grade modular systems.


Step 1: Identify Logical Boundaries

Start by breaking your application into domains or layers. For example, in an e-commerce application:

  • com.example.user → User management module
  • com.example.order → Order processing module
  • com.example.payment → Payment module
  • com.example.app → Main application module

This separation mirrors real-world domains, reducing coupling.


Step 2: Define Module Descriptors

Each module needs a module-info.java file.

User Module

module com.example.user {
    exports com.example.user.api;
}

Order Module

module com.example.order {
    requires com.example.user;
    exports com.example.order.api;
}

Payment Module

module com.example.payment {
    requires transitive com.example.order;
    exports com.example.payment.api;
}

Application Module

module com.example.app {
    requires com.example.payment;
}
  • order depends on user.
  • payment re-exports order with requires transitive.
  • app depends only on payment, but still gains access to order and user.

Step 3: Compile and Run Multi-Module Applications

Compile:

javac -d out --module-source-path src $(find src -name "*.java")

Run the main application:

java --module-path out -m com.example.app/com.example.app.Main

Step 4: Handle Reflection Needs

Frameworks like Spring or Hibernate may require reflective access. Use opens instead of open module.

module com.example.user {
    opens com.example.user.entity to hibernate.core;
    exports com.example.user.api;
}

This ensures only Hibernate can reflectively access the entities package.


Pitfalls & Misuse Cases

  1. Single Mega-Module → Eliminates encapsulation benefits.
  2. Over-Modularization → Splitting too aggressively creates complexity.
  3. Uncontrolled Transitive Requires → Creates hidden dependencies.
  4. Exporting Internals → Breaks encapsulation and allows misuse.
  5. Open Module in Production → Dangerous security exposure.

Best Practices

  • Keep modules cohesive around domain responsibilities.
  • Export only API packages, never internals.
  • Use requires transitive only for stable APIs.
  • Place entities and reflection-heavy classes in separate packages to manage with opens.
  • Regularly review module boundaries as applications evolve.

📌 What's New in Java Versions?

  • Java 5 → N/A (modules introduced in Java 9)
  • Java 9 → JPMS introduced: multi-module support, module-info.java
  • Java 11 → Better tooling for modular builds (Maven/Gradle integration)
  • Java 17 → Security and runtime performance improvements for multi-module apps
  • Java 21 → No significant updates across Java versions for this feature

Analogy

Think of a modular application as an office building:

  • Each department (module) has its own room and responsibilities.
  • Only certain documents (APIs) are shared across rooms.
  • Some files are inspected only by auditors (frameworks) with special permission (opens).
  • Without separation, it’s chaos—just like the classpath days.

Summary + Key Takeaways

  • Break applications into multiple modules with clear boundaries.
  • Use requires, exports, and requires transitive wisely to manage dependencies.
  • Apply opens selectively for frameworks requiring reflection.
  • Avoid pitfalls like mega-modules, over-modularization, and open modules.
  • JPMS enables cleaner, safer, and more maintainable applications for enterprise systems.

FAQs

Q1. What is the difference between the classpath and module path?
Classpath exposes everything globally; module path enforces explicit requires/exports.

Q2. Why do I get “package is not visible” errors when using modules?
Because the package wasn’t exported in module-info.java.

Q3. What is the purpose of requires transitive?
It re-exports dependencies so downstream modules don’t need to declare them.

Q4. How do open and opens differ in reflection?

  • open module → All packages reflective.
  • opens → Only specific packages reflective, often restricted to frameworks.

Q5. What are automatic modules, and should I use them?
They’re non-modular JARs treated as modules. Use temporarily in migration.

Q6. How does JPMS improve security compared to classpath?
By hiding internals, controlling reflection, and enforcing strong boundaries.

Q7. When should I use jlink vs jmod?

  • jlink → Build custom runtime images.
  • jmod → Package modules for distribution.

Q8. Can I migrate a legacy project to modules incrementally?
Yes, start by modularizing your own code, then dependencies.

Q9. How do I handle third-party libraries that aren’t modularized?
Use them as automatic modules or on the classpath temporarily.

Q10. Do frameworks like Spring or Hibernate fully support modules?
Not fully—many require opens to enable reflection.