AppMama

blog / libraries

Essential Android Libraries for a New App

· 11 min read · AppMama

The essential Android libraries for a new Compose app are a short list: Room for structured data, DataStore for settings, Retrofit or Ktor with kotlinx.serialization for networking, Coil for images, Hilt or Koin for dependency injection, WorkManager for background work, Navigation for screens, Paging for long lists, and Timber for logs. You will not need all of them on day one, and each has a clear “skip it if…” case.

Below is what each library does, when to pick it, the minimal setup that works today, and the gotchas that cost people an afternoon. Versions shown were current in November 2026; check each library’s release page before copying them.

The short list at a glance

NeedDefault pickAlternativeSkip it when
Local databaseRoomSQLDelightYou only store a few settings
Settings / small stateDataStore—Never; replace SharedPreferences
HTTP clientRetrofitKtor clientThe app has no backend
JSONkotlinx.serializationMoshi—
ImagesCoilGlideOnly local vector icons
Dependency injectionHiltKoin, manualTiny app: a manual container is fine
Background workWorkManager—Nothing must survive the app closing
NavigationNavigation 3Navigation Compose 2.xSingle-screen app
Paged listsPaging 3Manual “load more”Lists under a few hundred items
LoggingTimberandroid.util.Log—

Room: the local database

Room wraps SQLite with compile-time checked queries, Flow return types that re-emit when tables change, and a migration system. Use it whenever you store lists of things: notes, messages, a cache of API responses. For a handful of preferences, use DataStore instead (the full comparison is in Room vs DataStore vs SharedPreferences).

Room 3 vs Room 2. Room 3.0 went stable in July 2026 under a new package, androidx.room3. It is Kotlin and coroutines only: KSP is required (no kapt), DAO functions must be suspend or return a reactive type like Flow, and you must set a SQLite driver. Room 2.8.x is still released under androidx.room. For a new app, start on Room 3; for an existing Room 2 app, there is no rush, and the two can coexist because the packages differ.

app/build.gradle.kts

plugins {
    id("com.google.devtools.ksp")
    id("androidx.room3")
}

room3 {
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    implementation("androidx.room3:room3-runtime:3.0.3")
    ksp("androidx.room3:room3-compiler:3.0.3")
}

Declare the androidx.room3 plugin with apply false in the root build file, as with any plugin. The Room Gradle plugin is optional, but use it: it writes your schema JSON into schemas/, which you should commit, because auto-migrations are generated by diffing those files.

@Entity
data class NoteEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val body: String,
    val updatedAt: Long,
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM NoteEntity ORDER BY updatedAt DESC")
    fun observeAll(): Flow<List<NoteEntity>>

    @Upsert
    suspend fun upsert(note: NoteEntity)
}

@Database(entities = [NoteEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
}

val db = Room.databaseBuilder<AppDatabase>(context, "app.db")
    .setDriver(AndroidSQLiteDriver())
    .build()

AndroidSQLiteDriver uses the device’s SQLite and comes in transitively. If you want the same SQLite version on every device (or share the database code with iOS via Kotlin Multiplatform), add androidx.sqlite:sqlite-bundled and use BundledSQLiteDriver() instead, at the cost of a larger APK. Build the database once and share it as a singleton.

DataStore: settings and small state

DataStore replaces SharedPreferences. It is asynchronous (no disk reads on the main thread), exposes a Flow, and updates are transactional. Use Preferences DataStore for key-value settings, or typed DataStore with a kotlinx.serialization Serializer when you want a schema.

// implementation("androidx.datastore:datastore-preferences:1.3.0")

val Context.settingsDataStore by preferencesDataStore(name = "settings")

private val DARK_THEME = booleanPreferencesKey("dark_theme")

class SettingsRepository @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    val darkTheme: Flow<Boolean> =
        context.settingsDataStore.data.map { it[DARK_THEME] ?: false }

    suspend fun setDarkTheme(enabled: Boolean) {
        context.settingsDataStore.edit { it[DARK_THEME] = enabled }
    }
}

