Java modules for stronger package encapsulation
Java’s module system, introduced in Java 9, adds a boundary around groups of packages. It controls which packages are exposed to other modules, which dependencies a module requires and which internal components remain inaccessible. This gives Java applications stronger encapsulation without changing the language’s existing package or class model.
Packages still provide useful namespaces, but making a class public does not guarantee that external code should depend on it. A well-designed module exposes a deliberate API while hiding implementation packages containing database access, configuration, validation rules or other details that may change.
Why packages alone are not enough
Before modules, any public type in an imported package could be used by client code. Renaming a method or moving a class to an internal package could therefore break consumers unexpectedly. Modules let the owning team define a supported surface and hide everything else behind it.
This is particularly valuable in Australian enterprise environments. Sydney-based banking and fintech organisations often combine Java services with older payment and identity platforms. A controlled module boundary makes it easier to modernise one component without forcing every partner to adopt the same internal structure.
Choosing exports and dependencies
A module uses exports to publish a package and requires to declare a dependency. Exporting a package makes its public types available for compilation; requiring a module makes that module readable at both compile time and runtime.
module com.acme.billing {
exports com.acme.billing.api;
requires java.sql;
requires transitive com.acme.common;
}
Here, com.acme.billing.api is the public contract. The java.sql module is required for JDBC, while requires transitive allows applications using the billing API to compile against the common types they need.
Qualified exports offer another level of control. The directive exports com.acme.billing.api to com.acme.checkout permits one specific module to use the API without exposing it to the entire application. This is useful when several internal teams cooperate but need separate ownership boundaries.
Declaring a modular application
Create a module-info.java file at the source root of each Java module. The module name should be unique and stable because it becomes part of the application’s runtime identity. Avoid duplicating the same package across modules, as the Java runtime rejects split packages.
Modules are compiled with javac by using --module-source-path, and applications run with java by using --module-path and --module. A typical launch specifies the module name and its main class, such as com.acme.billing/com.acme.billing.Main.
Standard Java modules cover common platform needs. Applications can require java.sql for JDBC, java.logging for logging facilities or java.net.http for the built-in HTTP client. These requirements also document the platform capabilities expected by the code.
Handling reflection and service loading
Strong encapsulation can conflict with frameworks that create objects reflectively. Serialisation libraries, persistence tools and web frameworks may need to inspect fields or invoke constructors inside an internal package. An opens directive grants that reflective access without exporting the package as an ordinary API.
module com.acme.billing {
exports com.acme.billing.api;
opens com.acme.billing.internal to com.fasterxml.jackson.databind;
}
The target should be as specific as possible. Opening an entire package to every unnamed module weakens the boundary and should be reserved for controlled development or migration scenarios.
Service loading provides a cleaner route for integrations such as payment gateways and authentication providers. The consumer declares uses for the service contract, while the implementation module declares provides ... with .... Providers can then be discovered without exposing their implementation classes.
Testing modular code
Tests should verify the public API, but some suites also need access to package-private behaviour. A dedicated test module can require the production module and use opens for the specific packages it inspects. Build tools such as Maven and Gradle can also patch test code into the module under test, although module-path support must be configured correctly.
White-box tests remain valuable for complex validation and persistence rules, but they should not cause every internal package to become globally accessible. A limited test boundary also catches accidental dependency creation: when a test module cannot reach an internal type, the design issue becomes visible before release.
Adopting modules incrementally in Australian teams
Maven multi-module projects can introduce module-info.java files gradually, while Gradle builds need modularity and module-path settings enabled. Existing libraries can remain on the class path during an early migration, then move to named or automatic modules as their dependencies become clearer.
Different Australian markets place different demands on Java architecture. Brisbane organisations supporting mining and resource operations often run services close to remote assets, where predictable startup and limited external connectivity matter. Melbourne software teams may operate shared JVM platforms across many products, making stable module boundaries useful for reducing upgrade friction.
A brief “can you have a look?” review is familiar in Australian engineering culture, but explicit module descriptors still provide the lasting contract. Government and enterprise buyers also expect maintainable integrations, clear support arrangements and secure configuration. Starting with one well-defined application module, documenting its exports and validating it through module-path tests establishes a practical foundation for further encapsulation.