← Back to list

Koin: Scaling your project with Annotations, KSP, and Navigation 3 Integration.

The last time I wrote about the Koin DI framework was in 2020. Back then, it was an alternative to Dagger and a breath of fresh air for…

sparkss · 2026-01-26 03:44 · 11 claps · 15.9 min read
#koin #kotlin #jetpack-navigation #android #android-app-development
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Koin: Scaling your project with Annotations, KSP, and Navigation 3 Integration.

The last time I wrote about the Koin DI framework was in 2020. Back then, it was an alternative to Dagger and a breath of fresh air for implementing dependency injection without Dagger's steep learning curve.

But frameworks evolve. As projects grow in complexity, the “freedom” of Koin’s DSL can become a double-edged sword, leading to massive, hard-to-maintain module files and runtime “what-ifs.”

This month, I decided to revisit Koin by building a project from scratch, leveraging Koin 4.x, Navigation 3, and Clean Architecture. The goal is to move away from manual DSL declarations and embrace the structure of Koin Annotations. In this article, I will share my experience with this new ecosystem and how it compares to the “old way” I documented years ago.

Getting Started

If you want to go faster you can checkout the project here :

[embed]GitHub - lluzalves/KoinAndNavigation3: Koin Reborn: Scaling with Annotations and Navigation 3 Koin Reborn: Scaling with Annotations and Navigation 3 - lluzalves/KoinAndNavigation3github.com

I recommend starting with a small project before attempting a full migration. This allows you to understand the basic setup without worrying about legacy restrictions or library incompatibilities.

To set up the project, we need a compatible KSP version and specific Gradle adjustments. Start by updating your libs.versions.toml file:

[versions]
# Core Stack
agp = "8.13.2"
kotlin = "2.3.0"
ksp = "2.3.4"

# Android & Compose
composeBom = "2026.01.00"
coreKtx = "1.17.0"
lifecycle = "2.10.0"
activityCompose = "1.12.2"
nav3 = "1.0.0"

# Dependency Injection (Koin 4.x)
koin = "4.1.1"
koinAnnotations = "2.3.1"
koinNav3 = "4.2.0-beta3"

# Networking & Serialization
ktor = "3.3.3"

# Testing
mockk = "1.14.7"
turbine = "1.2.1"
coroutinesTest = "1.10.2"
[libraries]
# AndroidX Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }

# Navigation 3
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" }

# Compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3" }

# Koin 4.x & Annotations
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koinAnnotations" }
koin-ksp-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koinAnnotations" }
koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koinNav3" }

# Ktor 3
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-negotation = {module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor"}
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }

# Testing
test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }

[bundles]
koin = ["koin-android", "koin-compose", "koin-annotations"]
ktor = ["ktor-client-core", "ktor-client-cio", "ktor-serialization", "ktor-logging", "ktor-negotation"]
navigation3 = ["androidx-navigation3-runtime", "androidx-navigation3-ui", "koin-compose-navigation3"]
unit-testing = ["test-mockk", "test-turbine", "test-coroutines", "koin-test", "ktor-client-mock"]
[plugins]
# Android
android-application = { id = "com.android.application", version.ref = "agp" }

# Kotlin
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

# KSP
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

Note: KSP versions are strictly tied to your Kotlin version. In this setup, we use Kotlin 2.3.0 and KSP 2.3.4. We also utilize the beta release of koin-compose-navigation3 (4.2.0-beta3) to access the latest Navigation 3 integration.

Configuring build.gradle.kts

After syncing your libs.versions.toml, your build.gradle.kts needs attention. Since we are moving to a compile-time approach, we must tell the build system how to handle the newly generated code:

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.kotlin.serialization)
    // 1. Added KSP plugin
    alias(libs.plugins.ksp) 
}

android {
    // ... namespace and SDK configs
    // 2. Connecting KSP to your SourceSets
    sourceSets {
        getByName("debug") {
            java.srcDir("build/generated/ksp/debug/kotlin")
        }
        getByName("release") {
            java.srcDir("build/generated/ksp/release/kotlin")
        }
    }
}

