Extending Java Libraries with Service Loader
How to dynamically use Java’s Service Loader to discover and load SPI implementations at runtime for plugin-like extensions.
Join the DZone community and get the full member experience.
Join For FreeWhen designing a Java library, extensibility is often a key requirement, especially in the later phases of a project. Library authors want to allow users to add custom behavior or provide their own implementations without modifying the core codebase. Java addresses this need with the Service Loader API, a built-in mechanism for discovering and loading implementations of a given interface at runtime.
Service Loader enables a clean separation between the Application Programming Interface (API) and its implementation, making it a solid choice for plugin-like architectures and Service Provider Interfaces (SPI). In this post, we’ll look at how Service Loader can be used in practice, along with its advantages and limitations when building extensible Java libraries.
Example Usage
In the demo project, the library allows customization of the naming strategy based on annotations, for which dedicated SPI implementations are provided.
SPI Definition
First, let’s start with the SPI in the core library module:
public interface TypeAliasHandler<T extends Annotation> {
Class<T> getSupportedAnnotation();
String getTypeName(T annotation, Class<?> annotatedClass);
}
To enable the Service Loader API to discover implementations of this interface, a configuration file must be created in the META-INF/services/ directory on the classpath. The file name must exactly match the fully qualified name of the interface. Inside this file, list the fully qualified class names of all implementing classes, one per line. This mechanism allows Service Loader to automatically find and load all available implementations at runtime.
Built-in Providers
Within the same JAR file, we can define built-in annotations and their default behavior. For architectural consistency and convenience, the handler responsible for the built-in annotation also implements the SPI interface. This approach ensures that both internal and external implementations are treated uniformly by the Service Loader mechanism.
public class BuiltInTypeAliasHandler implements TypeAliasHandler<TypeAlias> {
@Override
public Class<TypeAlias> getSupportedAnnotation() {
return TypeAlias.class;
}
@Override
public String getTypeName(TypeAlias annotation, Class<?> annotatedClass) {
return annotation.value();
}
}
The annotation is:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TypeAlias {
String value();
}
The implementation must be defined in:
META-INF/services/com.github.alien11689.serviceloaderdemo.coreservice.spi.TypeAliasHandler
with the following content:
com.github.alien11689.serviceloaderdemo.coreservice.builtin.BuiltInTypeAliasHandler
Extensions Module
You can create a separate project (or JAR file) that provides custom annotations and their implementations. Such an extension module can be developed independently from the main library and added to the classpath as needed.
This demonstrates the true power of Service Loader — the ability to add new functionality without modifying the main library’s source code. No recompilation or redeployment of the core library is required.
Let’s start with the annotations:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CustomTypeAlias {
String nameOfTheType();
}
and
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface UpperCasedClassSimpleNameTypeAlias {
}
Their handlers (SPI implementations):
@ServiceProvider
public class CustomTypeAliasHandler implements TypeAliasHandler<CustomTypeAlias> {
@Override
public Class<CustomTypeAlias> getSupportedAnnotation() {
return CustomTypeAlias.class;
}
@Override
public String getTypeName(CustomTypeAlias annotation, Class<?> annotatedClass) {
return annotation.nameOfTheType();
}
}
and
@ServiceProvider
public class UpperCasedClassSimpleNameTypeAliasHandler implements TypeAliasHandler<UpperCasedClassSimpleNameTypeAlias> {
@Override
public Class<UpperCasedClassSimpleNameTypeAlias> getSupportedAnnotation() {
return UpperCasedClassSimpleNameTypeAlias.class;
}
@Override
public String getTypeName(UpperCasedClassSimpleNameTypeAlias annotation, Class<?> annotatedClass) {
return annotatedClass.getSimpleName().toUpperCase();
}
}
Since I used the @ServiceProvider annotation available from Avaje, I do not need to create the META-INF/services/...TypeAliasHandler file manually. It is generated automatically during the build with the following content:
com.github.alien11689.serviceloaderdemo.extensions.custom.CustomTypeAliasHandler
com.github.alien11689.serviceloaderdemo.extensions.uppercased.UpperCasedClassSimpleNameTypeAliasHandler
Discovering the Implementation
In one of the modules (even the one providing the SPI), there should be code that uses the Service Loader API to discover all implementations and use them. In this example, I placed the discovery code in the core module, which is a practical approach — the central module can aggregate all available implementations and provide convenient access to the rest of the application.
In the static initialization block, Service Loader scans the entire classpath for configuration files and automatically creates instances of all discovered implementations:
public class TypeAliasProvider {
private static Map<Class<? extends Annotation>, TypeAliasHandler> annotationToTypeNameHandler = new HashMap<>();
static {
var loader = ServiceLoader.load(TypeAliasHandler.class);
loader.forEach(typeNameHandler -> annotationToTypeNameHandler.put(typeNameHandler.getSupportedAnnotation(), typeNameHandler));
}
// ...
}
In the same class, the discovered implementations can then be used based on the annotations present on a given class:
public class TypeAliasProvider {
// ...
public String getTypeName(Object o) {
var aClass = o.getClass();
for (Annotation annotation : aClass.getAnnotations()) {
var typeNameHandler = annotationToTypeNameHandler.get(annotation.annotationType());
if (typeNameHandler != null) {
return typeNameHandler.getTypeName(annotation, aClass);
}
}
return aClass.getName();
}
}
Let’s Test It Together
To test the extension mechanism effectively, all SPI implementations must be available on the classpath. This means you need to include both the core module (with the SPI definition) and all extension modules containing specific implementations in the test project. Service Loader will automatically discover all available services and enable their use during test execution.
Test classes:
@TypeAlias("class_a")
class ClassWithDefaultTypeAlias {
}
@CustomTypeAlias(nameOfTheType = "Class B with custom alias")
class ClassWithCustomTypeAlias {
}
@UpperCasedClassSimpleNameTypeAlias
class UpperCaseClass {
}
Parameterized test:
class TypeAliasExtensionMappingTest {
private final TypeAliasProvider typeAliasProvider = new TypeAliasProvider();
@ParameterizedTest
@MethodSource("objectToTypeName")
void should_map_object_to_type_name(Object o, String expectedTypeName) {
Assertions.assertEquals(expectedTypeName, typeAliasProvider.getTypeName(o));
}
private static Stream<Arguments> objectToTypeName() {
return Stream.of(
arguments(new Object(), "java.lang.Object"),
arguments(new ClassWithDefaultTypeAlias(), "class_a"),
arguments(new ClassWithCustomTypeAlias(), "Class B with custom alias"),
arguments(new UpperCaseClass(), "UPPERCASECLASS")
);
}
}
Full Code
The full sample code can be found on my GitHub. The demo was initially designed to demonstrate extension possibilities for Javers.
Pros
- Lightweight and dependency-free – Service Loader is part of the JDK and requires no additional runtime libraries.
- Standardized solution – Works consistently across all JVM environments.
- Automatic service discovery – Implementations are discovered at runtime without explicit registration in code.
- Decoupled architecture – Encourages clean separation between core and plugins.
Cons
- No constructor arguments – Service implementations must provide a no-argument constructor, making configuration and dependency passing difficult. Additional SPI methods may be necessary (e.g.,
void configure(Properties properties)). - No built-in dependency injection – Service Loader does not manage dependencies, scopes, or lifecycle.
- Public class requirement – Implementations must be declared as public, which limits encapsulation.
- Limited configurability – Conditional or environment-based service loading is not supported out of the box.
- Harder to debug – Missing or incorrect service definitions may fail silently at runtime.
- Not ideal for complex systems – For advanced use cases, full DI frameworks such as Spring or Guice offer more flexibility.
Summary
Service Loader is a simple yet powerful tool for building extensible Java libraries. It excels in scenarios where minimal dependencies, portability, and clear API boundaries are important. While it has notable limitations — particularly around constructor flexibility, dependency injection, and visibility constraints — it remains an excellent choice for lightweight extension mechanisms.
With the help of tools like Avaje, some of the traditional pain points of Service Loader can be reduced, making it an even more attractive option for modern Java library design.
Published at DZone with permission of Dominik Przybysz. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments