Manual Dependency Injection in Android. A Beginner guide.

Kaushal Vasava
10 min readAug 24, 2023

--

I will show you how can you do manual dependency injection and why we need any library for it.

What is Dependency Injection?

Dependency injection is basically providing the objects that an object needs (its dependencies) instead of having it construct them itself. It’s a very useful technique for testing, since it allows dependencies to be mocked or stubbed out.

Dependencies can be injected into objects by many means (such as constructor injection or setter injection). One can even use specialized dependency injection frameworks (i.e. Dagger-Hilt, Koin, etc.) to do that, but they certainly aren’t required. You don’t need those frameworks to have dependency injection. Instantiating and passing objects (dependencies) explicitly is just as good an injection as injection by framework.

Classes often require references to other classes. For example, a Car class might need a reference to an Engine class. These required classes are called dependencies, and in this example the Car class is dependent on having an instance of the Engine class to run.

There are three ways for a class to get an object it needs:

  1. The class constructs the dependency it needs. In the example above, Car would create and initialize its own instance of Engine.
  2. Grab it from somewhere else. Some Android APIs, such as Context getters and getSystemService(), work this way.
  3. Have it supplied as a parameter. The app can provide these dependencies when the class is constructed or pass them in to the functions that need each dependency. In the example above, the Car constructor would receive Engine as a parameter.

The third option is dependency injection! With this approach you take the dependencies of a class and provide them rather than having the class instance obtain them itself.

Here’s an example. Without dependency injection, representing a Car that creates its own Engine dependency in code looks like this:

class Car {
private val engine = Engine()

fun start() {
engine.start()
}
}

fun main(args: Array) {
val car = Car()
car.start()
}

This is not an example of dependency injection because the Car class is constructing its own Engine.

This can be problematic because:

  • Car and Engine are tightly coupled - an instance of Car uses one type of Engine, and no subclasses or alternative implementations can easily be used. If the Car were to construct its own Engine, you would have to create two types of Car instead of just reusing the same Car for engines of type Gas and Electric.
  • The hard dependency on Engine makes testing more difficult. Car uses a real instance of Engine, thus preventing you from using a test double to modify Engine for different test cases.

What does the code look like with dependency injection? Instead of each instance of Car constructing its own Engine object on initialization, it receives an Engine object as a parameter in its constructor:

class Car(private val engine:Engine){
fun start() {
engine.start()
}
}

fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}

The main function uses Car. Because Car depends on Engine, the app creates an instance of Engine and then uses it to construct an instance of Car.

The benefits of this DI-based approach are:

  • Reusability of Car. You can pass in different implementations of Engine to Car. For example, you might define a new subclass of Engine called ElectricEngine that you want Car to use. If you use DI, all you need to do is pass in an instance of the updated ElectricEngine subclass, and Car still works without any further changes.
  • Easy testing of Car. You can pass in test doubles to test your different scenarios. For example, you might create a test double of Engine called FakeEngine and configure it for different tests.
  • Ease of refactoring code by injecting dependencies into a class, it becomes possible to replace them. We are able to swap out dependencies without touching the class’s implementation.

There are two major ways to do dependency injection in Android:

  • Constructor Injection. This is the way described above. You pass the dependencies of a class to its constructor.
  • Field Injection (or Setter Injection). Certain Android framework classes such as activities and fragments are instantiated by the system, so constructor injection is not possible. With field injection, dependencies are instantiated after the class is created. The code would look like this:
class Car {
lateinit var engine: Engine

fun start() {
engine.start()
}
}

fun main(args: Array) {
val car = Car()
car.engine = Engine()
car.start()
}

Note: Dependency injection is based on the Inversion of Control principle in which generic code controls the execution of specific code.

How can we do Manual Dependency injection?

Let’s Consider a flow to be a group of screens in your app that correspond to a feature. Login, registration, and checkout are all examples of flows.

When covering a login flow for a typical Android app, the LoginActivity depends on LoginViewModel, which in turn depends on UserRepository. Then UserRepository depends on a UserLocalDataSource and a UserRemoteDataSource, which in turn depends on a Retrofit service.