dependencies {
    // 3. Update your dependencies
    // Android Core & Compose (Using BOM)
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.tooling)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    implementation(libs.androidx.lifecycle.viewmodel.navigation3)

    // Use bundles for Koin and other libs that 
    implementation(libs.bundles.koin)
    implementation(libs.bundles.ktor)
    implementation(libs.bundles.navigation3)

    // KSP Compiler
    ksp(libs.koin.ksp.compiler)

    // Testing
    testImplementation(libs.bundles.unit.testing)
}

Key Changes and Why

  • KSP Plugin: KSP analyzes annotations during compilation to generate Koin modules. This catches errors early and improves runtime performance.
  • SourceSets: KSP generates files in the build folder. We must explicitly include these paths as source directories so the IDE recognizes the generated Koin classes.
  • Bundles: Using bundles from our version catalog groups related components (like ktoror navigation3) into a single line, keeping the build file clean.
  • KSP Configuration: Using the ksp configuration ensures the compiler only processes changed symbols, leading to faster iterative builds.

Resync and build your project to ensure the setup is working correctly.

Moving to Implementation

With the configuration ready, we can start developing. The main objective is to use koin and navigation3 in a clean architecture approach.

We will build a feature that flows through a standard architecture: Data Source → Repository → ViewModel → Presentation.

At each step, we will use Koin Annotations to define our dependencies and modules, effectively replacing the manual DSL declarations.

Providing Data Dependencies

We will use the JSONPlaceholder API, a simple service to test and introduce the concepts we want to explore with koin.

To start, create a new package called data and (if necessary) a sub-package called di. Inside, add a new file called NetworkModule.kt.

In this file, we use our first annotation, @Module. This tells KSP that this class contains dependency providers to include in our dependency graph.

@Module
class NetworkModule {

 // we should provide a http client and a url 
}

Now we will define two functions provideHttpClient() and provideBaseURL Those will be provided as @singles in our dependency graph.

@Single
    fun provideHttpClient(): HttpClient {
        return HttpClient(CIO.create()) {
            install(ContentNegotiation) {
                json(Json {
                    prettyPrint = true
                    isLenient = true
                    ignoreUnknownKeys = true
                })
            }
            install(Logging) {
                logger = Logger.SIMPLE
                level = LogLevel.ALL
            }
        }
    }

    @Single
    fun provideBaseUrl(): String = "https://jsonplaceholder.typicode.com"

To understand what is happening in ourNetworkModule, we used two annotations.

  • @Module: This marks the class as a Koin module. Instead of writing a manual module { ... } block, the KSP compiler will generate the necessary DSL for us.
  • @Single: This tells Koin to treat the returned object as a Singleton. This ensures we reuse the same HttpClient instance across the entire application, which is much more resource-efficient than creating a new one for every request.

After this, compile the code, and you should now be able to see the code generated by KSP.

The Testing Trap

Now let’s test it. I expect our test to fail and reveal a significant issue in the code above. Can you guess what it is? If not, let’s create a unit test to see how it behaves. Add the KoinModuleTest in your project's test folder.

We want to use the verify() method that Koin provides, a way to check whether all our dependencies are valid and working.

As you can notice, we are already using annotations, but our Koin setup is not yet checking them at compile time. We will get there soon. First, let's use verify() , and see what happens.

import com.danielluz.koinandnavigation.data.NetworkModule
import org.junit.Test
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.ksp.generated.module
import org.koin.test.verify.verify

class KoinModuleTest {

    @Test
    @OptIn(KoinExperimentalAPI::class)
    fun checkDIModules(){
        NetworkModule().module.verify()
    }
}

Run the test, it will fail:

Refining the Network Provider

The failure in our test is a direct consequence of how we defined provideHttpClient(). By instantiating the engine inside the function, we made Koin unable to provide the HttpClientEngine dependency it sees in the generated code.

This approach is considered bad practice for several reasons:

  • Separation of Concerns. The provider for HttpClient should focus strictly on configuration, such as serialization, logging, and headers. When we instantiate the engine inside it, the function becomes responsible for both creating the infrastructure and configuring the service. That's a big no for dependency injection and good practices.
  • Hidden Infrastructure. By instantiating the engine manually, we are hiding a vital piece of the stack. This makes it impossible to swap CIO for a MockEngine during unit tests.
  • Dependency Management. In a clean architecture approach with DI, we want our dependencies to be explicit. If a component needs an engine, it should ask for it.

