Introduction
When designing a Java library, extensibility is often a key requirement, especially in the latter phases of the 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 Application Programming Interface (API) and 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 customizing 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 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();
}
}
and the annotation is:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TypeAlias {
String value();
}
The implementation needs to be defined in
META-INF/services/com.github.alien11689.serviceloaderdemo.coreservice.spi.TypeAliasHandler
with 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 {
}
and their handlers - meaning the implementations of the SPI:
@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 @ServiceProvider annotation available from
Avaje, I don’t need to create the
META-INF/services/com.github.alien11689.serviceloaderdemo.coreservice.spi.TypeAliasHandler
manually. It will be created 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 Service Loader API to discover all implementations and
utilize 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 whole classpath for configuration files and automatically creates instances of all found 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.
I prepared some test classes using the annotations:
@TypeAlias("class_a")
class ClassWithDefaultTypeAlias {
}
@CustomTypeAlias(nameOfTheType = "Class B with custom alias")
class ClassWithCustomTypeAlias {
}
@UpperCasedClassSimpleNameTypeAlias
class UpperCaseClass {
}
and a parameterized test verifying the resulting type names:
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
Full sample code you can find 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 pluggins.
Cons
- No constructor arguments – Service implementations
must provide a no-argument constructor, making configuration and
dependency passing difficult. Some additional methods in SPI are
necessary e.g.
void configure(Properties properties) - No built-in dependency injection - Service Loader does not manage dependencies, scopes, or lifecycle.
- Public class requirement - Service implementations
must be declared as
public, which limits encapsulation and hides fewer internal details. - 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.