Dagger builds a directed acyclic graph of keys to bindings at compile time, validates it once, and generates plain factory classes that execute it at runtime. Hilt is the same machinery with the component hierarchy already written for you. Nothing about resolution is reflective and nothing is decided while the app is running.
What a binding is
A binding is one recipe for producing one key. There are four ways to declare one.
- An
@Inject constructor. The class tells Dagger how to build itself, and every constructor parameter becomes a dependency Dagger has to satisfy.
- A
@Provides method in a module. A method body that returns a finished object, for third party types you cannot annotate.
- A
@Binds method in a module. An abstract method that says this interface is satisfied by that implementation, no method body and no factory class generated for it.
- A component dependency or a subcomponent builder. Bindings that come from another component, either the narrow set a parent exposes or the parent's whole graph. There is more on that split in subcomponent versus component dependency.
The key is what makes resolution work. A key is the type plus an optional qualifier, so String and @BaseUrl String are two different keys with two different bindings. Without qualifiers the type alone collides the moment two String values are in the graph.
How the graph is built at compile time
The annotation processor runs during the build, under KAPT or KSP, and does a recursive walk.
- It starts at the entry points. The injected fields of a component, the provision methods declared on the component interface, and in Hilt the
@EntryPoint interfaces. Those are the roots.
- It resolves each key to exactly one binding. For every dependency of every root, it looks up the key, finds the binding, then repeats for that binding's own dependencies.
- It stops when everything is satisfied. The result is a directed acyclic graph, nodes are bindings, edges are dependencies, and every reachable key has one and only one producer.
- Unreachable bindings are ignored by default. A module binding nothing asks for is not validated unless you turn on full binding graph validation.
What it validates, and the errors you have actually seen
All four of these fail the build, not the app, which is the whole point of doing this at compile time.
- MissingBinding. A key nothing produces. The message names the key and prints the dependency trace showing which entry point led to it, which is usually enough to find the module you forgot to install.
- Duplicate binding. Two bindings for the same key. Usually two modules providing the same type, and the fix is a qualifier or removing one module.
- Dependency cycle.
A needs B and B needs A, so there is no valid instantiation order. Wrapping one side in Provider<B> or Lazy<B> breaks it, because a Provider is a handle rather than an instance, so A can be constructed with something that resolves B later, after A already exists. See circular dependencies.
- Scope mismatch. A
@Singleton binding in an unscoped component, or a shorter lived component holding a longer lived one. A component can carry one scope, and a scoped binding must live in a component carrying that same scope.
What gets generated
For each @Component interface you get a class named Dagger plus the interface name, and it is ordinary Java you can open and read.
- A factory per binding.
UserRepository_Factory with a get() that calls the real constructor with the arguments pulled from other factories.
- A
Provider chain. Each factory holds Provider fields for its dependencies, so the graph becomes a tree of small objects wired in the component's constructor.
DoubleCheck for scoped bindings. Dagger wraps a scoped provider in a double checked lock so the instance is created once per component instance and shared after that.
- Members injectors. For field injection, a
_MembersInjector class that assigns each annotated field.
How the graph runs at runtime
Runtime is boring by design, and that is the selling point.
- No reflection and no annotation reading. The generated code is direct constructor calls, so there is nothing to look up and nothing to fail.
- Instantiation order follows the DAG. Leaves first, then whatever depends on them, walking the factories on demand.
- Nothing is built until something asks. Unscoped bindings are built fresh on each
get(), scoped ones on the first get() and reused after.
Hilt on top
Hilt does not change resolution at all, it generates the components for you.
- A predefined hierarchy.
SingletonComponent at the top, then ActivityRetainedComponent, then ActivityComponent, FragmentComponent, ViewComponent and ViewWithFragmentComponent below it, with ViewModelComponent sitting beside ActivityComponent under ActivityRetainedComponent, and ServiceComponent under the singleton. That tree mirrors real Android lifetimes, which is why the scope rules stop feeling arbitrary.
@InstallIn places a module. It says which component a module's bindings belong to, and they are then visible in that component and every child below it.
@AndroidEntryPoint generates the wiring. It creates the component for that Activity or Fragment and injects it in the right lifecycle callback. A Gradle bytecode transform rewrites the class to extend a generated Hilt base class, which is why your source still reads AppCompatActivity.
@HiltViewModel plugs into the factory. It routes construction through a generated ViewModelProvider.Factory, so by viewModels() gets a constructor injected ViewModel scoped to ViewModelComponent.
Two things that widen the graph
- Multibindings.
@IntoSet and @IntoMap let many modules contribute into one Set<T> or Map<K, T>, so a feature module can register an interceptor or a worker factory without anything editing a central list. Duplicate map keys are still a compile error.
- Assisted injection.
@Assisted marks the parameters you supply at call time, a user id say, and @AssistedFactory generates the factory interface. Dagger fills in the graph parameters and you pass the rest.
How to debug the graph
- Read the error's dependency trace first. It prints the chain from the entry point down to the unsatisfied key, so the missing module is usually in the last two lines.
- Open the generated component. Under
build/generated, DaggerAppComponent is readable Java and shows exactly which binding won for a key.
- Check
@InstallIn before anything else in Hilt. A binding installed in the wrong component is the most common cause of a missing binding that looks impossible.
- Remember the Hilt Gradle plugin aggregates across modules. In a multi module build it collects the modules and entry points before Dagger runs, so a module in a library the app does not depend on is simply not there.
If you want the same idea without the code generation, the hand built version is implement a dependency injection container, a map from a key to a provider lambda with cycle detection bolted on.
@Qualifier annotation class BaseUrl
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @BaseUrl
fun baseUrl(): String = "https://api.example.com" // key is String plus the qualifier
@Provides @Singleton // scoped, one per SingletonComponent
fun client(@BaseUrl url: String): HttpClient = HttpClient(url)
}
Roughly what Dagger writes for that client binding.
final class NetworkModule_ClientFactory implements Factory<HttpClient> {
private final Provider<String> urlProvider; // resolved key, @BaseUrl String
public HttpClient get() { return NetworkModule.client(urlProvider.get()); }
}
// in DaggerAppComponent, scoped so it is memoized
this.clientProvider = DoubleCheck.provider(new NetworkModule_ClientFactory(urlProvider));
In the room, say it in one sentence. It is a compile time directed acyclic graph of keys to bindings, where a key is a type plus a qualifier, validated once during the build so a missing binding or a cycle is a build error, then executed at runtime as generated factories with no reflection at all. Hilt is that same graph with the Android component hierarchy already declared for you.