To fix this, we need to remove the manual instantiation and move it to a proper dependency.

Additionally, we need to enable the Koin compile-time checker to catch these issues during the build process, rather than waiting for a unit test to fail.

Let’s start by changing the method's signature and adding the engine as a provider.

 @Single
    fun provideHttpClient(httpClientEngine: HttpClientEngine): HttpClient {
        return HttpClient(httpClientEngine) {
            install(ContentNegotiation) {
                json(Json {
                    prettyPrint = true
                    isLenient = true
                    ignoreUnknownKeys = true
                })
            }
            install(Logging) {
                logger = Logger.SIMPLE
                level = LogLevel.ALL
            }
        }
    }

Also turn on KOIN_CONFIG_CHECK option in your build.gradles.kts. It enables compile-time configuration checking for Koin definitions. When enabled, the compiler will validate all Koin configurations at compile time to ensure safety and catch potential issues early. This helps with compile-time safety by detecting configuration problems before runtime.

ksp {
    arg("KOIN_CONFIG_CHECK", "true")
}

Run Compile Code again, and see the result.

Now we are using Koin Annotations with true compile-time safety. As expected, the build still fails. The reason is obvious: since our method now explicitly asks for a dependency, we must tell Koin how to provide it. To do so, we add a new @Single to provide the HttpClientEngine :

@Single
fun provideHttpEngine(): HttpClientEngine = CIO.create()

Run the compilation again, and you will see the error disappear. Our generated class has been updated with the new dependency, and our compile checker passed successfully. If you run the unit test now, it will also return a green checkmark.

We have successfully moved from a ‘hidden’ dependency problem that was untestable to a fully verified koin module.

Since we will need to create a dataSource, repository, and models, we can make use of @ComponentScan annotation by defining a new module called DataModule this will be our public central module for data package. Instead of manually registering every repository or any other :data related class, we use ComponentScanto let koin discover them for us.

package com.danielluz.koinandnavigation.data.di

import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module

private const val DATA_COMPONENT_SCAN = "com.danielluz.koinandnavigation.data"
@Module(includes = [NetworkModule::class])
@ComponentScan(DATA_COMPONENT_SCAN)
class DataModule {
}
  • @ComponentScan: This is where we gain scalability. By pointing it to the data package, Koin will automatically find any class annotated with @Single or @Factory within that package or its sub-packages. You no longer have to return to this file every time you add a new Repository.
  • Package-Level Scanning: Defining DATA_COMPONENT_SCAN as a constant keeps your module clean and prevents Koin from scanning parts of the app it doesn't need to, which helps keep the dependency resolution efficient.

Mapping our API

Our first task is to define the model that matches the JSONPlaceholder response. Before writing a single line of code, let's verify the contract.

You can run these cURL commands in your terminal to see exactly what the API expects and returns:

GET (Fetch Posts)

curl --location 'https://jsonplaceholder.typicode.com/posts' \
--header 'Accept: application/json'
[
  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "userId": 1,
    "id": 2,
    "title": "qui est esse",
    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
  }
...
]

POST (Create Posts)

curl --location 'https://jsonplaceholder.typicode.com/posts' \
--header 'Content-Type: application/json' \
--data '{
    "title": "Koin Reborn",
    "body": "Scaling with Annotations and Navigation 3",
    "userId": 1
}'
{
  "title": "Koin Reborn",
  "body": "Scaling with Annotations and Navigation 3",
  "userId": 1,
  "id": 101
}

Based on these responses, we define our Post model using Kotlin serialization.

package com.danielluz.koinandnavigation.data.model

import kotlinx.serialization.Serializable

@Serializable
data class Post(
    val id: Int? = null,
    val title: String,
    val body: String,
    val userId: Int
)

After that, we can define our datasource contract and implement it. Our PostDataSource interface and its implementation includes the createPost and getPostmethods.

package com.danielluz.koinandnavigation.data.datasource

import com.danielluz.koinandnavigation.data.model.Post

interface PostDataSource {
    suspend fun getPosts(): List<Post>
    suspend fun createPost(post: Post): Post
}
package com.danielluz.koinandnavigation.data.datasource

import com.danielluz.koinandnavigation.data.model.Post
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import org.koin.core.annotation.Single

