androidinterview.com

Android Dependency Injection Interview Questions

16 questions

Tier
Difficulty
Level

Showing all 16 questions

DI Concepts

What is Dependency Injection?

Tier: EssentialDifficulty: Easy

Dependency Injection is a pattern where a class receives the objects it depends on from the outside, instead of creating them itself.

// Without DI, the ViewModel builds its own dependency, and is stuck with it
class UserViewModel {
    private val repository = UserRepository(RetrofitClient.api)
}

// With DI, the dependency is handed in from the outside
class UserViewModel(private val repository: UserRepository) : ViewModel()

That one change buys you a few things. The class no longer needs to know how to construct its dependency, only what shape it needs, usually expressed as an interface. Testing gets much easier because you can hand the class a fake or mock repository instead of a real network client. And the dependency can be shared or scoped, the same UserRepository instance can be reused across every ViewModel that needs it instead of each one making its own.

You can do DI by hand, just pass constructor arguments yourself, and plenty of small apps do. The reason frameworks like Dagger, Hilt, or Koin exist is that manual DI gets painful once you have a real dependency graph, a repository needing an API client and a database, both needing a config object, all needing to be wired up consistently across dozens of classes. The framework generates or manages that wiring for you so you don't hand-assemble it at every call site.

Write a real-life example of Dependency Injection without using any library.

Tier: CommonDifficulty: Medium

Manual DI is just passing dependencies in through a constructor yourself, no framework required. Here's a small repository and ViewModel wired together by hand.

interface UserRepository {
    suspend fun getUser(id: String): User
}

class UserRepositoryImpl(private val api: ApiService) : UserRepository {
    override suspend fun getUser(id: String) = api.fetchUser(id)
}

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    fun loadUser(id: String) {
        viewModelScope.launch { _user.value = repository.getUser(id) }
    }
}

// wiring happens once, at the composition root
val api = Retrofit.Builder().baseUrl(BASE_URL).build().create(ApiService::class.java)
val repository: UserRepository = UserRepositoryImpl(api)
val viewModel = UserViewModel(repository)

Nothing here builds its own dependencies, UserRepositoryImpl receives an ApiService instead of constructing one, and UserViewModel receives a UserRepository interface instead of the concrete implementation. That's the whole pattern, the wiring, deciding that UserRepositoryImpl is the implementation to use, happens in exactly one place, usually called the composition root, an Activity, an Application class, or a simple factory function, rather than scattered across every class that needs a dependency.

This is exactly what a DI framework automates once that composition root gets big enough to be painful to maintain by hand, Dagger generates the equivalent of that wiring block at compile time, Hilt ties it to Android's lifecycle automatically, but the underlying idea, pass dependencies in rather than construct them, is the same either way.

Why do we use a Dependency Injection framework like Dagger in Android? What are the alternatives, and how would you build your own DI library?

Tier: CommonDifficulty: HardAsked at: meesho

Manual DI works fine at a small scale, and plenty of apps never outgrow it. A framework like Dagger earns its place once the graph gets big enough, a repository needing an API client and a database, both needing config, all needed by a dozen ViewModels, that wiring by hand becomes its own maintenance job, and a framework generates it consistently instead.

Alternatives

  • Dagger, compile time, no reflection, the most control and the steepest setup.
  • Hilt, Dagger with Android's component hierarchy predefined for you, the default choice for most apps today.
  • Koin, a runtime, pure Kotlin DSL, less setup, but a missing binding is a crash instead of a build failure.
  • Manual constructor injection, no library at all, viable for a small app or a single module with a shallow graph.

Building your own

A minimal DI library is really just a map from a type to a way of building it, plus a rule for whether to build a new instance or reuse one.

class Container {
    private val singletons = mutableMapOf<KClass<*>, Any>()
    private val factories = mutableMapOf<KClass<*>, () -> Any>()

    fun <T : Any> registerSingleton(type: KClass<T>, instance: T) { singletons[type] = instance }
    fun <T : Any> registerFactory(type: KClass<T>, factory: () -> T) { factories[type] = factory }

    @Suppress("UNCHECKED_CAST")
    fun <T : Any> get(type: KClass<T>): T =
        singletons[type] as? T ?: factories[type]?.invoke() as? T
            ?: error("No binding for $type")
}

That's the core of what Dagger, Hilt, and Koin all do underneath the different syntax, a registry of how to build each type, resolved either at compile time through generated code or at runtime through a map like this one. Writing a toy version is a good way to actually understand what the frameworks are automating, it's also exactly why nobody ships this by hand at scale, no compile time safety, no scoping beyond a flat singleton or factory split, and no handling for a dependency graph with cycles or complex lifetimes.

Read more Dependency injection in Android (opens in a new tab)

Dagger

Dagger gets asked more than it gets written now, because it is where the annotations and the vocabulary came from.

Explain the @Inject, @Module, @Provides and @Component annotations in Dagger 2.

Tier: EssentialDifficulty: Medium

These four annotations are the core vocabulary of Dagger, each one plays a different role in building the dependency graph.

  • @Inject on a constructor tells Dagger how to build that class itself, and marks the fields or constructor parameters that need dependencies supplied.
  • @Module marks a class that groups together methods for building objects Dagger can't construct directly, usually because they come from a third party library or need custom setup.
  • @Provides marks a method inside a @Module that returns a fully built object, Dagger calls it whenever something needs that type.
  • @Component marks an interface that Dagger uses to generate the actual implementation, it's the bridge between the modules and the classes that need injecting.
class UserRepository @Inject constructor(private val api: ApiService)

@Module
class NetworkModule {
    @Provides
    fun provideApi(): ApiService = Retrofit.Builder().build().create(ApiService::class.java)
}