The one rule that bites: never create two DataStore instances for the same file in one process. It throws an IllegalStateException. The top-level preferencesDataStore delegate exists precisely to keep it a singleton.

Retrofit vs Ktor: which HTTP client should you use?

Use Retrofit if your app is Android-only and talks to a REST API. You describe endpoints as an annotated interface, and it generates the calls on top of OkHttp, with suspend function support built in. Retrofit 3.0 is the current major version and keeps the same API.

// implementation("com.squareup.retrofit2:retrofit:3.0.0")
// implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0")

interface NotesApi {
    @GET("notes")
    suspend fun getNotes(): List<NetworkNote>

    @POST("notes")
    suspend fun create(@Body note: NetworkNote): NetworkNote
}

val json = Json { ignoreUnknownKeys = true }

val api: NotesApi = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()
    .create(NotesApi::class.java)

Use Ktor client if you share networking code with iOS or desktop through Kotlin Multiplatform, or you prefer building requests in code over annotations. It is fully Kotlin, coroutine-native, and lets you swap engines (OkHttp on Android, Darwin on iOS).

// implementation("io.ktor:ktor-client-okhttp:3.6.1")
// implementation("io.ktor:ktor-client-content-negotiation:3.6.1")
// implementation("io.ktor:ktor-serialization-kotlinx-json:3.6.1")

val client = HttpClient(OkHttp) {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

suspend fun getNotes(): List<NetworkNote> =
    client.get("https://api.example.com/notes").body()

Both are mature; the deciding question is Multiplatform. Do not add both to one app.

kotlinx.serialization: JSON without reflection

kotlinx.serialization generates serializers at compile time through a Kotlin compiler plugin, so it is fast, works well with R8, and understands Kotlin nullability and default values. Gson does not: it can put null into a non-null Kotlin property and silently ignores default values, and it is in maintenance mode. Moshi is still a fine choice in existing codebases.

app/build.gradle.kts

plugins {
    id("org.jetbrains.kotlin.plugin.serialization")
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.12.0")
}
@Serializable
data class NetworkNote(
    val id: Long,
    val title: String,
    @SerialName("updated_at") val updatedAt: Long,
    val tags: List<String> = emptyList(),
)

The serialization plugin version must match your Kotlin version, which is easiest to guarantee by declaring it in your version catalog with version.ref = "kotlin". You will also use the same plugin for type-safe navigation keys, so it earns its place twice.

Coil: loading images in Compose

Coil is Kotlin-first and coroutine-based, and its AsyncImage composable is the shortest path from a URL to pixels. In Coil 3 the network layer is a separate artifact; if you forget it, remote URLs fail to load.

// implementation("io.coil-kt.coil3:coil-compose:3.6.3")
// implementation("io.coil-kt.coil3:coil-network-okhttp:3.6.3")

AsyncImage(
    model = note.imageUrl,
    contentDescription = note.title,
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(72.dp).clip(RoundedCornerShape(12.dp)),
)

To configure caching or crossfade globally, implement SingletonImageLoader.Factory on your Application. Glide works too and has its own Compose integration, but Coil is the more natural fit for a Compose-only app.

Hilt vs Koin: which dependency injection library?

HiltKoin
How it worksCode generation (Dagger) at build timeRuntime service locator with a Kotlin DSL
Missing bindingBuild errorCrash at runtime (verify in tests)
Build costKSP processing on every buildNone
MultiplatformAndroid only✓
Official Jetpack integrationsViewModel, Navigation, WorkManagerCommunity-maintained equivalents

Pick Hilt for an Android-only app with several screens; it is what Google recommends and what most Android job codebases use. Pick Koin for Kotlin Multiplatform, or if build speed matters more to you than compile-time safety. For a two-screen app, a manual container in your Application class is perfectly respectable.

Hilt setup (app/build.gradle.kts)

plugins {
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.60.1")
    ksp("com.google.dagger:hilt-compiler:2.60.1")
    implementation("androidx.hilt:hilt-lifecycle-viewmodel-compose:1.4.0")
}
@HiltAndroidApp
class App : Application()

@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* setContent { ... } */ }