@Single
class PostRemoteDataSourceImpl(
    private val httpClient: HttpClient,
    private val baseUrl: String
) : PostDataSource {

    override suspend fun getPosts(): List<Post> =
        httpClient.get("${baseUrl}posts").body()

    override suspend fun createPost(post: Post): Post {
        return httpClient.post("${baseUrl}posts") {
            setBody(post)
            contentType(ContentType.Application.Json)
        }.body()
    }
}

Compile all sources again. If no errors are found, we are ready to move to the next step: testing our data source.

Testing with MockEngine and KoinTestRule

Is common to mock the entire PostDataSource. However, my suggestion here is to test the actual implementation while mocking only the HttpClientEngine.


class PostDataSourceTest : KoinTest {

    private val mockedEngine = MockEngine { _ ->
        respond(
            content = """
                {
                    "userId": 1,
                    "id": 101,
                    "title": "Koin Reborn",
                    "body": "Scaling with Annotations"
                }
            """.trimIndent(),
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }

Instead of hitting the real JSONPlaceholder API, we use Ktor’s MockEngine.

@get:Rule
    val koinRule = KoinTestRule.create {
        allowOverride(true)
        modules(
            DataModule().module,
            module {
                single<HttpClient> {
                    HttpClient(mockedEngine) {
                        install(ContentNegotiation) {
                            json(Json {
                                ignoreUnknownKeys = true
                                isLenient = true
                            })
                        }
                    }
                }
                single { "https://jsonplaceholder.typicode.com/" }
            }
        )
    }

Use KoinTestRule to orchestrate the Graph

To test our PostDataSource, we need a functioning Koin environment. We use the KoinTestRule to handle the lifecycle—starting Koin before the test and stopping it afterward.

The trick here is allowOverride(true). Since our real DataModule already defines an HttpClient, we need this flag to force our mockedEngine .

private val dataSource: PostDataSource by inject()

    @Test
    fun `createPost SHOULD return parsed Post WHEN server returns 201`() = runTest {
        val newPost = Post(title = "Koin Reborn", body = "Scaling with Annotations", userId = 1)

        val result = dataSource.createPost(newPost)

        assertEquals(101, result.id)
        assertEquals("Koin Reborn", result.title)
        assertEquals(1, result.userId)
    }
}

Injecting and Asserting

By implementing the KoinTest interface, we can use the by inject() delegate to retrieve our PostDataSource

Run the test and see the result.

This test allows us to verify the entire structure of our data source. If the test passes, it proves that KSP found your @Single annotation and that your HttpClient is correctly configured to talk to your API.

The next step is to create our Repository.

package com.danielluz.koinandnavigation.data.repository

import com.danielluz.koinandnavigation.data.model.Post

interface PostRepository {
    suspend fun getPosts(): Result<List<Post>>
    suspend fun savePost(post: Post): Result<Post>
}
package com.danielluz.koinandnavigation.data.repository

import com.danielluz.koinandnavigation.data.datasource.PostDataSource
import com.danielluz.koinandnavigation.data.model.Post
import org.koin.core.annotation.Single

@Single(binds = [PostRepository::class])
class PostRepositoryImpl(
    private val dataSource: PostDataSource
) : PostRepository {

    override suspend fun getPosts(): Result<List<Post>> {
        return try {
            val posts = dataSource.getPosts()
            Result.success(posts)
        } catch (exception: Exception) {
            Result.failure(exception)
        }
    }

    override suspend fun savePost(post: Post): Result<Post> {
        return try {
            val createdPost = dataSource.createPost(post)
            Result.success(createdPost)
        } catch (exception: Exception) {
            Result.failure(exception)
        }
    }
}

The repository structure

  • Explicit Binding: Just like we did with the DataSource, using binds in the @Single annotation creates a link between the interface and the implementation.
  • Automated Wiring: Since this class lives within the com.danielluz.koinandnavigation.data package, our DataModule’s @ComponentScan will find it, see the binding, and wire it to the PostDataSource automatically.
  • Safety with Result: By wrapping our network calls in a try-catch and returning a Result, we ensure that exceptions don't crash the UI.
package com.danielluz.koinandnavigation

class PostRepositoryKoinTest : KoinTest {

    private val mockDataSource: PostDataSource = mockk()

