Photo by Andreas Gücklhorn on Unsplash
In this article, we’ll explore the recommended approach for implementing Room in Kotlin Multiplatform (KMP) with Koin for dependency injection and the motivations behind each decision.
To visualise the Room implementation, we’ll build a screen using Compose Multiplatform (CMP) and launch the app on Android and iOS.
Getting started
To begin, we add the required dependencies to our libs.versions.toml file.
| [versions] | |
| room = "2.7.0-alpha13" | |
| ksp = "2.1.10-1.0.29" | |
| sqlite = "2.4.0" | |
| koin = "4.0.0" | |
| [libraries] | |
| androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } | |
| androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } | |
| sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } | |
| koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } | |
| koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } | |
| koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } | |
| koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } | |
| [plugins] | |
| ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } | |
| room = { id = "androidx.room", version.ref = "room" } |
We then use these dependencies in our build.gradle.kts file, alongside using the Room plugin to declare the database schema directory.
| plugins { | |
| alias(libs.plugins.room) | |
| alias(libs.plugins.ksp) | |
| } | |
| kotlin { | |
| sourceSets { | |
| androidMain.dependencies { | |
| implementation(compose.preview) | |
| implementation(libs.androidx.activity.compose) | |
| implementation(libs.koin.android) | |
| } | |
| commonMain.dependencies { | |
| implementation(libs.androidx.room.runtime) | |
| implementation(libs.sqlite.bundled) | |
| api(libs.koin.core) | |
| implementation(libs.koin.compose) | |
| implementation(libs.koin.compose.viewmodel) | |
| } | |
| } | |
| } | |
| dependencies { | |
| add("kspAndroid", libs.androidx.room.compiler) | |
| add("kspIosSimulatorArm64", libs.androidx.room.compiler) | |
| add("kspIosX64", libs.androidx.room.compiler) | |
| add("kspIosArm64", libs.androidx.room.compiler) | |
| } | |
| room { | |
| schemaDirectory("$projectDir/schemas") | |
| } |
Room setup
In common code, we create an entity to define the structure of the database table. In this article, we’re storing a list of movies.
| @Entity | |
| data class Movie( | |
| @PrimaryKey(autoGenerate = true) | |
| val id: Long = 0L, | |
| val name: String, | |
| ) |
Next, we set up a MovieDao to interact with the database. Using Flow makes the movie list reactive, and suspend functions ensure we don’t block the UI thread during database operations.
| @Dao | |
| interface MovieDao { | |
| @Query("SELECT * FROM movie") | |
| fun getMovies(): Flow<List<Movie>> | |
| @Insert | |
| suspend fun insert(movie: Movie) | |
| @Query("DELETE FROM movie") | |
| suspend fun deleteMovies() | |
| } |
Still in common code, we create an abstract class that extends RoomDatabase and incorporates the entity and DAO. We also define a database constructor and link this to the database using the @ConstructedBy annotation.
| @Database(entities = [Movie::class], version = 1) | |
| @ConstructedBy(MovieDatabaseConstructor::class) | |
| abstract class MovieDatabase: RoomDatabase() { | |
| abstract fun getMovieDao(): MovieDao | |
| } | |
| // Room compiler generates the `actual` implementations | |
| @Suppress("NO_ACTUAL_FOR_EXPECT") | |
| expect object MovieDatabaseConstructor : RoomDatabaseConstructor<MovieDatabase> { | |
| override fun initialize(): MovieDatabase | |
| } |
The Room compiler will generate the actual implementations of the database constructor for us, so we add a @Suppress annotation to ignore any warnings related to this.
Database builder
The database requires a builder, and this is the only component in Room for KMP that requires platform-specific logic.
In androidMain, we create a function that takes in an Android Context to define a database path and uses this to return a database builder.
| fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<MovieDatabase> { | |
| val appContext = context.applicationContext | |
| val dbFile = appContext.getDatabasePath("movie_database.db") | |
| return Room.databaseBuilder<MovieDatabase>( | |
| context = appContext, | |
| name = dbFile.absolutePath, | |
| ) | |
| } |
Similarly, in iosMain we create a function that uses NSFileManager and NSDocumentDirectory to define a database path and return a database builder.
| fun getDatabaseBuilder(): RoomDatabase.Builder<MovieDatabase> { | |
| val dbFilePath = documentDirectory() + "/movie_database.db" | |
| return Room.databaseBuilder<MovieDatabase>( | |
| name = dbFilePath, | |
| ) | |
| } | |
| @OptIn(ExperimentalForeignApi::class) | |
| private fun documentDirectory(): String { | |
| val documentDirectory = NSFileManager.defaultManager.URLForDirectory( | |
| directory = NSDocumentDirectory, | |
| inDomain = NSUserDomainMask, | |
| appropriateForURL = null, | |
| create = false, | |
| error = null, | |
| ) | |
| return requireNotNull(documentDirectory?.path) | |
| } |
Database creation
Back in commonMain, we define a function that takes in the platform-specific database builders and creates the database. For the database driver, we use the BundledSQLiteDriver — this is the recommended driver for Room KMP as it provides the most consistent and up-to-date version of SQLite across all platforms. The BundledSQLiteDriver also has the benefit of being usable in common code, which means we don’t have to specify a driver for each platform.
| fun getMovieDatabase(builder: RoomDatabase.Builder<MovieDatabase>): MovieDatabase { | |
| return builder | |
| .setDriver(BundledSQLiteDriver()) | |
| .setQueryCoroutineContext(Dispatchers.IO) | |
| .build() | |
| } |
We also configure the database to use Dispatchers.IO for executing asynchronous queries, which is the recommended Dispatcher for database IO operations and ensures the queries won’t block the UI thread.
Koin setup
The final part of this Room KMP setup is using Koin to tie everything together. To start, we create a commonModule in commonMain to manage shared dependencies.
| fun commonModule(): Module = module { | |
| single<MovieDao> { get<MovieDatabase>().getMovieDao() } | |
| } |
For platform-specific dependencies, we create a platformModule in commonMain using the expect / actual mechanism.
| expect fun platformModule(): Module |
We implement this platformModule in androidMain using a provided Context value to create the database.
| actual fun platformModule(): Module = module { | |
| single<MovieDatabase> { | |
| val builder = getDatabaseBuilder(context = get()) | |
| getMovieDatabase(builder) | |
| } | |
| } |
Implementing the platformModule in iosMain is simpler since it does not require a Context value.
| actual fun platformModule(): Module = module { | |
| single<MovieDatabase> { | |
| val builder = getDatabaseBuilder() | |
| getMovieDatabase(builder) | |
| } | |
| } |
Initialising Koin
Next, we define functions to initialise Koin on both platforms in our common code. As seen above, our Android platformModule requires a Context for the database builder. To provide this, we add a KoinAppDeclaration parameter to our initKoin function. We use this inside the startKoin function, which gives Koin modules access to the Context value.
| fun initKoin(appDeclaration: KoinAppDeclaration = {}) { | |
| startKoin { | |
| appDeclaration() | |
| modules( | |
| commonModule() + platformModule() | |
| ) | |
| } | |
| } |
We then create a new class in androidMain that extends Application and calls the initKoin function, passing the Android Context in.
| class MainApplication : Application() { | |
| override fun onCreate() { | |
| super.onCreate() | |
| initKoin( | |
| appDeclaration = { androidContext(this@MainApplication) }, | |
| ) | |
| } | |
| } |
To use this new MainApplication class, we are required to update the AndroidManifest.xml file.
| <?xml version="1.0" encoding="utf-8"?> | |
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | |
| <application | |
| android:name=".MainApplication" | |
| <!-- Rest of manifest --> | |
| </application> | |
| </manifest> |
Now we can define a function to initialise Koin for iOS, which doesn’t require a Context value. We encounter a quirk in KMP here, as function default values do not work in native iOS code, so we can’t simply call the initKoin function. To solve this, we define an initKoinIos function that passes in an empty lambda value for the appDeclaration parameter.
| fun initKoinIos() = initKoin(appDeclaration = {}) |
The initKoinIos function has to be called in native Swift code. To do this, we use the file name of the function and the function name with the do value prepended. We also import ComposeApp to give the Swift code access to the function.
| import SwiftUI | |
| import ComposeApp | |
| @main | |
| struct iOSApp: App { | |
| init() { | |
| KoinKt.doInitKoinIos() | |
| } | |
| var body: some Scene { | |
| WindowGroup { | |
| ContentView() | |
| } | |
| } | |
| } |
Complete Room
That’s it! We can now inject the MovieDao in common code, giving us access to our Room database on both platforms.
Crafting a UI
To visualise the Room implementation, we’ll build a movie list screen using Compose Multiplatform and launch the app on both Android and iOS, all within our common code.
We start by defining a MovieUiState for the screen, which holds a movie name the user can enter, and a list of movies to display. For the movie name, we use the recommended TextFieldValue instead of a simple String value.
| data class MovieUiState( | |
| val movieName: TextFieldValue = TextFieldValue(""), | |
| val movies: List<Movie> = emptyList() | |
| ) |
Next, we create a MovieViewModel and inject our MovieDao in. The MovieDao is injected straight into the ViewModel here to keep things simple for this article. In production code, the app layering would be more robust, and the MovieDao would be injected into a repository or a data source.
We also add a private MutableStateFlow backing field to store the movie name value.
| class MovieViewModel(private val movieDao: MovieDao): ViewModel() { | |
| private val _movieName = MutableStateFlow(TextFieldValue("")) | |
| } |
State production
To produce the UI state, we combine the Flow list of movies with the MutableStateFlow movie name field.
| class MovieViewModel(private val movieDao: MovieDao): ViewModel() { | |
| private val _movieName = MutableStateFlow(TextFieldValue("")) | |
| val uiState: StateFlow<MovieUiState> = combine( | |
| movieDao.getMovies(), | |
| _movieName | |
| ) { movies, movieText -> | |
| MovieUiState(movieName = movieText, movies = movies) | |
| }.stateIn( | |
| scope = viewModelScope, | |
| started = SharingStarted.WhileSubscribed(5000), | |
| initialValue = MovieUiState() | |
| ) | |
| } |
The stateIn operator is the recommended way to produce UI state from reactive streams. A key reason for this is because it allows state production to start only when collection begins in the UI, instead of occurring as soon as the ViewModel is created if the init{} function is used. This gives you more control over the ViewModel and uiState, making it easier to test.
The stateIn operator also gives us finer-grained control over the state production behaviour through the started parameter. This can be set to either SharingStarted.WhileSubscribed if the state should only be active when the UI is visible, or SharingStarted.Lazily if the state should be active as long as the user may return to the UI.
Finalising the ViewModel
To complete the ViewModel, we provide three functions to update the state.
| class MovieViewModel(private val movieDao: MovieDao): ViewModel() { | |
| // ... | |
| fun updateMovieName(newText: TextFieldValue) { | |
| _movieName.value = newText | |
| } | |
| fun insertMovie(movieName: String) { | |
| viewModelScope.launch { | |
| movieDao.insert(Movie(name = movieName)) | |
| } | |
| } | |
| fun deleteMovies() { | |
| viewModelScope.launch { | |
| movieDao.deleteMovies() | |
| } | |
| } | |
| } |
We also add the ViewModel to our Koin commonModule, allowing us to inject it into our screen.
| fun commonModule(): Module = module { | |
| single<MovieDao> { get<MovieDatabase>().getMovieDao() } | |
| singleOf(::MovieViewModel) | |
| } |
Movie screen
With the ViewModel set up, the next step is to create the screen. It is recommended practice to create both a stateful and a stateless version of each screen in your app, as it makes them more reusable, easier to test, and simpler to preview.
We first create the stateful screen by injecting the ViewModel using Koin and collecting the UI state. We then pass the UI state and the state updating functions into the stateless screen.
| @Composable | |
| fun MovieScreen(viewModel: MovieViewModel = koinViewModel()) { | |
| val uiState by viewModel.uiState.collectAsStateWithLifecycle() | |
| MovieScreen( | |
| movies = uiState.movies, | |
| movieName = uiState.movieName, | |
| onUpdateMovieName = viewModel::updateMovieName, | |
| onAddMovie = viewModel::insertMovie, | |
| onDeleteMovies = viewModel::deleteMovies | |
| ) | |
| } |
We then create the stateless screen, using a Scaffold to ensure proper inset padding.
| @Composable | |
| fun MovieScreen( | |
| movies: List<Movie>, | |
| movieName: TextFieldValue, | |
| onUpdateMovieName: (TextFieldValue) -> Unit, | |
| onAddMovie: (String) -> Unit, | |
| onDeleteMovies: () -> Unit | |
| ) { | |
| Scaffold(modifier = Modifier.fillMaxSize()) { scaffoldPadding -> | |
| Column( | |
| verticalArrangement = Arrangement.spacedBy(16.dp), | |
| modifier = Modifier | |
| .padding(scaffoldPadding) | |
| .padding(16.dp) | |
| ) { | |
| // ... | |
| } | |
| } | |
| } |
Inside the Column, we add two Composables that enable the user to add a movie to the Room database.
| OutlinedTextField( | |
| value = movieName, | |
| onValueChange = { onUpdateMovieName(it) }, | |
| label = { Text(text = "Enter movie name") }, | |
| modifier = Modifier.fillMaxWidth() | |
| ) | |
| Button( | |
| onClick = { | |
| if (movieName.text.isNotBlank()) { | |
| onAddMovie(movieName.text) | |
| onUpdateMovieName(TextFieldValue("")) | |
| } | |
| }, | |
| modifier = Modifier.fillMaxWidth() | |
| ) { | |
| Text(text = "Add Movie") | |
| } |
To display the movies, we define a MovieItem and use this within a LazyColumn to create a scrollable list of movies.
| @Composable | |
| fun MovieItem( | |
| movie: Movie, | |
| modifier: Modifier = Modifier | |
| ) { | |
| Card( | |
| modifier = modifier | |
| .fillMaxWidth() | |
| .padding(4.dp), | |
| elevation = CardDefaults.cardElevation(4.dp) | |
| ) { | |
| Text( | |
| text = movie.name, | |
| modifier = Modifier.padding(16.dp) | |
| ) | |
| } | |
| } |
| LazyColumn(modifier = Modifier.weight(1f)) { | |
| items(movies) { movie -> | |
| MovieItem(movie) | |
| } | |
| } |
To clear the movies list, we create a button and hook this up to the onDeleteMovies function.
| Button( | |
| onClick = onDeleteMovies, | |
| colors = ButtonDefaults.buttonColors( | |
| containerColor = MaterialTheme.colorScheme.tertiary, | |
| ), | |
| modifier = Modifier.fillMaxWidth() | |
| ) { | |
| Text(text = "Delete Movies") | |
| } |
To make the MovieScreen reachable within the app, we simply add it to the base App Composable. In a production app, you would instead integrate this MovieScreen into your existing navigation logic.
| @Composable | |
| fun App() { | |
| MaterialTheme { | |
| MovieScreen() | |
| } | |
| } |
App deployment
We can now deploy the finished app to both platforms, starting with Android.

Running the app on iOS produces the same behaviour, and validates that the Room implementation is functioning correctly on both platforms! 🎉

Job Offers
Conclusion
That wraps up this article — I hope it has given you a better understanding of how to use Room in Kotlin Multiplatform with Koin.
You can find my app projects on GitHub — feel free to reach out with any questions or feedback.
Happy coding!
This article is previously published on proandroiddev.com.



