Dagger SPI - Extending Dagger with custom Dependency Graph validations
In one of recent Dagger versions, Google added support for processing internal Dagger dependency graph information as part of the dagger-spi
artifact (also read SPI vs API). According to the docs, it allows us access to the same model information that Dagger internally uses and lets us add few functionality on top of Dagger’s compiler - like a plugin. Recently I used this functionality to build a tool that visualizes how the overall dependency graph of your project is structured. In this article, I plan to discuss one other functionality of the SPI artifact (validations) and explain how to consume this SPI from scratch.
What we will build?
A compile time validator for Dagger declarations that can
- Validate a Dagger binding according to project specific needs.
- Have customized behavior based on options received from Java compiler options.
- Provide rich formatted error messages similar to Dagger.
- Minor guidance on how to organize these validations for scaling.
Why custom validations?
While Dagger is excellent at compile time static validations and preventing misconfigured DI graph from crashing your application, it does not have any knowledge of the consuming framework’s functional needs. At its core, it only sees types we declare and don’t know anything about framework requirements - which is good. dagger-android
and upcoming dagger.hilt
are exceptions but the point still stands because they are built on top of Dagger core. Because of this, there is nothing stopping us from declaring bindings that can negatively affect your application stability and we usually realy on our expertise to manually vet those declarations. If we somehow are able to connect these framework specific functionalities to Dagger’s already existing validations then we can prevent misuse.
Various Faces of Android’s Context
If you are an Android developer today, you probably would have stumbled upon this question. Each of the ContextWrapper
implementations serve a different purpose. For example, Application
, Service
or Activity
etc. It is common knowledge to excercise caution when using Activity
instance - holding it too long outside of the active scope will easily leak memory and usual solution is to prefer Application
instance. But then again you lose all the configuration and theme data if you use Application
. Given all the specifics, all the below declarations in Dagger are valid:
@Binds
fun bindApplication(context: Application): Context
@Binds
fun bindActivity(context: Activity): Context
@Binds
fun bindService(context: Service): Context
With these bindings, it is possible to inject short lived implementation like Activity
into a larger scoped object causing memory leak. I personally prefer to avoid Context
bindings altogether and bind implementation specifically. Using Dagger @Qualifier
is also another approach but it is not enforced by Dagger.
In this article, let’s build a custom validation for android.content.Context
that fails the build if it detects a Context
binding without any @Qualifier
.
Trident
Continuing the knife related naming thing going on with Dagger, for this project I chose Trident, a special form of late middle ages Dagger designed for defending or parrying. I saw the word “defend”, so I am rolling with it for validations (heh).
BindingGraphPlugin
Before jumping to implementation details, I think it helps to talk a bit about specifics of the SPI first. BindingGraphPlugin
is our single source of extension where the visitGraph(bindingGraph: BindingGraph, diagnosticReporter: DiagnosticReporter)
will be called for every @Component
. From here, we can utilize the graph information available in BindingGraph
and then report errors using DiagnosticReporter
. In order to let Dagger discover our custom plugin, we have to make our implementation available in the classpath so that Dagger can use ServiceLoader
to instantiate our class (more on this later).
BindingGraph
BindingGraph
is how Dagger represents our dependency graph. Dagger performs a lot of checks before construction and then finally reports the constructed graph to plugin, where we are able to inspect it. It is not possible to modify the graph once constructed. Some notable constructs of BindingGraph
are listed below.
Node
- As the name suggests, a node in the graph and it can be any of the below.ComponentNode
- A node that defines theComponent
itself. For example, if you have anAppComponent
and aAppSubComponent
they will be present asComponentNode
in the graph.MaybeBinding
- A marker to denote thatNode
can be a dependency binding. Can is an important term here.MissingBinding
- If we forget to declare a binding that another dependency uses, it is denoted asMissingBinding
and used in error reporting and build usually fails. One exception is full binding graph validation but that is not the scope of this article.Binding
- A valid dependency declaration is denoted asBinding
. For example, if we have@Provides fun provides(): Vehicle
, then we haveBinding
instance forVehicle
.
Edge
- As the name suggests, an edge connects any two nodes in the graph. There are many variations of it as listed below.DependencyEdge
- An edge between two dependency, meaning the source depends on the target. For example,Vehicle(tyres: List<Tyre>)
, edge starts fromBinding
of vehicle and ends at binding of tyres.SubcomponentCreatorBindingEdge
- An edge to represent a link between a parent component and creator of the subcomponent. Example,Subcomponent.Builder
orSubcomponent.Factory
.ChildFactoryMethodEdge
- Similar to above but represents a link between the parent component and the child subcomponent.
Diagnostic Reporter
Based on data from BindingGraph
we can report our custom validation result using DiagnosticReporter
. The API is straight forward - for the Node
s or Edge
s we think are invalid, we call one of the reportXXX()
methods and Dagger will format that error in a neat way and fail the build or warn accordingly.
In addition to these, there are also Filer
, Types
, Elements
and an option map are provided. Filer
can be used to generate files and Types
, Elements
give access to type system which we can use if needed.
Setting up the project
As mentioned earlier, all of these validation run during compile time and not at runtime. In order for Dagger to discover our custom plugin using Java’s ServiceLoader
, we have to make sure we declare our plugin code in the annotation processor classpath. i.e either annotationProcessor
or kapt
for Koltin projects.
- Create a Java or Kotlin library module.
- Add an
implmentation
dependency ondagger-spi
likeimplementation "com.google.dagger:dagger-spi:${versions.dagger}"
. - Now we should be able to extend
BindingGraphPlugin
and add our custom validations.- For automating
ServiceLoader
declarations, we can use Google’s auto service. Thus the declartion becomes@AutoService(BindingGraphPlugin::class) class TridentValidator : BindingGraphPlugin { // implementation }
- For automating
- Finally this module should be consumed in the annotation processor configuration like
kapt project(":dagger-validator")
Writing validations
We can verify if the above setup is working by adding a println
to visitGraph
. Once done, we can straight away start with validations in visitGraph
block. For Android’s Context validation, we simply need to look for Binding
instance that has a type android.content.Context
and whether it has any @Qualifier
associated with it. If it does not have any then we simply call diagnosticReporter.reportBinding
as shown below.
override fun visitGraph(
bindingGraph: BindingGraph,
diagnosticReporter: DiagnosticReporter
) {
bindingGraph.bindings()
.filter { binding ->
val key = binding.key()
key.type().toString() == "android.content.Context" && !key.qualifier().isPresent
}.forEach { contextBinding ->
diagnosticReporter.reportBinding(
ERROR,
contextBinding,
"Please annotate context binding with any qualifier"
)
}
}
In the consuming app
module, let’s bind Context
with @BindsInstance
and run the build.
@Singleton
@Component
interface AppComponent : AndroidInjector<SpiValidation> {
@Component.Factory
interface Factory {
fun create(@BindsInstance context: Context): AppComponent
}
}
As expected the build would fail with the error as show below. By using DiagnosticReporter
, we are able to retain the same error format Dagger uses.
error: [dev.arunkumar.dagger.validator.TridentValidator] Please annotate context binding with any qualifier
public abstract interface AppComponent extends dagger.android.AndroidInjector<dev.arunkumar.dagger.spi.validation.SpiValidation> {
^
android.content.Context is injected at
dev.arunkumar.dagger.spi.validation.MainActivity.context
dev.arunkumar.dagger.spi.validation.MainActivity is injected at
dagger.android.AndroidInjector.inject(T) [dev.arunkumar.dagger.spi.validation.di.AppComponent → dev.arunkumar.dagger.spi.validation.MainActivity_Builder_MainActivity.MainActivitySubcomponent]
> Task :app:kaptDebugKotlin FAILED
Customizing Behavior
Similar to Dagger, there is a way for us to customize our validation behavior based on compiler arguments provided via Gradle and javac
. This is done via supportedOptions()
and initOptions()
respectively. The overall flow of options looks as shown in the diagram below.
First, we declare all the supported options via supportedOptions()
as Set<String>
. Dagger then uses this information to filter out raw options received from compiler and then calls initOptions
. It is good practice to properly namespace the option key for clarity. For example, instead of simply stating enabled
, we could expose trident.enabled
which is much clearer in intention. Personally, I don’t much prefer working with Map<String, String>
for options. It is better to abstract the options into a type safe data structure. One such way is shown below.
/**
* Name space for compiler options
*/
private const val TRIDENT_NAMESPACE = "trident"
/**
* Source of truth for all supported options
*/
enum class SupportedOptions(val key: String) {
ENABLED("$TRIDENT_NAMESPACE.enabled"),
}
val SUPPORTED_OPTIONS = values().map { it.key }.toMutableSet()
data class TridentOptions(val enabled: Boolean = false)
/**
* @return true if this map contains `key` and its value is a `Boolean` with `true`.
*/
private fun Map<String, String>.booleanValue(key: String): Boolean {
return containsKey(key) && get(key)?.toBoolean() == true
}
/**
* Parses raw key value pair received from javac and maps it to typed data structure (`TridentOptions`)
*/
fun Map<String, String>.parseTridentOptions(): TridentOptions {
val enabled = booleanValue(ENABLED.key)
return TridentOptions(enabled)
}
Then in TridentValidator
it becomes easy to declare and parse options to TridentOptions
.
/**
* Map to store user defined option values
*/
private lateinit var options: Map<String, String>
override fun initOptions(options: MutableMap<String, String>) {
this.options = options
}
override fun supportedOptions() = SUPPORTED_OPTIONS
override fun visitGraph(
bindingGraph: BindingGraph,
diagnosticReporter: DiagnosticReporter
){
val tridentOptions = options.parseTridentOptions()
if (tridentOptions.enabled) {
// Do validations
}
}
So far we have covered setting up the project, writing validation and the ability to customize behavior based on build arguments. This forms the crux of the article and the remainder of the article is about my own take on organizing the validations for better seperation with Dagger.
Organizing the validations
In order to better oganize and scope individual validations, I decided to refactor the base setup with Dagger and introducing seperate class for each type of validation and ability to add/remove validations by using Multibindings. As a first step, define a base Validator
class as shown below.
class Validator
@Inject
constructor(
private val tridentOptions: TridentOptions,
private val validations: Set<@JvmSuppressWildcards Validation>
) {
fun doValidation() {
if (tridentOptions.enabled) {
validations.forEach { validation ->
validation.validate()
}
}
}
}
With this we can configure Dagger to contribute n
number of Validation
implementations that the Validator
can execute.
/**
* Marker interface to annotate that a class performs validation.
*/
interface Validation {
/**
* The [BindingGraph] of the component for which the validation is to be performed
*/
val bindingGraph: BindingGraph
/**
* The [diagnosticReporter] instance which can be used to report errors/warning to dagger.
*/
val diagnosticReporter: DiagnosticReporter
/**
* Implementation of the method is expected to utilize `bindingGraph` and report any validation
* failures/concerns to `diagnosticReporter`.
*/
fun validate()
}
bindingGraph
and diagnosticReporter
are the minimum required objects for validation hence they are part of interface. Implementations could request any other dependency from Dagger if required. Finally we configure our injector TridentComponent
to accept these values via combination of @BindsInstance
and @Module
for placing these instances in the graph.
@Component(
modules = [
TridentModule::class,
ValidationModule::class
]
)
interface TridentComponent {
fun validator(): Validator
@Component.Factory
interface Factory {
fun create(
tridentModule: TridentModule,
@BindsInstance bindingGraph: BindingGraph,
@BindsInstance diagnosticReporter: DiagnosticReporter
): TridentComponent
}
}
Overall, the dependency graph looks like below (generated with Scabbard).
I also added another Validation
that ensure primitives are not bound as-is and has a qualifier. Works for Integer
alone for now but can be easily extended.
class PrimitivesValidation
@Inject
constructor(
override val bindingGraph: BindingGraph,
override val diagnosticReporter: DiagnosticReporter
) : Validation {
override fun validate() {
bindingGraph.bindings()
.filter { binding ->
val key = binding.key()
key.type().toString() == Integer::class.java.name && !key.qualifier().isPresent
}.forEach { binding ->
diagnosticReporter.reportBinding(
WARNING,
binding,
"Primitives should be annotated with any qualifier"
)
}
}
}
Connecting all together, the validation in TridentValidator.visitGraph
becomes as shown below.
override fun visitGraph(
bindingGraph: BindingGraph,
diagnosticReporter: DiagnosticReporter
) {
val tridentModule = TridentModule(types, elements, options.asTridentOptions())
DaggerTridentComponent.factory()
.create(tridentModule, bindingGraph, diagnosticReporter)
.validator()
.doValidation()
}
The full source of this sample is available here
Conclusion
In this article, Dagger SPI specifics, internal Dagger model representations, and building custom validations for Dagger dependency graph was presented in detail. Dagger SPI, although limited (only inspection allowed) is a great way to naturally extend Dagger’s compiler according to project specific and framerwork specific needs. Although only few methods of DiagnosticReporter
were covered in this article, it is powerful and allows us to target many different types of nodes and edges. For example, it can be used to block any usage of @Subcomponent
at all. Finally an opinionated way to organize the valdiation code better was presented.
Further work
Some of the things that I considered initially for the article were:
- Adding a compiler option to toggle between
Diagnostic.Kind.ERROR
orDiagnostic.Kind.WARNING
thus allowing us to configure severity of validation. - Custom validation that vets any illegal dependency edge. For example, any instance in which it is forbidden to use another specific binding as a dependency.
Would be glad to hear any feedback/concerns in the comments below, thanks.
– Arun
Comments