Kahibaro
Discord Login Register

23 Dependency Injection

Understanding Dependency Injection in Android

Dependency Injection, often shortened to DI, is a way to give an object everything it needs to work without that object creating those things by itself. In Android apps this becomes more important as your project grows, because many classes start to depend on the same objects, such as network clients, repositories, or configuration helpers. If each class creates its own dependencies, your code becomes hard to test, hard to change, and easy to break.

This chapter introduces the idea of Dependency Injection in a practical way, so that when you later use a DI framework such as Hilt you already understand what problem it solves and why it is useful in Android.

What Is a Dependency

A dependency is simply another object or component that a class needs to do its work. For example, if you have a UserRepository that talks to a remote server using ApiService, then UserRepository depends on ApiService.

In code, this often looks like the repository needing an instance of another class.

class ApiService {
    fun getUser(id: String): String {
        return "User $id"
    }
}
class UserRepository {
    private val apiService = ApiService()
    fun loadUser(id: String): String {
        return apiService.getUser(id)
    }
}

Here, UserRepository depends on ApiService. It also creates the ApiService instance inside itself. This is convenient for very small examples, but it creates problems as the application grows.

Problems With Creating Dependencies Inside Classes

When a class creates its own dependencies, it becomes tightly connected to specific implementations. This affects your Android apps in several ways.

First, it becomes harder to test. In the previous example, if you want to test UserRepository, you might want to provide a fake ApiService that returns predictable data without calling a real server. Because UserRepository always creates a real ApiService, you cannot easily replace it with a fake one.

Second, it becomes harder to change implementations. Maybe you want to switch from one networking library to another, or use a different data source in debug builds. If many classes create their own instances with ApiService(), you have to update code in many places.

Third, it makes configuration and resource sharing more complicated. In Android, you often want a single instance of something such as OkHttpClient or a database. If each class creates its own, you waste memory and may introduce bugs, for example by having multiple databases with different states.

Finally, object creation itself may depend on Android context or other values. For example, SharedPreferences need a Context. If you create these in many places using context.getSharedPreferences(...), you spread context handling throughout your code and it becomes easy to leak references or misuse different contexts.

All these issues motivate the use of Dependency Injection.

The Core Idea of Dependency Injection

The key idea behind Dependency Injection is that an object should not create its own dependencies. Instead, those dependencies are given to the object from the outside. In other words, they are injected.

Important rule: A class should receive its dependencies from the outside, not create them by itself.

This keeps each class focused on its main responsibility, and avoids mixing business logic with object creation logic.

There are several ways to inject dependencies, but they all follow the same principle. The class declares what it needs, and something else is responsible for building and providing those needs.

Constructor Injection

Constructor injection is the most common and simplest form of Dependency Injection, especially in Kotlin. Instead of creating its own ApiService, the UserRepository declares that it requires an ApiService and receives it through its constructor.

class ApiService {
    fun getUser(id: String): String {
        return "User $id"
    }
}
class UserRepository(private val apiService: ApiService) {
    fun loadUser(id: String): String {
        return apiService.getUser(id)
    }
}

Here, UserRepository no longer decides how to build ApiService. It simply receives an ApiService instance from whoever creates the repository.

To create a UserRepository, you now do the following.

val apiService = ApiService()
val userRepository = UserRepository(apiService)

Constructor injection has some advantages that matter a lot in Android development. First, dependencies are clearly visible. You can look at the constructor and immediately see what this class needs. Second, it encourages immutability, because dependencies are often stored in val properties and do not change after construction. Third, it makes testing easy. In a test, you can pass a fake or mock ApiService without touching production code.

Property and Method Injection

Constructor injection is preferred, but sometimes it is not possible, especially in Android components that the system creates for you, such as Activity, Fragment, or Service. In those cases, you cannot freely change the constructor. Dependency Injection can still be done using properties or methods.

With property injection, a dependency is assigned directly to a property after object creation. This is often handled by a DI framework, but conceptually it looks like this.

class UserRepository {
    lateinit var apiService: ApiService
    fun loadUser(id: String): String {
        return apiService.getUser(id)
    }
}
val repository = UserRepository()
repository.apiService = ApiService()

With method injection, you pass dependencies through methods that need them.