    @get:Rule
    val koinRule = KoinTestRule.create {
        printLogger()
        allowOverride(true)
        modules(
            DataModule().module,
            module {
                single<PostDataSource> { mockDataSource }
                single() { "https://test.com/" }
            }
        )
    }

    private val repository: PostRepository by inject()

    @Test
    fun `repository SHOULD be correctly injected WHEN requested by interface`() = runTest {
        val posts = listOf(
            Post(1, "Koin Reborn", "Testing...", 1)
        )
        coEvery { mockDataSource.getPosts() } returns posts 

        val result = repository.getPosts()

        assertTrue(result.isSuccess)
        assertEquals(posts, result.getOrNull())
        assertEquals("Koin Reborn", result.getOrNull()?.get(0)?.title.toString())

    }
}

This test structure allowed us to check:

  • Interface Binding Verification: This test confirms that @Single(binds = [PostRepository::class]) is working.
  • Graph Integrity: It ensures that PostRepositoryImpl can successfully receive its PostDataSource dependency from the Koin container.
  • Environment: You are testing in an environment that closely mirrors your production app’s dependency resolution logic.

Now we should move to our presentation layer.

@ViewModels

We can now use @KoinViewModel annotation, and the KSP compiler will generate the necessary ViewModelProvider.Factory logic. This ensures our PostViewModel is lifecycle-aware and correctly receives its dependencies from the Koin graph.

First, to align with the compose best practice development, let's define the UIState contract.

sealed interface PostUiState {
    data object Initial : PostUiState
    data object Loading : PostUiState
    data class Success(val posts: List<Post>) : PostUiState
    data class Failure(val errorMessage: String?) : PostUiState
}

By using the annotation, even if the ViewModel's dependency complexity grows (be careful with that), the DI remains invisible. Because it’s annotated with @KoinViewModel, KSP automatically updates the generated factory to include the new dependencies.

Let's declare our presentation module so we can load it correctly and test our view model successfully.

package com.danielluz.koinandnavigation.presentation.di

import com.danielluz.koinandnavigation.data.di.DataModule
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module

@Module(includes = [DataModule::class])
@ComponentScan("com.danielluz.koinandnavigation")
class AppModule

@OptIn(ExperimentalCoroutinesApi::class)
class PostViewModelTest : KoinTest {

    private val repository: PostRepository = mockk()
    private val dispatcher = StandardTestDispatcher()

    @get:Rule
    val koinRule = KoinTestRule.create {
        printLogger()
        allowOverride(true)
        modules(
            AppModule().module,
            module {
                single<PostRepository> { repository }
            }
        )
    }

    private val viewModel: PostViewModel by inject()

    @Before
    fun setup() {
        Dispatchers.setMain(dispatcher)
    }

    @Test
    fun `fetchPosts SHOULD emit Loading then Success WHEN repo returns success`() =
        runTest(dispatcher) {
            val expectedPosts = listOf(
                Post(
                    id = 1,
                    title = "Koin Reborn",
                    body = "...",
                    userId = 1
                )
            )
            coEvery { repository.getPosts() } returns Result.success(expectedPosts)

            viewModel.uiState.test {
                assertEquals(PostUiState.Initial, awaitItem())

                viewModel.fetchPosts()

                assertEquals(PostUiState.Loading, awaitItem())

                val result = awaitItem() as PostUiState.Success
                assertEquals(expectedPosts, result.posts)
            }
        }

