Simple APIs are elegant APIs
I recently gave a presentation about how Dagger works under the hood, and I was once again struck by the elegance of the javax.inject.Provider
interface. The interface is so simple it almost seems useless, but it’s also incredibly flexible, and forms the basis of much of the code generated by Dagger.
Like many dependency injection frameworks for JVM languages, Dagger uses and builds on the standard set of annotations for injectable classes defined in JSR-330 and provided in the javax.inject package.
Here’s what the interface looks like.
public interface Provider<T> {
T get();
}
Dagger generates providers for all of the dependencies required in your object graph. This means any classes with an @Inject
annotated constructor, types returned from @Provides
annotated factory functions in a module, and a few other cases will all have individual Provider
s generated by Dagger.
You write:
class WebApi @Inject constructor(
httpClient: OkHttpClient
)
And Dagger generates (roughly):
public final class WebApi_Factory implements Provider<WebApi> {
private final Provider<OkHttpClient> httpClientProvider;
public WebApi_Factory(
Provider<OkHttpClient> httpClientProvider
) {
this.httpClientProvider = httpClientProvider;
}
@Override
public WebApi get() {
return new WebApi(httpClientProvider.get());
}
}
So far that all seems pretty straight-forward. But let’s take a look at the power that this simple interface offers.
Shared instances⌗
The simplest implementation of a Provider
is shown above, and this is roughly what Dagger generates. It simply creates a new instance of an object by calling it’s injected constructor each time the get()
function is called.
This is handy, but in a lot of cases we want to share a single instance of a class. The Provider
interface makes this trivial. We can write a Provider
that simply wraps another Provider
, keeping a reference to the first generated value.
// We need a marker value to support nullable Ts
private const val UNINITIALIZED = Any()
/**
* A non-threadsafe [Provider] that keeps and returns a single instance.
*/
class SharedInstanceProvider<T>(
val delegate: Provider<T>
) : Provider<T> {
private var instance: Any = UNINITIALIZED
override fun get(): T {
if (instance == UNINITIALIZED) {
instance = delegate.get()
}
return instance as T
}
}
With this simple Provider
we can now wrap any other Provider
, allowing us to provide a shared instance for all consumers.
Dagger has something similar to the above, called Lazy, which you can inject instead of a raw dependency to get the same functionality. Dagger also uses this functionality to scope dependencies to a Component or Subcomponent.
Eager vs. Lazy instantiation⌗
The Provider
interface allows the lifecycle of an object to be considered an implementation detail of the system. In both cases above the object creation is lazy: nothing gets instantiated until the get()
function is called. But that fact is simply an implementation detail.
The simplicity of the Provider
interface means that when the instance is created doesn’t matter to the caller, and we can make an instance that eagerly instantiates objects if we choose.
/**
* A [Provider] that eagarly creates a shared instance.
*/
class EagerProvider<T>(
delegate: Provider<T>
): Provider<T> {
private val instance = delegate.get()
override fun get(): T = instance
}
The above allows us to make any Provider
eagerly instanted without requiring changes to any consuming code.
What, not how⌗
Because Dagger’s generated factories are passing around Provider
implementations, it’s incredibly easy to provide dependencies in a multitude of different ways without drastically complicating the code.
In Dagger, dependencies can be provided as static instances to your component factory, by factory functions in a module, or via constructor injection. They can be bound as some supertype, collected into sets and maps, or optionally provided.
The Provider
interface hides the details of how an object is instantiated from consumers, allowing them to focus on what is being provided.
For example, a Provider
that returns things from factory functions in a Module might look like this:
@Module
class NetworkModule {
@Provides fun provideAnalytics(backend: Backend): Analytics
}
class AnalyticsProvider(
private val module: NetworkModule,
private val backendProvider: Provider<Backend>
): Provider<Analytics> {
override fun get(): Analytics {
return module.provideAnalytics(backendProvider.get())
}
}
A Provider
that returns static values passed into your component factory would look like this:
class ApiKeyProvider(
private val apiKey: String,
): Provider<String> {
override fun get(): String = apiKey
}
With all of these different implementations, consumers never need to know about the internals, and never need to change when those internal change.
Aim for simple APIs⌗
When I’m writing code, even in proprietary internal modules, I like to keep the API surface of whatever I’m working on in mind. I like to see how I can make the API as simple as possible for consumers (even if the only consumer is slightly older me), and I’m often surprised by the flexibility that these simple abstractions can provide.