class UserRepository {
    fun loadUser(id: String, apiService: ApiService): String {
        return apiService.getUser(id)
    }
}

Property and method injection are useful in some Android situations, but they make dependencies less obvious than constructor injection. You must be careful to initialize everything correctly before you call methods that rely on injected properties.

Who Creates the Dependencies

Once you start applying Dependency Injection, new questions appear. If classes no longer create their dependencies, where are these dependencies created, and who decides which implementation to use?

At first, you can manually create and connect objects in one place. In small examples this might be in your Application class or in a simple helper object.

For instance, you can have a simple object responsible for wiring together your dependencies.

object AppContainer {
    val apiService = ApiService()
    val userRepository = UserRepository(apiService)
}

Then, anywhere in the app, you can reuse the same userRepository or apiService instance by accessing them through AppContainer. This approach centralizes object creation and makes it easier to share instances, but it is still manual wiring. As your app grows and you have many dependencies, this wiring becomes repetitive and hard to manage. It is easy to introduce mistakes such as using the wrong instance or forgetting to update all places when a constructor changes.

In complex Android projects, a Dependency Injection framework automates much of this work.

Why Dependency Injection Matters in Android

Android projects face specific challenges that make Dependency Injection particularly useful.

First, you have many components that are created by the system: Activities, Fragments, Services, BroadcastReceivers, and so on. These components often need access to the same objects, for example repositories, network clients, or analytics trackers. DI provides a consistent way to give them those objects without duplicating setup code.

Second, lifecycles are complex. Some objects should live as long as the entire application, others only as long as a screen, and others only during a background task. DI helps manage different lifetimes by deciding when to create and when to reuse instances. This concept is usually called scopes and you will meet it when using DI tools.

Third, testing Android apps is much easier when most logic is in plain Kotlin classes that receive their dependencies. Activities and Fragments can delegate work to ViewModels and repositories that are simple to construct with test doubles. DI encourages this architecture and makes test setup cleaner.

Finally, modern Android architectures such as MVVM usually rely on clear separation of layers: UI, domain, and data. Dependency Injection fits naturally here, because each layer receives the services it needs without needing to know how they are created or where they come from.

Manual DI vs Framework-based DI

It is important to understand that Dependency Injection is a pattern, not a tool. You can apply DI by hand with simple constructors and factories, or you can use a framework that helps you manage everything.

Manual DI means you explicitly write the code that constructs and connects all objects. For small projects this is simple and often enough. You get full control and there is no extra library to learn. However, as soon as you have many modules and different lifecycles, the manual approach becomes hard to maintain.

Framework-based DI uses a library to create and inject dependencies for you. In Android, common DI frameworks include Dagger, Hilt, and Koin. They help you by generating object graphs, handling scopes, and injecting into Android framework classes.

Even when you use a framework, the underlying ideas remain the same. Your classes should still receive dependencies rather than create them. Understanding this pattern first will make it much easier to learn specific tools later.

Common DI Terms in Android

When you start using Dependency Injection frameworks, you will encounter several recurring terms that build on the concepts introduced here.

A dependency graph is a mental picture of all objects in your app and how they depend on each other. A DI framework keeps track of this, so it knows how to build each object.

A module or provider is a part of your configuration that describes how to create dependencies. Instead of writing val apiService = ApiService() yourself in many places, you describe once how to build ApiService, then the framework uses that description whenever needed.

A scope defines how long an instance should live. In Android, you may have an application scope, an activity scope, or a fragment scope. This means an object might be created once per application, once per activity, or once per fragment.

All these ideas derive directly from the basic principle that classes receive dependencies, and that something else builds and manages those dependencies for you.

Preparing for Hilt and Other Tools

Later, when you learn Hilt, you will see annotations that look like instructions for the DI framework. They essentially say things like "this class can receive dependencies using constructor injection" or "this function tells the framework how to build a specific object."

The core pattern does not change. You will still aim to write classes that clearly state what they need in their constructor or properties. The DI framework will then handle the actual wiring, especially for Android specific components that the system creates.

By focusing on these ideas early, you will be able to understand Hilt, or any other DI tool, as a helper that automates object creation and injection, rather than as something mysterious.

Views: 2

Comments

Please login to add a comment.

Don't have an account? Register now!