    @Test
    fun `createPost SHOULD emit Loading then Success WHEN repo returns success`() =
        runTest(dispatcher) {
            val newPost = Post(id = 101, title = "New", body = "Body", userId = 1)
            coEvery { repository.savePost(any()) } returns Result.success(newPost)

            viewModel.uiState.test {
                assertEquals(PostUiState.Initial, awaitItem())

                viewModel.createPost("New", "Body")

                assertEquals(PostUiState.Loading, awaitItem())

                val result = awaitItem() as PostUiState.Success
                assertEquals(101, result.posts.first().id)
            }
        }

Now we will finish our structure with the koinand navigation3 integration.

Navigation 3: A New Paradigm Integrated with Koin.

In Navigation 3, you move between screens by simply updating the state of your navigation host. Unlike the old NavController, you are now managing a stateful list of routes.

We replace brittle string routes with serializable classes. Each route implements the NavKey marker interface, which allows navigation3to automatically handle state restoration and persistence across configuration changes.

@Serializable
sealed interface Route : NavKey {
    @Serializable data object Home : Route
    @Serializable data object CreatePost : Route
}

Using the koin-compose-navigation3 library, we define our navigation logic inside a Koin module. This centralizes your screen-to-ViewModel mapping and ensures every screen is part of your DI graph.

Let's define our navigation module.

fun navigationModule(backStack: NavBackStack<NavKey>) = module {
    navigation<Route.Home> {
        PostListScreen(
            viewModel = koinViewModel(),
            onNavigateToCreate = { backStack.add(Route.CreatePost) }
        )
    }

    navigation<Route.CreatePost> {
        CreatePostScreen(
            viewModel = koinViewModel(),
            onBack = { backStack.removeLastOrNull() }
        )
    }
}

We use navigation<T> which is a koin-specific DSL to register a destination. When the navigation system sees Route.Home at the top of your backstack, it executes the code inside this block to render the UI.

It also provides automatic viewmodel scoping, as koinscopes the viewmodel to the destination. This brings a better:

  • Lifecycle: The ViewModel is created when you enter the screen and is cleared (destroyed) only when the route is removed from the backstack.
  • Efficiency: It prevents memory leaks by ensuring ViewModels don’t outlive their relevant screens.
navigation<Route.Home> { ... }
viewModel = koinViewModel()

Instead of using a NavController.navigate() command, you simply modify the list:

  • Forward: add() a new route to the end of the list.
  • Backward: removeLastOrNull() to pop the current screen off.
onNavigateToCreate = { backStack.add(Route.CreatePost) }
onBack = { backStack.removeLastOrNull() }

So together, Koin and Navigation will work like this:

To finish, add the following code to the MainActivity:

class MainActivity : ComponentActivity() {
    @OptIn(KoinExperimentalAPI::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            KoinNavigationTheme {
                val backStack = rememberNavBackStack(  Route.Home)

                BackHandler(enabled = backStack.size > 1) {
                    backStack.removeLastOrNull()
                }
                loadKoinModules(navigationModule(backStack))

                NavDisplay(
                    backStack = backStack,
                    onBack = { backStack.removeLastOrNull() },
                    entryProvider =  koinEntryProvider(),
                    entryDecorators = listOf(
                        rememberSaveableStateHolderNavEntryDecorator(),
                        rememberViewModelStoreNavEntryDecorator()
                    )
                )
            }
        }
    }
}

In Navigation 3, the backStack is a live, reactive object that exists only within the UI composition of the MainActivity. Since our navigationModule is a function that requires this specific backStack instance to perform actions like add() or removeLastOrNull(), we cannot provide it at the global application level. Instead, we "reach in" at runtime in the MainActivity to bridge the two.

We also initialize the NavDisplay with two essential decorators that ensure our applications remain stable:

  • **rememberSaveableStateHolderNavEntryDecorator**: This is the "memory" of your UI. It preserves state, such as scroll positions or text input, even as you navigate away and back, ensuring a seamless user experience across configuration changes.
  • **rememberViewModelStoreNavEntryDecorator**: This is the "cleaner." It scopes your @KoinViewModel instances directly to the backstack entry. This means a viewModel is only cleared and destroyed when its specific screen is permanently popped from the stack, preventing memory leaks while keeping data alive when it's needed.

Check the entire code here:

[embed]GitHub - lluzalves/KoinAndNavigation3: Koin Reborn: Scaling with Annotations and Navigation 3 Koin Reborn: Scaling with Annotations and Navigation 3 - lluzalves/KoinAndNavigation3github.com

Thanks for reading.


메타데이터
post_id
273abe767a4b
slug
koin-scaling-your-project-with-annotations-ksp-and-navigation-3-integration-273abe767a4b
url
https://medium.com/@spparks_/koin-scaling-your-project-with-annotations-ksp-and-navigation-3-integration-273abe767a4b
canonical_url
https://medium.com/@spparks_/koin-scaling-your-project-with-annotations-ksp-and-navigation-3-integration-273abe767a4b
author_url
https://medium.com/@spparks_
status
ok
fetched_at
2026-07-13 06:23:13