Skip to content

Dependency injection

Albert Pinto edited this page Jul 13, 2021 · 1 revision

It is usual in programming to use an instance of any class inside another one. One approach would consist of creating the instance in the constructor of the other one. For instance, suppose that we have a Car class that needs an Engine.

class Engine(val model: String)
class Car(modelName: String) {
    val engine = Engine(modelName)
}

This approach would cause coupling between the classes, which will difficult for the task to test them in isolation. In addition, we will generate a new instance of Engine every time we create one of Car, so the memory used would significantly increase.

Pattern definition

The dependency injection pattern allows us to decouple the implementation of the classes by passing the instance directly to the other one, without knowing how it is implemented. There are two types of dependency injection:

  • Constructor injection: the instance of the other class is passed to the class's constructor.
  • Field injection: the instance is injected after the class is created.
// Constructor injection
class Engine(val model: String)
class Car(val engine: Engine)
fun main() {
    val engine = Engine("A1B2")
    val car = Car(engine)
}

// Setter injection
class Engine(val model: String)
class Car {
    var engine: Engine? = null
}
fun main() {
    val engine = Engine("A1B2")
    val car = Car()
    car.engine = engine
}

Dependency injection in the project

In the project, we will use the Dagger Hilt library to inject the dependencies, which allows us to use either constructor or field injection. By convention, we will use constructor injection whenever it is possible since it is easier to test. The only place where we cannot use constructor injection is in activities and fragments since these classes are created by the system and do not allow parameters in their constructors. However, most of the presentation logic resides in the ViewModel; thus, the use of dependency injection in activities or fragment with Dagger Hilt will be insignificant. In fact, the only time we might be interested in injecting instance into activities or fragments would be when injecting a custom contract to start another activity for a result.

Key concepts

The project is already set up for using Dagger Hilt; however, we will take a look at the key concepts of this library, which are represented using annotations:

  • @HiltAndroidApp: every application that uses dagger hilt have to be annotated explicitly.
  • @AndroidentryPoint: each activity or fragment has to be annotated to enable the dependency injection of all the requirements by itself or for the other injected instances.
  • @HiltViewModel: viewmodels should be annotated with this annotation since they are created lazily.
  • @Module: file that contains the instances to be injected.
  • @InstallIn: specify the scope in which the module will be installed.
  • @Bind: injects an instance of a given type that do not require any parameters.
  • @Provides: injects an instance of a given type that have dependencies.
  • @Singleton: specify that the injected instance will always be the same.
  • @ActivityContext: injects the application context.

The use of injections with @Bind and @Provides at the same time is not allowed, so if an instance of the type is injected with @Provides, then all the dependencies have to be injected with the same annotation. In the project, we will always use @Provides with @Singleton.

Declaring providers

In the project, there are two modules defined: UseCaseProviders and DataProviders. The first will provide the different use cases, while the latter will provide the data access to the use cases. Both will be installed in the SingletonComponent, which is used by fragments. Since the data providers will be used in the use case ones, we need to include the first into the latter.

@Module(includes = [DataProviders::class])
@InstallIn(SingletonComponent::class)
class UseCaseProvider {

}

The signature of the functions determines the dependencies and the types that are provided declared in the module. For instance, we are going to declare a function to inject the SendName use case.

@Provides
@Singleton
fun provideSendName(
    nameRepository: NameRepository
) : SendName = SendNameImpl(nameRepository)

In this code, we provide an instance of SendName that has a NameRepository as a dependency. The dependency injector will look for a function that provides a NameRepository instance, and if it does not exists, the application will not compile. There is a common issue when using dependency injectors related to the return types of the provider functions. To exemplify this, we will change the signature of the function.

@Provides
@Singleton
fun provideSendName(
    nameRepository: NameRepository
) = SendNameImpl(nameRepository)

With this change, the application will not compile since the dependency injector will look for a provider that returns a SendName, and this one returns a SendNameImpl. To prevent this issue, we will always specify the return type of providers.

Declaring injections in classes

In order to use constructor injections in our classes, we have to annotate the constructor with @Inject.

class SendNameImpl @Inject constructor(
    private val nameRepository: NameRepository
) : SendName {
    override suspend fun execute(request: SendName.Request): UseCaseResult<SendName.Response> {
        // ...
    }
}

If we want to use the field injection inside a class, we only need to annotate the field with @Inject.

@Inject
private lateinit var googleSignInContract: GoogleSignInContract