@Component(modules = [NetworkModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
}

The way to think about the split, @Inject is for classes you own and can annotate directly, @Provides inside a @Module is for everything else, an interface, a third party class, or anything that needs a builder instead of a plain constructor call. @Component ties both together and is what actually generates code at compile time, an AppComponent implementation with all the wiring already written for you.

What is a Module in Dagger?

Tier: CommonDifficulty: Easy

A Module is a class that groups @Provides methods for objects Dagger can't build with a plain @Inject constructor, usually because the type comes from a library you don't own, or because building it needs some setup logic beyond just calling a constructor.

@Module
class NetworkModule {
    @Provides
    fun provideOkHttpClient(): OkHttpClient =
        OkHttpClient.Builder().addInterceptor(loggingInterceptor).build()

    @Provides
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder().baseUrl(BASE_URL).client(client).build()
}

You can't put @Inject directly on OkHttpClient's constructor, it's a third party class Dagger doesn't own, so the @Provides method inside a module is where that construction logic lives instead. A module is registered with a @Component in its modules list, and Dagger calls its @Provides methods, in dependency order, whenever something in the graph needs that type.

The rule of thumb, if you own the class and can add @Inject to its constructor, do that, it's less code. Reach for a module and @Provides specifically for classes you don't own, interfaces that need a concrete implementation chosen, or objects whose construction genuinely needs custom logic.

What is a Component in Dagger?

Tier: CommonDifficulty: Medium

A Component is the interface Dagger uses to generate the code that connects your modules, the "here's how to build things" side, to the classes that actually need dependencies injected, the "here's who needs them" side.

@Component(modules = [NetworkModule::class, DatabaseModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
    fun userRepository(): UserRepository
}

You list the modules it pulls providers from, and you declare either inject() methods for classes that receive their dependencies as fields, or accessor methods that return a fully built object directly. Dagger generates a class implementing that interface, DaggerAppComponent, at compile time, with every method following the actual chain of @Provides and @Inject constructors needed to satisfy it.

A Component also defines the boundary of a dependency graph. Anything reachable from its modules and their transitive dependencies can be injected through it, anything outside that graph is a compile error, which is what makes a missing binding show up as a build failure instead of a runtime crash.

What is the difference between @Provides and @Binds in Dagger?

Tier: CommonDifficulty: Medium

@Provides is a method with a body, you write the actual construction logic and return the finished object. @Binds is an abstract method with no body at all, it just tells Dagger which implementation class to hand out whenever an interface is requested, and Dagger generates the wiring itself.

@Module
class RepositoryModule {
    @Provides
    fun provideApi(): ApiService = Retrofit.Builder().build().create(ApiService::class.java)
}

@Module
abstract class BindingModule {
    @Binds
    abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}

@Binds only works for a straightforward case, mapping an interface to exactly one implementation that Dagger can already construct on its own, usually because that implementation has an @Inject constructor. It can't do any actual construction work, so anything that needs real logic to build, like the Retrofit instance above, has to stay as @Provides.

The reason @Binds is preferred when it applies, it generates less code. @Provides needs a full method Dagger has to call at runtime, @Binds compiles down to a direct reference, no extra method invocation, which is a small but real difference at scale across a large dependency graph.

How does Dagger work?

Tier: CommonDifficulty: Hard

Dagger reads your @Inject, @Module, and @Component annotations at compile time, through an annotation processor, and generates real Java classes that build your dependency graph. There's no reflection at runtime, everything Dagger does happens once, during the build, which is the main thing that separates it from older, reflection based DI frameworks.

@Component(modules = [NetworkModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
}
// generates DaggerAppComponent.create() at compile time

For every @Component interface you write, Dagger generates a class named Dagger plus the interface name, DaggerAppComponent here, that implements it. That generated class has factory methods for every type in the graph, following the chain from @Provides methods and @Inject constructors down to whatever needs injecting, and it wires the whole thing together as plain constructor calls, no reflection involved.

That compile time generation is also why Dagger errors show up as build failures instead of runtime crashes. A missing binding, forgetting to provide an ApiService anywhere in the graph, fails the build with a message naming exactly what's missing, rather than crashing the app the first time that dependency is actually needed.

How does dependency resolution work in Dagger and Hilt? How is the graph built?

Tier: CommonDifficulty: Hard

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.

Read more Dependency injection with Hilt (opens in a new tab)Hilt and Dagger annotations cheat sheet (opens in a new tab)

Hilt & Koin

How would you choose between Dagger 2 and Dagger-Hilt?

Tier: EssentialDifficulty: Medium

For any new Android app, choose Hilt. It's built on top of Dagger, so you get the same compile time verified dependency graph and the same performance, but with most of the boilerplate stripped away.

The difference is what you have to write by hand.

  • Dagger 2 makes you define your own components, tie each one to an Android lifecycle owner yourself, and write the modules that wire it all together. That's a lot of ceremony to get right, and it's easy for a new team member to wire a scope incorrectly.
  • Hilt gives you standard components already scoped to Application, Activity, Fragment, ViewModel, and so on, generated for you. You annotate a class with @AndroidEntryPoint or @HiltViewModel and the scoping is handled correctly by convention, so there's a lot less room to get it wrong.

The one case where you'd still reach for plain Dagger is a non-Android module, like a shared Kotlin Multiplatform or pure JVM library, where Hilt's Android-specific generated components don't apply and you want the graph without any Android dependency at all.

So in practice the decision isn't really Dagger versus Hilt as competitors, Hilt is opinionated Dagger for Android. You pick raw Dagger only when you're outside the Android application layer or maintaining a large legacy codebase where migrating off Dagger isn't worth the churn.

Have you used Koin? What are your thoughts on it?

Tier: CommonDifficulty: Easy

Koin is a valid alternative to Dagger and Hilt, it trades compile time safety for a much simpler setup, a plain Kotlin DSL instead of annotation processing and generated code.

val appModule = module {
    single<ApiService> { Retrofit.Builder().build().create(ApiService::class.java) }
    factory { UserRepository(get()) }
    viewModel { UserViewModel(get()) }
}

startKoin { modules(appModule) }

The appeal is real, no build time code generation, no annotation processor errors that are hard to read, and a graph you can understand just by reading the module definitions. The cost is also real, Koin resolves dependencies at runtime with a service locator style graph, so a missing binding is a runtime crash instead of a compile error, and that graph is a little slower to resolve than Dagger's generated code, though rarely enough to matter in practice.

For most apps this is a fair trade. Hilt is the safer default for a large team or a codebase that's going to grow for years, because catching a missing dependency at compile time instead of in a crash report is worth the setup cost. Koin is a good fit for a smaller app, or a team that values reading straightforward Kotlin over debugging annotation processor output, there isn't one right answer here, it depends on how much the compile time safety is actually worth to the team building it.

What is interesting about Hilt?

Tier: CommonDifficulty: Easy

Hilt's real contribution is standardizing the component hierarchy that Android teams kept hand rolling with plain Dagger. SingletonComponent, ActivityComponent, FragmentComponent, ViewModelComponent, these are predefined, already tied to the right Android lifecycle, and already nested correctly, application scope contains activity scope contains fragment scope. Nobody has to design and wire that hierarchy themselves anymore.

@HiltAndroidApp
class MyApp : Application()

@AndroidEntryPoint
class MainActivity : AppCompatActivity()

@HiltViewModel
class UserViewModel @Inject constructor(private val repository: UserRepository) : ViewModel()

The annotations do the wiring that used to be manual, @AndroidEntryPoint generates the component for that Activity or Fragment and calls inject automatically, @HiltViewModel plugs into a ViewModelProvider.Factory Hilt generates for you, so by viewModels() just works with constructor injected dependencies. That's a real amount of boilerplate that plain Dagger left entirely up to each team to design consistently.

The tradeoff, Hilt is opinionated about that structure, it assumes the standard Android component hierarchy, which is exactly right for a typical app and awkward for anything unusual, like injecting into a class with no natural Android scope. It's built on top of Dagger, not a replacement for it, so everything about how Dagger resolves the graph is unchanged, Hilt just removes the setup work most apps were doing the same way anyway.

Read more Dependency injection with Hilt (opens in a new tab)

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

Dagger

How do custom scopes work in Dagger?

Tier: Less commonDifficulty: Hard

A custom scope is your own annotation, marked with @Scope, that ties an object's lifetime to a specific Component instead of the built in @Singleton scope, which is tied to the application's lifetime.

@Scope
@Retention(AnnotationRetention.RUNTIME)
annotation class LoginScope

@LoginScope
@Component(dependencies = [AppComponent::class])
interface LoginComponent {
    fun inject(activity: LoginActivity)
}

@LoginScope
class LoginSessionManager @Inject constructor()

Once LoginSessionManager is annotated @LoginScope and provided inside a @LoginScope component, Dagger hands out the same instance for every injection point within that component's lifetime, and a fresh one the next time that component is created. That's the point of a custom scope, something that should live exactly as long as the login flow, not the whole app and not a single screen, gets its own scope instead of being forced into @Singleton or left unscoped.

The rule Dagger enforces at compile time, a scoped binding can only live in a component carrying that same scope annotation, and a component can only carry one scope. Trying to provide a @LoginScope object from an unscoped or differently scoped component is a compile error, not a runtime surprise, which is exactly the kind of mistake Dagger is built to catch early.

What is a circular dependency in Dagger and how do you resolve it?

Tier: Less commonDifficulty: Hard

A circular dependency is when two classes each need the other to be constructed, A needs a B in its constructor and B needs an A in its. Dagger catches this at compile time, since it can't find a starting point, building either one means building the other first, forever.

class A @Inject constructor(val b: B)
class B @Inject constructor(val a: A)  // compile error, dependency cycle

The real fix is almost always to break the cycle at the design level, one direction of that dependency usually isn't a real, immediate need. Extracting the shared piece both classes actually depend on into a third class, and having both A and B depend on that instead of on each other, is the cleanest way out.

When the cycle is genuinely unavoidable, wrapping one side in a Provider<B> or Lazy<B> instead of a plain B breaks it, since Dagger can then construct A with a provider that only resolves B lazily, after A already exists.

class A @Inject constructor(val bProvider: Provider<B>)
class B @Inject constructor(val a: A)

Reaching for Lazy or Provider should be the exception, not the default fix, a circular dependency is usually a sign two classes are more tightly coupled than they need to be, and the redesign is worth doing before reaching for the escape hatch.

What is a subcomponent and what is its use? How do you use qualifiers to provide different instances of the same type? Constructor injection vs method injection? What is a scope and the @Singleton annotation?

Tier: Less commonDifficulty: Hard

A subcomponent is a child component that inherits everything already in its parent's graph and adds its own, more narrowly scoped bindings on top. You reach for one when part of the graph needs a shorter lifetime than the parent, a login flow or a single screen, without duplicating everything the parent already provides.

@LoginScope
@Subcomponent(modules = [LoginModule::class])
interface LoginComponent {
    fun inject(activity: LoginActivity)
}

@Component(modules = [AppModule::class])
interface AppComponent {
    fun loginComponent(module: LoginModule): LoginComponent
}
Qualifiers

A qualifier is a custom annotation that lets Dagger tell apart two bindings of the same type, since it otherwise has no way to know which OkHttpClient you mean.

@Qualifier
annotation class AuthClient

@Provides
@AuthClient
fun provideAuthClient(): OkHttpClient = OkHttpClient.Builder().addInterceptor(authInterceptor).build()

class ApiService @Inject constructor(@AuthClient private val client: OkHttpClient)
Constructor injection vs method injection

Constructor injection, @Inject on the constructor, is the default and should be the first choice, Dagger builds the whole object in one call and the class can be immutable. Method, or field, injection is for classes Dagger doesn't construct itself, an Activity or Fragment the Android framework instantiates, where you instead call component.inject(this) and let Dagger fill in @Inject annotated fields after the fact.

Scope and @Singleton

A scope annotation ties a binding's lifetime to a specific component instance, the same instance gets reused for every injection within that component rather than a new one being built each time. @Singleton is just the built in scope Dagger ships with, conventionally tied to the application level component, so a @Singleton binding lives as long as the app does. Custom scopes work the same way but are tied to whatever component you define them for instead.

What is the difference between a subcomponent and a component dependency under the hood?

Tier: Less commonDifficulty: Hard

A subcomponent inherits its parent's entire graph automatically, every binding in the parent is visible to it, and under the hood Dagger generates the subcomponent as an inner class of the parent's generated component, sharing its instance state directly.

@Subcomponent
interface LoginComponent {
    fun inject(activity: LoginActivity) // sees everything AppComponent provides
}

A component dependency is the opposite, deliberately narrow. The child component only gets access to whatever the parent explicitly exposes through methods declared in its interface, nothing else leaks through, and Dagger generates it as a fully separate, standalone component with its own generated class.

@Component
interface AppComponent {
    fun exposedRepository(): UserRepository // only this is visible to dependents
}

@Component(dependencies = [AppComponent::class])
interface FeatureComponent

The practical difference, a subcomponent is tightly coupled to its parent, created through a method on the parent and unusable without it, which is why it's the right choice for a natural child scope, like a login flow living inside the app scope. A component dependency is loosely coupled, any component exposing the right methods can be a dependency, which is what you reach for when two components need to be built and tested independently of each other, or come from separate modules in a multi-module project.