@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
    @Binds
    abstract fun bindNotesRepository(impl: OfflineFirstNotesRepository): NotesRepository
}

@HiltViewModel
class NotesViewModel @Inject constructor(
    private val repository: NotesRepository,
) : ViewModel()

// In a composable:
val viewModel: NotesViewModel = hiltViewModel()

The Koin equivalent is a module DSL and a start call:

// implementation("io.insert-koin:koin-androidx-compose:4.2.2")

val appModule = module {
    single<NotesRepository> { OfflineFirstNotesRepository(get(), get()) }
    viewModelOf(::NotesViewModel)
}

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@App)
            modules(appModule)
        }
    }
}

// In a composable:
val viewModel: NotesViewModel = koinViewModel()

Hilt and Room both need the KSP plugin; its version must be compatible with your Kotlin version, so check the KSP releases page when you upgrade Kotlin.

WorkManager: background work that must finish

WorkManager runs deferrable work that must complete even if the user leaves the app or the device reboots: syncing, uploading, periodic cleanup. It respects Doze and battery limits and lets you add constraints like “only on unmetered network”. It is not for work that should stop when the user leaves a screen; use viewModelScope for that.

// implementation("androidx.work:work-runtime-ktx:2.12.0")

class SyncWorker(
    context: Context,
    params: WorkerParameters,
) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result =
        try {
            // call your repository here
            Result.success()
        } catch (e: IOException) {
            Result.retry()
        }
}

val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "sync",
    ExistingPeriodicWorkPolicy.KEEP,
    request,
)

Always enqueue periodic work as unique work, or every app launch schedules another copy. The minimum periodic interval is 15 minutes. To inject dependencies into workers with Hilt, add androidx.hilt:hilt-work plus ksp("androidx.hilt:hilt-compiler"), annotate the worker with @HiltWorker and its constructor with @AssistedInject, have your Application implement Configuration.Provider with a HiltWorkerFactory, and remove WorkManager’s default initializer from the manifest.

Navigation 3 (androidx.navigation3, stable since late 2025) is the library Google now recommends for Compose apps. You own the back stack as a plain observable list of keys; NavDisplay renders whatever is on top. That makes adaptive list-detail layouts, custom back handling and testing much simpler than with a graph you cannot see into.

// implementation("androidx.navigation3:navigation3-runtime:1.2.1")
// implementation("androidx.navigation3:navigation3-ui:1.2.1")
// implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.11.0")

@Serializable data object NoteList : NavKey
@Serializable data class NoteDetail(val id: Long) : NavKey

@Composable
fun AppNavigation() {
    val backStack = rememberNavBackStack(NoteList)
    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),
            rememberViewModelStoreNavEntryDecorator(),
        ),
        entryProvider = entryProvider {
            entry<NoteList> {
                NotesRoute(onOpen = { id -> backStack.add(NoteDetail(id)) })
            }
            entry<NoteDetail> { key -> NoteDetailRoute(noteId = key.id) }
        },
    )
}

The rememberViewModelStoreNavEntryDecorator() line scopes each screen’s ViewModel to its back stack entry, so it is cleared when the screen is popped. Keys must implement NavKey and be @Serializable for rememberNavBackStack to survive process death.

Navigation Compose 2.x (androidx.navigation:navigation-compose) is still maintained and has type-safe @Serializable routes since 2.8. Keep it in an existing app that uses it, or if you also have Fragment-based screens in the same graph.

Paging 3: long and infinite lists

Paging loads data in chunks as the user scrolls, handles loading and error states at the list edges, and plugs straight into LazyColumn. Use it for feeds, search results and anything backed by a paginated API or a large table. For a list of 50 items, it is overkill; a Flow<List<T>> is simpler.