LoginActivity is the entry point to the login flow and the user interacts with the activity. Thus, LoginActivity needs to create the LoginViewModel with all its dependencies.

The Repository and DataSource classes of the flow look like this:

class UserRepository(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
private val loginService: LoginRetrofitService
) { ... }

Here’s what LoginActivity looks like:

class LoginActivity: Activity() {

private lateinit var loginViewModel: LoginViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// In order to satisfy the dependencies of LoginViewModel, you have to also
// satisfy the dependencies of all of its dependencies recursively.
// First, create retrofit which is the dependency of UserRemoteDataSource
val retrofit = Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(LoginService::class.java)

// Then, satisfy the dependencies of UserRepository
val remoteDataSource = UserRemoteDataSource(retrofit)
val localDataSource = UserLocalDataSource()

// Now you can create an instance of UserRepository that LoginViewModel needs
val userRepository = UserRepository(localDataSource, remoteDataSource)

// Lastly, create an instance of LoginViewModel with userRepository
loginViewModel = LoginViewModel(userRepository)
}
}

There are issues with this approach:

  1. There’s a lot of boilerplate code. If you wanted to create another instance of LoginViewModel in another part of the code, you'd have code duplication.
  2. Dependencies have to be declared in order. You have to instantiate UserRepository before LoginViewModel in order to create it.
  3. It’s difficult to reuse objects. If you wanted to reuse UserRepository across multiple features, you'd have to make it follow the singleton pattern. The singleton pattern makes testing more difficult because all tests share the same singleton instance.

Managing dependencies with a container

To solve the issue of reusing objects, you can create your own dependencies container class that you use to get dependencies. All instances provided by this container can be public. In the example, because you only need an instance of UserRepository, you can make its dependencies private with the option of making them public in the future if they need to be provided:

// Container of objects shared across the whole app
class AppContainer {

// Since you want to expose userRepository out of the container, you need to satisfy
// its dependencies as you did before
private val retrofit = Retrofit.Builder()
.baseUrl("https://example.com")
.build()
.create(LoginService::class.java)

private val remoteDataSource = UserRemoteDataSource(retrofit)
private val localDataSource = UserLocalDataSource()

// userRepository is not private; it'll be exposed
val userRepository = UserRepository(localDataSource, remoteDataSource)
}

Because these dependencies are used across the whole application, they need to be placed in a common place all activities can use: the Application class. Create a custom Application class that contains an AppContainer instance.

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

// Instance of AppContainer that will be used by all the Activities of the app
val appContainer = AppContainer()
}

Note: AppContainer is just a regular class with a unique instance shared across the app placed in your Application class. However, AppContainer is not following the singleton pattern; in Kotlin, it's not an object, and in Java, it's not accessed with the typical Singleton.getInstance() method.

Now you can get the instance of the AppContainer from the application and obtain the shared of UserRepository instance:

class LoginActivity: Activity() {

private lateinit var loginViewModel: LoginViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Gets userRepository from the instance of AppContainer in Application
val appContainer = (application as MyApplication).appContainer
loginViewModel = LoginViewModel(appContainer.userRepository)
}
}

In this way, you don’t have a singleton UserRepository. Instead, you have an AppContainer shared across all activities that contains objects from the graph and creates instances of those objects that other classes can consume.

If LoginViewModel is needed in more places in the application, having a centralized place where you create instances of LoginViewModel makes sense. You can move the creation of LoginViewModel to the container and provide new objects of that type with a factory. The code for a LoginViewModelFactory looks like this:

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
override fun create(): LoginViewModel {
return LoginViewModel(userRepository)
}
}

You can include the LoginViewModelFactory in the AppContainer and make the LoginActivity consume it:

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
class AppContainer {
...
val userRepository = UserRepository(localDataSource, remoteDataSource)

val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class LoginActivity: Activity() {

private lateinit var loginViewModel: LoginViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Gets LoginViewModelFactory from the application instance of AppContainer
// to create a new LoginViewModel instance
val appContainer = (application as MyApplication).appContainer
loginViewModel = appContainer.loginViewModelFactory.create()
}
}

