JavaProgrammingSenior Java Developer

Under what specific JPMS accessibility constraint does the ServiceLoader mechanism fail to locate provider implementations located in non-exported packages, despite their presence on the module path?

Pass interviews with Hintsage AI assistant

Answer to the question

The ServiceLoader fails to locate providers when the containing module does not declare a provides ... with directive in its module-info.java descriptor. The Java Platform Module System (JPMS) enforces strong encapsulation by default, preventing the ServiceLoader (located in java.base) from accessing classes in packages that are not exported or opened. The provides directive acts as a contractual declaration that grants the ServiceLoader privileged reflective access to instantiate the specified provider class, bypassing normal package accessibility constraints without requiring the package to be exported to all modules.

Situation from life

Context: A legacy enterprise CRM system was being migrated from Java 8 to Java 17. The goal was to modularize the monolithic architecture into distinct domains: crm-core, crm-email, and crm-api. The crm-email module contained an implementation of the NotificationService interface defined in crm-api.

After migration, the application threw ServiceConfigurationError during bootstrap. This occurred despite the EmailNotificationService class being public and the JAR files present on the module path. The stack trace indicated that no providers were found for the service type, causing the notification subsystem to fail initialization.

Problem: The development team assumed that public visibility of the implementation class was sufficient. This reflected classpath-era assumptions where public classes were globally visible. However, JPMS prevents the ServiceLoader from accessing classes in non-exported packages of other modules. The crm-email module did not export the com.crm.email.internal package. Critically, the module-info.java lacked the provides com.crm.api.NotificationService with com.crm.email.internal.EmailNotificationService declaration. Consequently, the ServiceLoader could not locate or instantiate the provider, as the module system treated the implementation as an encapsulated internal detail.

Solutions considered:

  • Exporting the package: Adding exports com.crm.email.internal; to the crm-email module descriptor. This approach was rejected because it would expose internal implementation details to all other modules. It violated encapsulation and created tight coupling that the module system was designed to prevent.

  • Opening the package for reflection: Using opens com.crm.email.internal; or specifically opens com.crm.email.internal to java.base;. While this allows reflective access, it was deemed overly permissive and semantically incorrect. It signals that the package is subject to deep reflection generally, rather than specifically providing a service through a controlled mechanism.

  • Using the provides ... with directive: Adding the declaration provides com.crm.api.NotificationService with com.crm.email.internal.EmailNotificationService; to the module-info.java. This is the idiomatic JPMS solution. It explicitly declares the service relationship and grants the ServiceLoader the necessary access rights to instantiate the class while maintaining strict encapsulation.

Chosen solution: The team selected the third option. This approach required no changes to the implementation code itself. It preserved the internal visibility of the package and made the service dependency explicit in the module metadata.

Result: The application successfully loaded the EmailNotificationService at runtime. The modular boundary remained intact, preventing other modules from directly instantiating or depending on the internal implementation classes. The ServiceLoader could correctly discover and provision the service through the declared contract.

What candidates often miss

Why does ServiceLoader require the provider class to possess a public zero-argument constructor, and what specific exception manifests if this constraint is violated?

The ServiceLoader instantiates provider classes via reflection using Class.getConstructor().newInstance(). This strictly requires a public no-arg constructor. If this constructor is absent, or if it is not public, the ServiceLoader throws a ServiceConfigurationError. This error is typically wrapped around a NoSuchMethodException or IllegalAccessException during iterator traversal. Candidates frequently overlook that this constructor must be explicitly provided if any other constructors are defined. They also miss that instantiation occurs lazily when Iterator.next() is invoked, not during the initial ServiceLoader.load() call.

How does the ServiceLoader mechanism handle provider classes located in unnamed modules when the service interface is defined within a named module?

When a service interface resides in a named module but the implementation is in an unnamed module (the classpath), the ServiceLoader can still locate the provider. Unnamed modules implicitly read all named modules, and all named modules implicitly read unnamed modules. However, the provider class must still be public with a public no-arg constructor. The common misconception is that strong encapsulation prevents this scenario entirely. In reality, the unnamed module acts as a compatibility layer. Providers in unnamed modules cannot be accessed by code in named modules that does not explicitly read the unnamed module. This creates a directional accessibility constraint that candidates often fail to consider.

What distinguishes the ServiceLoader.loadInstalled() method from ServiceLoader.load() in terms of class loader delegation and provider visibility?

ServiceLoader.loadInstalled() uses the system class loader (or platform class loader in modern JVM versions) to search for providers. It restricts discovery to the installed extensions directory or platform modules. It explicitly ignores providers on the application module path or classpath. In contrast, ServiceLoader.load() typically uses the thread context class loader or a specified class loader. This enables it to discover application-level providers. Candidates often conflate these methods, leading to silent failures where application providers are not found. This happens because loadInstalled() was used incorrectly, expecting it to behave like the standard load method but with broader visibility.