Dependency Injection
- 1. Registering a service implementation
- 2. Injecting a service
- 3. Extension initialization sequence
- 4. Testing extension classes
- 5. Advanced concepts: default providers
- 6. Limitations
1. Registering a service implementation
As a general rule, the module that provides the implementation also should register it with the
ServiceExtensionContext
. This is done in an accompanying service extension. For example, providing a “FunkyDB” based
implementation for a FooStore
(stores Foo
objects) would require the following classes:
- A
FooStore.java
interface, located in SPI:public interface FooService { void store(Foo foo); }
- A
FunkyFooStore.java
class implementing the interface, located in:extensions:funky:foo-store-funky
:public class FunkyFooStore implements FooStore { @Override void store(Foo foo){ // ... } }
- A
FunkyFooStoreExtension.java
located also in:extensions:funky:foo-store-funky
. Must be accompanied by a “provider-configuration file” as required by theServiceLoader
documentation. Code examples will follow below.
1.1 Use @Provider
methods (recommended)
Every ServiceExtension
may declare methods that are annotated with @Provider
, which tells the dependency resolution
mechanism, that this method contributes a dependency into the context. This is very similar to other DI containers, e.g.
Spring’s @Bean
annotation. It looks like this:
public class FunkyFooStoreExtension implements ServiceExtension {
@Override
public void initialize(ServiceExtensionContext context) {
// ...
}
//Example 1: no args
@Provider
public SomeService provideSomeService() {
return new SomeServiceImpl();
}
//Example 2: using context
@Provider
public FooStore provideFooStore(ServiceExtensionContext context) {
var setting = context.getConfig("...", null);
return new FunkyFooStore(setting);
}
}
As the previous code snipped shows, provider methods may have no args, or a single argument, which is the
ServiceExtensionContext
. There are a few other restrictions too. Violating these will raise an exception. Provider
methods must:
- be public
- return a value (
void
is not allowed) - either have no arguments, or a single
ServiceExtensionContext
.
Declaring a provider method is equivalent to invoking
context.registerService(SomeService.class, new SomeServiceImpl())
. Thus, the return type of the method defines the
service type
, whatever is returned by the provider method determines the implementation of the service.
Caution: there is a slight difference between declaring @Provider
methods and calling
service.registerService(...)
with respect to sequence: the DI loader mechanism first invokes
ServiceExtension#initialize()
, and then invokes all provider methods. In most situations this difference is
negligible, but there could be situations, where it is not.
1.2 Provide “defaults”
Where @Provider
methods really come into their own is when providing default implementations. This means we can have a
fallback implementation. For example, going back to our FooStore
example, there could be an extension that provides a
default (=in-mem) implementation:
public class DefaultsExtension implements ServiceExtension {
@Provider(isDefault = true)
public FooStore provideDefaultFooStore() {
return new InMemoryFooStore();
}
}
Provider methods configured with isDefault=true
are only invoked, if the respective service (here: FooStore
) is not
provided by any other extension.
As a general programming rule, every SPI should come with a default implementation if possible.
Default provider methods are a tricky topic, please be sure to thoroughly read the additional documentation about them here!
1.3 Register manually (not recommended)
Of course, it is also possible to manually register services by invoking the respective method on
the ServiceExtensionContext
@Provides(FooStore.class/*, possibly others*/)
public class FunkyFooStoreExtension implements ServiceExtension {
@Override
public void initialize(ServiceExtensionContext context) {
var setting = context.getConfig("...", null);
var store = new FunkyFooStore(setting);
context.registerService(FooStore.class, store);
}
}
There are three important things to mention:
- the call to
context.registerService()
makes the object available in the context. From this point on other extensions can inject aFooStore
(and in doing so will provide aFunkyFooStore
). - the interface class must be listed in the
@Provides()
annotation, because it helps the extension loader to determine in which order in which it needs to initialize extensions - service registrations must be done in the
initialize()
method.
2. Injecting a service
As with other DI mechanisms, services should only be referenced by the interface they implement. This will keep
dependencies clean and maintain extensibility, modularity and testability. Say we have a FooMaintenanceService
that
receives Foo
objects over an arbitrary network channel and stores them.
2.1 Use @Inject
to declare dependencies (recommended)
public class FooMaintenanceService {
private final FooStore fooStore;
public FooMaintenanceService(FooStore fooStore) {
this.fooStore = fooStore;
}
}
Note that the example uses what we call constructor injection (even though nothing is actually injected), because
that is needed for object construction, and it increases testability. Also, those types of instance members should be
declared final
to avoid programming errors.
In contrast to conventional DI frameworks the fooStore
dependency won’t get auto-injected - rather, this is done in a
ServiceExtension
that accompanies the FooMaintenanceService
and that injects FooStore
:
public class FooMaintenanceExtension implements ServiceExtension {
@Inject
private FooStore fooStore;
@Override
public void initialize(ServiceExtensionContext context) {
var service = new FooMaintenanceService(fooStore); //use the injected field
}
}
The @Inject
annotation on the fooStore
field tells the extension loading mechanism that FooMaintenanceExtension
depends on a FooService
and because of that, any provider of a FooStore
must be initialized before the
FooMaintenanceExtension
. Our FunkyFooStoreExtension
from the previous chapter provides a FooStore
.
2.2 Use @Requires
to declare dependencies
In cases where defining a field seems unwieldy or is simply not desirable, we provide another way to dynamically resolve service from the context:
@Requires({ FooService.class, /*maybe others*/ })
public class FooMaintenanceExtension implements ServiceExtension {
@Override
public void initialize(ServiceExtensionContext context) {
var fooStore = context.getService(FooStore.class);
var service = new FooMaintenanceService(fooStore); //use the resolved object
}
}
The @Requires
annotation is necessary to inform the service loader about the dependency. Failing to add it may
potentially result in a skewed initialization order, and in further consequence, in an EdcInjectionException
.
Both options are almost semantically equivalent, except for optional dependencies: while
@Inject(required=false)
allows for nullable dependencies,@Requires
has no such option and the service dependency must be resolved by explicitly allowing it to be optional:context.getService(FooStore.class, true)
.
3. Extension initialization sequence
The extension loading mechanism uses a two-pass procedure to resolve dependencies. First, all implementations of
of ServiceExtension
are instantiated using their public default constructor, and sorted using a topological sort
algorithm based on their dependency graph. Cyclic dependencies would be reported in this stage.
Second, the extension is initialized by setting all fields annotated with @Inject
and by calling its initialize()
method. This implies that every extension can assume that by the time its initialize()
method executes, all its
dependencies are already registered with the context, because the extension(s) providing them were ordered at previous
positions in the list, and thus have already been initialized.
4. Testing extension classes
To test classes using the @Inject
annotation, use the appropriate JUnit extension @DependencyInjectionExtension
:
@ExtendWith(DependencyInjectionExtension.class)
class FooMaintenanceExtensionTest {
private final FooStore mockStore = mock();
@BeforeEach
void setUp(ServiceExtensionContext context) {
context.registerService(FooStore.class, mockStore);
}
@Test
void testInitialize(FooMaintenanceExtension extension, ServiceExtensionContext context) {
extension.initialize(context);
verify(mockStore).someMethodGotInvoked();
}
}
5. Advanced concepts: default providers
In this chapter we will use the term “default provider” and “default provider method” synonymously to refer to a method
annotated with @Provider(isDefault=true)
. Similarly, “provider”, “provider method” or “factory method” refer to
methods annotated with just @Provider
.
5.1 Fallbacks versus extensibility
Default provider methods are intended to provide fallback implementations for services rather than to achieve extensibility - that is what extensions are for. There is a subtle but important semantic difference between fallback implementations and extensibility:
5.2 Fallback implementations
Fallbacks are meant as safety net, in case developers forget or don’t want to add a specific implementation for a service. It is there so as not to end up without an implementation for a service interface. A good example for this are in-memory store implementations. It is expected that an actual persistence implementation is contributed by another extension. In-mem stores get you up and running quickly, but we wouldn’t recommend using them in production environments. Typically, fallbacks should not have any dependencies onto other services.
Default-provided services, even though they are on the classpath, only get instantiated if there is no other implementation.
5.3 Extensibility
In contrast, extensibility refers to the possibility of swapping out one implementation of a service for another by
choosing the respective module at compile time. Each implementation must therefore be contained in its own java module,
and the choice between one or the other is made by referencing one or the other in the build file. The service
implementation is typically instantiated and provided by its own extension. In this case, the @Provider
-annotation **
must not** have the isDefault
attribute. This is also the case if there will likely only ever be one implementation
for a service.
One example for extensibility is the IdentityService
: there could be several implementations for it (OAuth,
DecentralizedIdentity, Keycloak etc.), but providing either one as default would make little sense, because all of them
require external services to work. Each implementation would be in its own module and get instantiated by its own
extension.
Provided services get instantiated only if they are on the classpath, but always get instantiated.
5.4 Deep-dive into extension lifecycle management
Generally speaking every extension goes through these lifecycle stages during loading:
inject
: all fields annotated with@Inject
are resolvedinitialize
: theinitialize()
method is invoked. All required collaborators are expected to be resolved after this.provide
: all@Provider
methods are invoked, the object they return is registered in the context.
Due to the fact that default provider methods act a safety net, they only get invoked if no other provider method offers
the same service type. However, what may be a bit misleading is the fact that they typically get invoked during the
inject
phase. The following section will demonstrate this.
5.5 Example 1 - provider method
Recall that @Provider
methods get invoked regardless, and after the initialze
phase. That means, assuming both
extensions are on the classpath, the extension that declares the provider method (= ExtensionA
) will get fully
instantiated before another extension (= ExtensionB
) can use the provided object:
public class ExtensionA { // gets loaded first
@Inject
private SomeStore store; // provided by some other extension
@Provider
public SomeService getSomeService() {
return new SomeServiceImpl(store);
}
}
public class ExtensionB { // gets loaded second
@Inject
private SomeService service;
}
After building the dependency graph, the loader mechanism would first fully construct ExtensionA
, i.e.
getSomeService()
is invoked, and the instance of SomeServiceImpl
is registered in the context. Note that this is
done regardless whether another extension actually injects a SomeService
. After that, ExtensionB
gets constructed,
and by the time it goes through its inject
phase, the injected SomeService
is already in the context, so the
SomeService
field gets resolved properly.
5.6 Example 2 - default provider method
Methods annotated with @Provider(isDefault=true)
only get invoked if there is no other provider method for that
service, and at the time when the corresponding @Inject
is resolved. Modifying example 1 slightly we get:
public class ExtensionA {
@Inject
private SomeStore store;
@Provider(isDefault = true)
public SomeService getSomeService() {
return new SomeServiceImpl(store);
}
}
public class ExtensionB {
@Inject
private SomeService service;
}
The biggest difference here is the point in time at which getSomeService
is invoked. Default provider methods get
invoked when the @Inject
dependency is resolved, because that is the “latest” point in time that that decision can
be made. That means, they get invoked during ExtensionB
’s inject phase, and not during ExtensionA
’s provide phase.
There is no guarantee that ExtensionA
is already initialized by that time, because the extension loader does not know
whether it needs to invoke getSomeService
at all, until the very last moment, i.e. when resolving ExtensionB
’s
service
field. By that time, the dependency graph is already built.
Consequently, default provider methods could (and likely would) get invoked before the defining extension’s provide
phase has completed. They even could get invoked before the initialize
phase has completed: consider the following
situation the previous example:
- all implementors of
ServiceExtension
get constructed by the JavaServiceLoader
ExtensionB
gets loaded, runs through its inject phase- no provider for
SomeService
, thus the default provider kicks in ExtensionA.getSomeService()
is invoked, butExtensionA
is not yet loaded ->store
is null- -> potential NPE
Because there is no explicit ordering in how the @Inject
fields are resolved, the order may depend on several factors,
like the Java version or specific JVM used, the classloader and/or implementation of reflection used, etc.
5.7 Usage guidelines when using default providers
From the previous sections and the examples demonstrated above we can derive a few important guidelines:
- do not use them to achieve extensibility. That is what extensions are for.
- use them only to provide a fallback implementation
- they should not depend on other injected fields (as those may still be null)
- they should be in their own dedicated extension (cf.
DefaultServicesExtension
) and Java module - do not provide and inject the same service in one extension
- rule of thumb: unless you know exactly what you’re doing and why you need them - don’t use them!
6. Limitations
Only available in
ServiceExtension
: services can only be injected intoServiceExtension
objects at this time as they are the main hook points for plugins, and they have a clearly defined interface. All subsequent object creation must be done manually using conventional mechanisms like constructors or builders.No multiple registrations: registering two implementations for an interface will result in the first registration being overwritten by the second registration. If both providers have the same topological ordering it is undefined which comes first. A warning is posted to the
Monitor
.It was a conscientious architectural decision to forego multiple service registrations for the sake of simplicity and clean design. Patterns like composites or delegators exist for those rare cases where having multiple implementors of the same interface is indeed needed. Those should be used sparingly and not without good reason.
No collection-based injection: Because there can be only ever one implementation for a service, it is not possible to inject a collection of implementors as it is in other DI frameworks.
Field injection only:
@Inject
can only target fields. For examplepublic SomeExtension(@Inject SomeService someService){ ... }
would not be possible.No named dependencies: dependencies cannot be decorated with an identifier, which would technically allow for multiple service registrations (using different tags). Technically this is linked to the limitation of single service registrations.
Direct inheritors/implementors only: this is not due to a limitation of the dependency injection mechanism, but rather due to the way how the context maintains service registrations: it simply maintains a
Map
containing interface class and implementation type.Cyclic dependencies: cyclic dependencies are detected by the
TopologicalSort
algorithmNo generic dependencies:
@Inject private SomeInterface<SomeType> foobar
is not possible.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.