// implementation("androidx.paging:paging-compose:3.5.3")

// ViewModel
val notes: Flow<PagingData<Note>> =
    Pager(PagingConfig(pageSize = 30)) { repository.notesPagingSource() }
        .flow
        .cachedIn(viewModelScope)

// UI
val items = viewModel.notes.collectAsLazyPagingItems()
LazyColumn {
    items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
        items[index]?.let { NoteRow(it) }
    }
}

Always call cachedIn(viewModelScope) so a rotation does not reload from page one. With Room 3, a DAO returns a PagingSource only after you add androidx.room3:room3-paging and register @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) on the DAO or database. For network plus database, use a RemoteMediator.

Timber: logging you can leave in

Timber is a tiny wrapper over Log that adds the class name as a tag automatically and, more importantly, logs nothing unless you plant a tree. Plant a DebugTree in debug builds and, optionally, a tree that forwards warnings to your crash reporter in release.

// implementation("com.jakewharton.timber:timber:5.0.1")

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
    }
}

Timber.d("Loaded %d notes", notes.size)

Since AGP 8, BuildConfig is not generated by default; enable it with buildFeatures { buildConfig = true }. Timber 5.0.1 has been the latest release for years. It is finished, not abandoned.

Which libraries should you skip in a new app?

Add a library when the first screen that needs it appears, not in a “starter kit” on day one. Each dependency costs build time, APK size and upgrade work, so the smallest set that covers your actual features wins.

Putting them in a version catalog

Declare everything in gradle/libs.versions.toml so versions live in one place and Android Studio can suggest upgrades. The Gradle version catalogs guide covers bundles and migration; here is the core of a catalog for this stack:

gradle/libs.versions.toml

[versions]
kotlin = "2.4.21"
ksp = "2.3.13"
room3 = "3.0.3"
hilt = "2.60.1"
retrofit = "3.0.0"
coil = "3.6.3"
navigation3 = "1.2.1"
lifecycle = "2.11.0"

[libraries]
room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "1.3.0" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.12.0" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version = "1.4.0" }
work-runtime = { module = "androidx.work:work-runtime-ktx", version = "2.12.0" }
navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
paging-compose = { module = "androidx.paging:paging-compose", version = "3.5.3" }
timber = { module = "com.jakewharton.timber:timber", version = "5.0.1" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
room3 = { id = "androidx.room3", version.ref = "room3" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

These versions were verified on Maven Central and Google’s Maven repository in November 2026. They will age; check the AndroidX releases page and each library’s changelog for the latest. If an upgrade breaks the build, the common Gradle build errors guide covers the usual Kotlin, KSP and AGP mismatches. How these pieces fit together into layers is covered in Android app architecture.

Frequently asked questions

Should I use Room 3 or Room 2 for a new Android app?

Use Room 3 for a new app. It is stable, Kotlin and coroutines first, and the version that will get new features. Room 2.8 keeps working and is still released, so existing apps can migrate when convenient.

Is Retrofit still worth using in 2026?

Yes. Retrofit 3 is actively maintained and remains the simplest way to call a REST API from an Android-only app. Choose Ktor client instead when you share networking code across platforms with Kotlin Multiplatform.

Do I need a dependency injection library at all?

Not for a very small app; constructor injection plus a manual container in your Application class works fine. Once you have several ViewModels, background workers, or navigation-scoped ViewModels, Hilt or Koin saves real boilerplate.

Is Gson deprecated?

Gson is in maintenance mode rather than formally deprecated. For Kotlin code, prefer kotlinx.serialization or Moshi, because Gson uses reflection and does not respect Kotlin nullability or default property values.

Which libraries does a minimal Compose app really need?

Usually lifecycle-runtime-compose for collectAsStateWithLifecycle, a navigation library once you have two screens, and DataStore if you store any settings. Add networking, images, Room and DI only when a feature needs them.