Best Practices for Structuring Modular Project Layout in Java Applications

Illustration for Best Practices for Structuring Modular Project Layout in Java Applications
By Last updated:

A frequent pain point when adopting the Java Platform Module System (JPMS) is figuring out how to organize the project layout. Many developers mistakenly treat the module system as just another packaging scheme, mixing APIs, internals, and services in a single place. The result? tangled dependencies, reflection errors, and difficulties in scaling the application.

In real-world enterprise systems and microservices, a well-structured modular layout ensures that boundaries remain clear, APIs are stable, and internals stay hidden. This becomes even more important when building cloud-native apps or custom runtime images with jlink, where lean, maintainable modularization is key.

This tutorial explores best practices for structuring modular projects in Java.


Step 1: Organize Source Folders per Module

Each module should have its own source directory, containing both the module-info.java and its packages.

project-root/
 ├── com.example.user/
 │    ├── module-info.java
 │    └── com/example/user/...
 ├── com.example.order/
 │    ├── module-info.java
 │    └── com/example/order/...
 ├── com.example.payment/
 │    ├── module-info.java
 │    └── com/example/payment/...
 └── com.example.app/
      ├── module-info.java
      └── com/example/app/...

This ensures clear boundaries between modules.


Step 2: Separate APIs from Implementations

Avoid exporting everything from a module. Export only public APIs, keep internals hidden, and use opens where reflection is needed.

Example

module com.example.user {
    exports com.example.user.api;   // API package
    opens com.example.user.entity to hibernate.core; // Reflection access
}
  • com.example.user.api → exposed to other modules.
  • com.example.user.entity → internal but opened for Hibernate.

Step 3: Use Common Modules for Shared Code

When multiple modules depend on the same utilities, create a common module instead of duplicating code.

module com.example.common {
    exports com.example.common.util;
}

This avoids split package issues and maintains DRY principles.


Step 4: Keep Module Descriptors Lean and Clear

Avoid cluttering module-info.java.

  • Use requires transitive only for stable APIs.
  • Avoid open module in production.
  • Keep dependencies explicit.

Example

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

Step 5: Layer Your Application Logically

Follow layered architecture principles:

  • Core Modules → business logic (user, order, payment).
  • Service Modules → APIs, external integration.
  • Application Module → entry point.

This helps maintain a clean architecture.


Step 6: Use Services for Loose Coupling

Decouple modules with provides and uses.

// Service API module
module com.example.notification {
    exports com.example.notification.api;
}

// Provider module
module com.example.email {
    requires com.example.notification;
    provides com.example.notification.api.Notifier 
        with com.example.email.EmailNotifier;
}

// Consumer module
module com.example.app {
    requires com.example.notification;
    uses com.example.notification.api.Notifier;
}

This enables extensibility without tight coupling.


Pitfalls & Misuse Cases

  1. One Giant Module → No real encapsulation benefits.
  2. Exporting Internals → Breaks modular integrity.
  3. Unnecessary Transitive Requires → Hidden dependencies cause chaos.
  4. Mixing APIs with Implementations → Leads to tight coupling.
  5. Over-Modularization → Too many modules add complexity.

Best Practices Checklist

  • ✅ Keep one source directory per module.
  • ✅ Separate APIs from implementations.
  • ✅ Create common utility modules.
  • ✅ Use requires transitive sparingly.
  • ✅ Apply opens only to specific frameworks, not entire modules.
  • ✅ Organize modules into logical layers.
  • ✅ Use services (provides/uses) for extensibility.

📌 What's New in Java Versions?

  • Java 5 → N/A (modules introduced in Java 9)
  • Java 9 → JPMS introduced with modular project layouts
  • Java 11 → Better IDE and build tool support (Maven/Gradle modular builds)
  • Java 17 → Security and runtime refinements for modular applications
  • Java 21 → No significant updates across Java versions for this feature

Analogy

Think of a modular project as an office building:

  • Each department (module) has its own office space (source folder).
  • Only reception areas (APIs) are visible to outsiders.
  • Sensitive documents (internals) stay locked, unless auditors (frameworks) are granted special access.
  • The building is efficient only if departments don’t step on each other’s responsibilities.

Summary + Key Takeaways

  • Structure projects with one folder per module.
  • Keep APIs and internals separate.
  • Avoid pitfalls like mega-modules, excessive exports, or over-modularization.
  • Use services and layered architecture for extensibility and maintainability.
  • A clean modular layout leads to scalable, secure, and future-proof applications.

FAQs

Q1. What is the difference between the classpath and module path?
Classpath allows everything; module path enforces strong boundaries.

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

Q3. What is the purpose of requires transitive?
It re-exports dependencies so downstream modules inherit them.

Q4. How do open and opens differ in reflection?

  • open module → all packages reflective.
  • opens → only specific packages reflective.

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

Q6. How does JPMS improve security compared to classpath?
By hiding internals, blocking reflection, and eliminating split packages.

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 small with one JAR, modularize step by step.

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

Q10. Do frameworks like Spring or Hibernate fully support modules?
Not fully; many require opens for reflection.