This approach is better than the previous one, but there are still some challenges to consider:

  1. You have to manage AppContainer yourself, creating instances for all dependencies by hand.
  2. There is still a lot of boilerplate code. You need to create factories or parameters by hand depending on whether you want to reuse an object or not.

Managing dependencies in application flows

AppContainer gets complicated when you want to include more functionality in the project. When your app becomes larger and you start introducing different feature flows, there are even more problems that arise:

  1. When you have different flows, you might want objects to just live in the scope of that flow. For example, when creating LoginUserData (that might consist of the username and password used only in the login flow) you don't want to persist data from an old login flow from a different user. You want a new instance for every new flow. You can achieve that by creating FlowContainer objects inside the AppContainer as demonstrated in the next code example.
  2. Optimizing the application graph and flow containers can also be difficult. You need to remember to delete instances that you don’t need, depending on the flow you’re in.

Imagine you have a login flow that consists of one activity (LoginActivity) and multiple fragments (LoginUsernameFragment and LoginPasswordFragment). These views want to:

  1. Access the same LoginUserData instance that needs to be shared until the login flow finishes.
  2. Create a new instance of LoginUserData when the flow starts again.

You can achieve that with a login flow container. This container needs to be created when the login flow starts and removed from memory when the flow ends.

Let’s add a LoginContainer to the example code. You want to be able to create multiple instances of LoginContainer in the app, so instead of making it a singleton, make it a class with the dependencies the login flow needs from the AppContainer.

class LoginContainer(val userRepository: UserRepository) {

val loginData = LoginUserData()

val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
...
val userRepository = UserRepository(localDataSource, remoteDataSource)

// LoginContainer will be null when the user is NOT in the login flow
var loginContainer: LoginContainer? = null
}

Once you have a container specific to a flow, you have to decide when to create and delete the container instance. Because your login flow is self-contained in an activity (LoginActivity), the activity is the one managing the lifecycle of that container. LoginActivity can create the instance in onCreate() and delete it in onDestroy().

class LoginActivity: Activity() {

private lateinit var loginViewModel: LoginViewModel
private lateinit var loginData: LoginUserData
private lateinit var appContainer: AppContainer


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appContainer = (application as MyApplication).appContainer

// Login flow has started. Populate loginContainer in AppContainer
appContainer.loginContainer = LoginContainer(appContainer.userRepository)

loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
loginData = appContainer.loginContainer.loginData
}

override fun onDestroy() {
// Login flow is finishing
// Removing the instance of loginContainer in the AppContainer
appContainer.loginContainer = null
super.onDestroy()
}
}

Like LoginActivity, login fragments can access the LoginContainer from AppContainer and use the shared LoginUserData instance.

Because in this case you’re dealing with view lifecycle logic, using lifecycle observation makes sense.

Note: If you need the container to survive configuration changes, follow the Saving UI States guide. You need to handle it the same way you handle process death; otherwise, your app might lose state on devices with less memory.

Conclusion and why we need library for DI?

Dependency injection is a good technique for creating scalable and testable Android apps. Use containers as a way to share instances of classes in different parts of your app and as a centralized place to create instances of classes using factories.

When your application gets larger, you will start seeing that you write a lot of boilerplate code (such as factories), which can be error-prone. You also have to manage the scope and lifecycle of the containers yourself, optimizing and discarding containers that are no longer needed in order to free up memory. Doing this incorrectly can lead to subtle bugs and memory leaks in your app.

Because of such issues, we need library that can manage lifecycle scope automatically and providing containers for every Android class in your project. Popular libraries are Dagger, Hilt, Koin, etc.

Thank you for reading this article! Don’t forget to clap only if you think I deserve it👏 and Follow me for more such useful articles about Android Developement.

If you have any query related to Android, I’m always happy to help you. You can reach me on LinkedIn, Twitter , Github and Instagram.

Happy Learning🚀 Happy Coding💻

--

--

Kaushal Vasava

Android Developer | Kotlin | Jetpack Compose | Kotlin MultiPlatform | LinkedIn's Top Voice (3K+ Followers) | Apps with 100K+ Downloads on the Playstore