Why is a Dependency Injection (DI) Framework Necessary?

May 03, 2025
For medium to large-scale apps, setting up a Dependency Injection (DI) framework early on is extremely important for maintaining a well-organized and maintainable codebase. If you’re unfamiliar with DI, here’s a relatable example.
Imagine you’re creating a Compose screen that displays a list of movies from a Room database. In a larger app, we might organize the codebase to decouple concerns and improve maintainability. For this, we’d typically need the following components:
  • Database (Room): To store and retrieve movies.
  • DAO: To define database operations.
  • Repository: To abstract the data source and provide a clean API for data access.
  • ViewModel: To manage UI logic and interact with the repository.
You may be interested in these articles as well:

Entity

The Movie class represents a table in the Room database.
@Entity(tableName = "movies") data class Movie( @PrimaryKey(autoGenerate = true) val id: Int = 0, val title: String, val director: String, val releaseYear: Int )

DAO

The MovieDao interface defines the database operations for the Movie entity.
@Dao interface MovieDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMovie(movie: Movie) @Query("SELECT * FROM movies") suspend fun getAllMovies(): List<Movie> }

Database

Define the Room database that provides the MovieDao.
@Database(entities = [Movie::class], version = 1, exportSchema = false) abstract class MovieDatabase : RoomDatabase() { abstract fun movieDao(): MovieDao }

Repository

The repository abstracts the data source, making it easier to swap out Room with another data source in the future.
class MovieRepository(private val movieDao: MovieDao) { suspend fun addMovie(movie: Movie) { movieDao.insertMovie(movie) } suspend fun getMovies(): List<Movie> { return movieDao.getAllMovies() } }

ViewModel

The MovieViewModel manages UI-related data and interacts with the MovieRepository.
class MovieViewModel(private val repository: MovieRepository) : ViewModel() { private var _movies = mutableStateOf<List<Movie>>(emptyList()) val movies: State<List<Movie>> = _movies init { loadMovies() } fun loadMovies() { viewModelScope.launch(Dispatchers.IO) { _movies.value = repository.getMovies() } } fun addMovie(title: String, director: String, releaseYear: Int) { viewModelScope.launch(Dispatchers.IO) { repository.addMovie(Movie(title = title, director = director, releaseYear = releaseYear)) loadMovies() } } }

Moment of truth…

With everything at hand, we can now make the Compose screen.
@Composable fun MovieScreen() { }
And we need the MovieViewModel instance here to show the list of movies and add new ones. How do we get it?
@Composable fun MovieScreen() { val database: MovieDatabase = Room.databaseBuilder(LocalContext.current, MovieDatabase::class.java, "movie_database") .fallbackToDestructiveMigration() .build() val movieDao: MovieDao = database.movieDao() val repository = MovieRepository(movieDao) val viewModel = MovieViewModel(repository) }
See how challenging it is to get the MovieViewModel?. Without a DI framework, every screen (e.g., MovieScreen) must manually handle the initialization of its required dependencies, like ViewModel, Repository, DAO, and Database.
If you have many screens, this obviously becomes a nightmare.
With a Dependency Injection framework like Koin this becomes as simple as:
fun MovieScreen(viewModel: MovieViewModel = koinViewModel()) { }
All you need is a dependency graph like this
val appModule = module { // Provide the database, it's singleton single { Room.databaseBuilder(get(), MovieDatabase::class.java, "movie_database") .fallbackToDestructiveMigration() .build() } // Provide the DAO single { get<MovieDatabase>().movieDao() } // Provide the Repository single { MovieRepository(get()) } // Provide the ViewModel viewModel { MovieViewModel(get()) } }
And then initialize Koin in the Application class.
import android.app.Application import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin class MyApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@MyApplication) modules(appModule) } } }

Here’s how it works:

We ask Koin to give the viewModel instance using koinViewModel():
val viewModel: MovieViewModel = koinViewModel()
Koin uses the type (MovieViewModel) to locate its declaration in the dependency graph, and finds the following definition:
viewModel { MovieViewModel(get()) }
Here, Koin creates and returns a MovieViewModel instance. Since MovieViewModel has a constructor parameter (repository), Koin resolves it by fetching the necessary dependency. When get() is used, it tries to get the appropriate instance based on the parameter type. In this case, it looks for a MovieRepository declaration and finds:
single { MovieRepository(get()) }
Koin applies the same process to create the MovieRepository instance, resolving its dependencies as needed (e.g., the MovieDao). This chain continues until all required dependencies are resolved and initialized automatically.
This could be the end of this tutorial, but

Here’s the thing though…

While the previous example demonstrates basic dependency injection, there’s a more robust way to implement it using interfaces and abstractions. This approach follows the Dependency Inversion Principle (DIP) from SOLID principles, which states that high-level modules should not depend on low-level modules, but both should depend on abstractions.
Let’s improve our implementation:
// Define interfaces for our abstractions interface IMovieRepository { suspend fun addMovie(movie: Movie) suspend fun getMovies(): List<Movie> } interface IMovieDao { suspend fun insertMovie(movie: Movie) suspend fun getAllMovies(): List<Movie> } // Implement the interfaces class MovieRepository(private val movieDao: IMovieDao) : IMovieRepository { // implementation } class MovieViewModel(private val repository: IMovieRepository) : ViewModel() { // ... implementation remains the same ... }
Then update our dependency declarations:
val appModule = module { // Bind interface to implementation single<IMovieDao> { get<MovieDatabase>().movieDao() } single<IMovieRepository> { MovieRepository(get()) } viewModel { MovieViewModel(get()) } }
This approach offers several benefits:
  1. Loose Coupling: Components depend on abstractions rather than concrete implementations
  2. Testability: Easy to create mock implementations for testing
  3. Flexibility: Can swap implementations without changing dependent code
  4. Maintainability: Clear separation of concerns and dependencies
For example, if you later decide to switch from Room to a different database solution, you only need to:
  1. Create a new implementation of IMovieDao
  2. Update the dependency declaration in appModule
The rest of your application code remains unchanged because it depends on the interface, not the implementation.
This pattern is particularly valuable in larger applications where you might need to:
  • Switch between different data sources (local database vs remote API, SQLite vs PostgreSQL etc.)
  • Create different implementations for different environments: development, testing, production. For example, you could use SQLite in development environment and PostgreSQL in production with minimal code changes.
  • Mock dependencies for unit testing
  • Gradually migrate from one implementation to another
Note: For the sake of easier comparison with the first example, I prefixed interface names have been used in the later example. While this is common in some languages, in Java/Kotlin, a preffered way is:
Interface: IMovieRepository -> MovieRepository Implementation: MovieRepository -> MovieRepositoryImpl