evant / kotlin-inject

Dependency injection lib for kotlin
Apache License 2.0
1.14k stars 51 forks source link

Component dependencies resolution fails when intermediate component is hidden behind an interface #311

Closed dimsuz closed 9 months ago

dimsuz commented 9 months ago

I will illustrate my problem with a sample. I tried to minimize it, but it's still quite verbose and has 3 components, sorry :)

The crux of the problem is that due to modularity concerns I want to hide a component impl inside a separate module and provide only a component interface, and if I do that dependency resolution breaks and kotlin-inject outputs an error. I'm not sure if this is a bug or I do something wrong.

import me.tatarka.inject.annotations.*

@Scope
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER)
annotation class NetworkScope
@Scope
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER)
annotation class AppScope
@Scope
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER)
annotation class ActivityScope

@NetworkScope
@Component
abstract class NetworkComponent {
  @Provides
  @NetworkScope
  fun repositoryCount(): Int = 42
}

@Inject
@NetworkScope
class CountHolder(val repositoryCount: Int)

// I want to hide this component behind an interface which will be 
// in a different gradle module, separate from the module with an impl
@AppScope
interface AppComponent

@Component
abstract class AppComponentImpl(
  @Component
  val networkComponent: NetworkComponent
) : AppComponent

@ActivityScope
@Component
abstract class ActivityComponent(
  @Component
  val appComponent: AppComponent // LINE A
) {
  abstract val viewModel: MainViewModel
}

@ActivityScope
@Inject
class MainViewModel(val countHolder: CountHolder)

fun run() {
  val activityComponent = ActivityComponent::class.create(
    appComponent = AppComponentImpl::class.create(
      networkComponent = NetworkComponent::class.create()
    )
  )
  println(activityComponent.viewModel.countHolder.repositoryCount)
}

If you insert this sample in IDE and compile, you'll get an error:

Cannot find component with scope: @org.example.kotlin.inject.test.NetworkScope to inject org.example.kotlin.inject.test.CountHolder checked: [@org.example.kotlin.inject.test.ActivityScope org.example.kotlin.inject.test.ActivityComponent, @org.example.kotlin.inject.test.AppScope org.example.kotlin.inject.test.AppComponent]

BUT if you go to the line marked "LINE A" and change AppComponent to AppComponentImpl, then compilation is successful and everything works.

But this defeats my goal of hiding AppComponentImpl in a module which no one depends on and having everyone depend on the module with interface AppComponent instead. Can I somehow achieve this?

I can't mark AppComponent interface with @Component (doesn't work), any other solutions maybe?

evant commented 9 months ago

What you are doing ends up hiding NetworkComponent from kotlin-inject so it can't find the NetworkScope. The code it generates looks roughly like this, which you can see the issue more clearly:

class InjectActivityComponent(@Component val appComponent: AppComponent) : ActivityComponent {
    override val viewModel: MainViewModel get() = MainViewModel(
         // if appCompont isn't exposing networkComponent it doesn't know how to get items from it's scope
         appComponent.networkComponent.get("CountHolder") { CounterHolder(networkComponent.respositoryCount()) }
    )
}

So you need to expose your NetworkComponent. You can also split it up with an interface/impl for some information hiding, but you'll need to make sure your dependencies are still reachable from it.

@NetworkScope
interface NetworkComponent {
     // needed so value can still be provided through the interface
     val repositoryCount: Int
}

@Component
abstract class NetworkComponentImpl {
  @Provides
  @NetworkScope
  fun repositoryCount(): Int = 42
} : NetworkComponent

@Inject
@NetworkScope
class CountHolder(val repositoryCount: Int)

@AppScope
abstract class AppComponent(@Component val networkComponent: NetworkComponent) 

@Component
abstract class AppComponentImpl(networkComponent: NetworkComponent) : AppComponent(networkComponent)

@ActivityScope
@Component
abstract class ActivityComponent(
  @Component
  val appComponent: AppComponent
) {
  abstract val viewModel: MainViewModel
}

@ActivityScope
@Inject
class MainViewModel(val countHolder: CountHolder)

fun run() {
  val activityComponent = ActivityComponent::class.create(
    appComponent = AppComponentImpl::class.create(
      networkComponent = NetworkComponentImpl::class.create()
    )
  )
  println(activityComponent.viewModel.countHolder.repositoryCount)
}

(note: haven't actually tested above, lmk if something's wrong)

dimsuz commented 9 months ago

This was the part I was so close to and needed, but never grasped:

@AppScope
abstract class AppComponent(@Component val networkComponent: NetworkComponent) 

I suspected that I need to mention networkComponent in AppComponent somehow, but my thinking was stuck on it being an interface and I couldn't see how to do that.

Right! abstract class!

Great, thank you very much, I'll test it tomorrow at work!

I'll close the issue as it is clear now that it's not a bug, and if I have further questions I'll ask them below :pray: