Skip to main content
Version: 4.2

Multi-Module Apps

This guide covers organizing Koin in multi-module Android applications, including feature modules, shared dependencies, and lazy loading.

Why Multi-Module Architecture?

Multi-module architecture provides several benefits for Android apps:

  • Separation of concerns - Features are isolated in their own modules
  • Faster build times - Only changed modules need rebuilding
  • Reusability - Share common code across features
  • Team scalability - Teams can work on separate modules independently
  • Better encapsulation - Internal implementation details stay private

Module Organization Patterns

Typical Multi-Module Structure

app/
├── app/ # Main application module
├── feature/
│ ├── login/ # Login feature module
│ ├── home/ # Home feature module
│ ├── profile/ # Profile feature module
│ └── settings/ # Settings feature module
├── core/
│ ├── data/ # Data layer (repositories, data sources)
│ ├── domain/ # Domain layer (use cases, entities)
│ ├── network/ # Network clients
│ └── database/ # Database
└── shared/
└── ui/ # Shared UI components

Koin Module Organization

Each Gradle module can have its own Koin module(s):

// :feature:login module
package com.app.feature.login

val loginModule = module {
viewModelOf(::LoginViewModel)
factoryOf(::LoginUseCase)
}

// :feature:home module
package com.app.feature.home

val homeModule = module {
viewModelOf(::HomeViewModel)
scopedOf(::HomeRepository)
}

// :core:data module
package com.app.core.data

val dataModule = module {
singleOf(::UserRepository)
singleOf(::OrderRepository)
}

// :core:network module
package com.app.core.network

val networkModule = module {
single { provideOkHttpClient() }
single { provideRetrofit(get()) }
}

Starting Koin in Multi-Module Apps

Application Module Setup

The :app module is responsible for starting Koin and loading all necessary modules:

// :app module - MyApplication.kt
class MyApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
androidLogger()
androidContext(this@MyApplication)

// Load all modules
modules(
// Core modules
networkModule,
databaseModule,
dataModule,

// Feature modules
loginModule,
homeModule,
profileModule,
settingsModule,

// Shared modules
uiModule
)
}
}
}
info

Best Practice (Koin 4.2+): Use includes() to declare module dependencies. This prevents accidental overriding and makes module relationships explicit.

// :core:network module
val networkModule = module {
single { createOkHttpClient() }
single { createRetrofit(get()) }
}

// :core:data module - declares dependency on networkModule
val dataModule = module {
includes(networkModule) // Explicit dependency

singleOf(::UserRepository)
singleOf(::OrderRepository)
}

// :feature:login module - includes its dependencies
val loginModule = module {
includes(dataModule) // Includes data + network automatically

viewModelOf(::LoginViewModel)
factoryOf(::LoginUseCase)
}

// Application - only need to load top-level modules
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()

startKoin {
androidContext(this@MyApplication)
modules(
// Only specify feature modules
// Their dependencies are loaded via includes()
loginModule,
homeModule,
profileModule
)
}
}
}

Benefits of includes():

  • ✅ Prevents accidental definition override
  • ✅ Makes module dependencies explicit and clear
  • ✅ Ensures dependencies are loaded in correct order
  • ✅ Reduces errors from forgetting to load a dependency module

Centralized Module List

Create a central file to organize all modules:

// :app module - KoinModules.kt
object KoinModules {

val coreModules = listOf(
networkModule,
databaseModule,
dataModule
)

val featureModules = listOf(
loginModule,
homeModule,
profileModule,
settingsModule
)

val sharedModules = listOf(
uiModule
)

val allModules = coreModules + featureModules + sharedModules
}

// Application.kt
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()

startKoin {
androidLogger()
androidContext(this@MyApplication)
modules(KoinModules.allModules)
}
}
}

Feature Modules

Self-Contained Feature Modules

Each feature module defines its own dependencies:

// :feature:login module
package com.app.feature.login

// Login-specific dependencies
class LoginViewModel(
private val loginUseCase: LoginUseCase,
private val userRepository: UserRepository // From :core:data
) : ViewModel()

class LoginUseCase(
private val authService: AuthService // From :core:network
)

// Module definition
val loginModule = module {
viewModelOf(::LoginViewModel)
factoryOf(::LoginUseCase)
}

Feature Module Dependencies

Feature modules can depend on core modules:

// :feature:login/build.gradle.kts
dependencies {
implementation(project(":core:data"))
implementation(project(":core:network"))
implementation(project(":core:domain"))
}

The Koin module automatically resolves dependencies from other modules:

// :feature:home module
val homeModule = module {
viewModelOf(::HomeViewModel) // Dependencies from core modules resolved automatically
}

class HomeViewModel(
private val userRepository: UserRepository, // From :core:data
private val getOrdersUseCase: GetOrdersUseCase // From :core:domain
) : ViewModel()
info

Koin Advantage: Unlike Hilt, you don't need @EntryPoint interfaces or component dependencies. Koin resolves dependencies across modules automatically as long as all modules are loaded in startKoin {}.

Shared Modules

Common Dependencies Module

Create shared modules for dependencies used across features:

// :core:data module
val dataModule = module {
// Repositories shared across features
singleOf(::UserRepository)
singleOf(::OrderRepository)
singleOf(::ProductRepository)
}

// :core:network module
val networkModule = module {
// Network clients
single {
OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
}

single {
Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(get())
.build()
}

single { get<Retrofit>().create(ApiService::class.java) }
}

// :core:domain module
val domainModule = module {
// Use cases
factoryOf(::GetUserUseCase)
factoryOf(::GetOrdersUseCase)
factoryOf(::GetProductsUseCase)
}

Shared UI Module

Share UI components and utilities:

// :shared:ui module
val uiModule = module {
// Shared ViewModels or utilities
factoryOf(::ImageLoader)
factoryOf(::DateFormatter)

// Shared navigation
singleOf(::AppNavigator)
}

Lazy Module Loading (4.2.0+)

Load feature modules only when needed to improve startup performance.

Defining Lazy Modules

// :app module
class MyApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
androidLogger()
androidContext(this@MyApplication)

// Load core modules immediately
modules(
networkModule,
databaseModule,
dataModule
)

// Feature modules loaded lazily
lazyModules(
loginModule,
profileModule,
settingsModule
)
}
}
}

Loading Modules On-Demand

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Load login module when user navigates to login screen
loadKoinModules(loginModule)
}
}

class ProfileActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Load profile module when profile screen opens
loadKoinModules(profileModule)
}
}

Unloading Modules

class FeatureActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
loadKoinModules(featureModule)
}

override fun onDestroy() {
super.onDestroy()
// Unload module when feature is no longer needed
unloadKoinModules(featureModule)
}
}
note

When to Use Lazy Loading:

  • Large apps with many features
  • Features that are rarely used
  • Features with heavy initialization (large libraries, databases)
  • To improve app startup time

Module Visibility and Boundaries

Internal Implementations

Keep implementation details internal to modules:

// :core:data module - UserRepository.kt
// Public interface
interface UserRepository {
suspend fun getUser(id: String): User
}

// Internal implementation
internal class UserRepositoryImpl(
private val api: ApiService,
private val database: UserDatabase
) : UserRepository {
override suspend fun getUser(id: String): User {
// Implementation details
}
}

// Module exposes interface, hides implementation
val dataModule = module {
single<UserRepository> { UserRepositoryImpl(get(), get()) }
}

API Modules Pattern

Separate API from implementation:

// :core:data:api module (interfaces only)
interface UserRepository {
suspend fun getUser(id: String): User
}

// :core:data:impl module (implementation)
internal class UserRepositoryImpl : UserRepository {
override suspend fun getUser(id: String): User { ... }
}

val dataImplModule = module {
single<UserRepository> { UserRepositoryImpl(get(), get()) }
}

Feature modules depend only on :core:data:api:

// :feature:home/build.gradle.kts
dependencies {
implementation(project(":core:data:api")) // Only the interface
// No dependency on :core:data:impl
}

Module Dependencies

Direct Dependencies

// :core:network depends on nothing
val networkModule = module {
single { OkHttpClient() }
}

// :core:data depends on :core:network
val dataModule = module {
single { UserRepository(get()) } // Gets OkHttpClient from networkModule
}

// :feature:home depends on :core:data
val homeModule = module {
viewModel { HomeViewModel(get()) } // Gets UserRepository from dataModule
}

Avoiding Circular Dependencies

// ❌ Bad - Circular dependency
// :core:data module
val dataModule = module {
single { UserRepository(get<UserPreferences>()) } // Depends on domain
}

// :core:domain module
val domainModule = module {
single { UserPreferences(get<UserRepository>()) } // Depends on data - CIRCULAR!
}

// ✅ Good - Extract shared dependency
// :core:common module
val commonModule = module {
single { UserPreferences() }
}

// :core:data module
val dataModule = module {
single { UserRepository(get<UserPreferences>()) } // From common
}

// :core:domain module
val domainModule = module {
single { GetUserUseCase(get<UserRepository>(), get<UserPreferences>()) }
}

Testing Multi-Module Apps

Testing Individual Modules

Test each module in isolation:

// Test :feature:login module
class LoginViewModelTest : KoinTest {

@get:Rule
val koinTestRule = KoinTestRule.create {
modules(
loginModule,
// Mock core dependencies
module {
single<UserRepository> { mockk<UserRepository>() }
single<AuthService> { mockk<AuthService>() }
}
)
}

private val viewModel: LoginViewModel by inject()

@Test
fun `test login success`() {
// Test using injected ViewModel
}
}

Integration Testing Across Modules

Test feature modules with real core modules:

class HomeIntegrationTest : KoinTest {

@get:Rule
val koinTestRule = KoinTestRule.create {
modules(
homeModule, // Feature module
dataModule, // Real data module
networkModule // Real network module
)
}

@Test
fun `test home screen loads user data`() {
val viewModel: HomeViewModel by inject()
// Test with real dependencies
}
}

Module Verification

Verify module configuration is correct:

class ModuleCheckTest : KoinTest {

@Test
fun `verify login module`() {
koinApplication {
modules(loginModule, dataModule, networkModule)
checkModules()
}
}

@Test
fun `verify all modules`() {
koinApplication {
modules(KoinModules.allModules)
checkModules()
}
}
}

Best Practices

1. One Koin Module Per Gradle Module

// ✅ Good - Each Gradle module has one Koin module
// :feature:login module
val loginModule = module { /* ... */ }

// :feature:home module
val homeModule = module { /* ... */ }

// ❌ Avoid - Multiple Koin modules in one Gradle module (unless necessary)
val loginModule1 = module { /* ... */ }
val loginModule2 = module { /* ... */ }
val loginModule3 = module { /* ... */ }

2. Load Core Modules First

startKoin {
modules(
// Core modules first
networkModule,
databaseModule,
dataModule,

// Then feature modules
loginModule,
homeModule
)
}

3. Use Lazy Loading for Optional Features

// Always loaded
modules(coreModules)

// Load on-demand
lazyModules(
premiumFeatureModule,
adminFeatureModule,
debugToolsModule
)

4. Keep Module Dependencies Minimal

// ✅ Good - Feature depends only on what it needs
// :feature:home/build.gradle.kts
dependencies {
implementation(project(":core:data"))
implementation(project(":core:domain"))
}

// ❌ Bad - Feature depends on everything
dependencies {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:network")) // Not needed directly
implementation(project(":core:database")) // Not needed directly
implementation(project(":feature:login")) // Feature-to-feature dependency
}

5. Use Interfaces for Module Boundaries

// :core:data:api (interfaces)
interface UserRepository {
suspend fun getUser(id: String): User
}

// :core:data:impl (implementations)
internal class UserRepositoryImpl : UserRepository { ... }

val dataModule = module {
single<UserRepository> { UserRepositoryImpl(get()) }
}

6. Document Module Dependencies

/**
* Login feature module.
*
* Dependencies:
* - :core:data - UserRepository
* - :core:network - AuthService
* - :core:domain - LoginUseCase
*/
val loginModule = module {
viewModelOf(::LoginViewModel)
factoryOf(::LoginPresenter)
}

Common Patterns

Feature Module Template

// Standard feature module structure
package com.app.feature.[feature_name]

// ViewModels
class FeatureViewModel(
private val useCase: FeatureUseCase,
private val repository: FeatureRepository
) : ViewModel()

// Use cases (if complex logic)
class FeatureUseCase(
private val repository: FeatureRepository
)

// Module definition
val featureModule = module {
viewModelOf(::FeatureViewModel)
factoryOf(::FeatureUseCase)
}

Shared State Across Features

// :core:session module
class UserSession {
var currentUser: User? = null
var authToken: String? = null
}

val sessionModule = module {
single { UserSession() }
}

// :feature:login module
class LoginViewModel(
private val session: UserSession // Shared across features
) : ViewModel()

// :feature:home module
class HomeViewModel(
private val session: UserSession // Same instance
) : ViewModel()

Platform-Specific Modules

// Common module for KMP
expect val platformModule: Module

// Android implementation
actual val platformModule = module {
single { AndroidSpecificService() }
single<ImageLoader> { AndroidImageLoader(androidContext()) }
}

// iOS implementation
actual val platformModule = module {
single { IOSSpecificService() }
single<ImageLoader> { IOSImageLoader() }
}

Dynamic Feature Modules

For Android Dynamic Feature Modules:

// :app module
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()

startKoin {
androidContext(this@MyApplication)
modules(coreModules)
}
}
}

// Dynamic feature module
class DynamicFeatureActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Load module when dynamic feature is installed
if (!isDynamicModuleLoaded) {
loadKoinModules(dynamicFeatureModule)
isDynamicModuleLoaded = true
}
}

companion object {
private var isDynamicModuleLoaded = false
}
}

Migration from Single Module

Step 1: Identify Module Boundaries

// Before - Single module
val appModule = module {
// Network
single { OkHttpClient() }

// Data
single { UserRepository(get()) }

// ViewModels
viewModel { LoginViewModel(get()) }
viewModel { HomeViewModel(get()) }
}

Step 2: Extract by Layer

// After - Multi-module
// :core:network
val networkModule = module {
single { OkHttpClient() }
}

// :core:data
val dataModule = module {
single { UserRepository(get()) }
}

// :feature:login
val loginModule = module {
viewModel { LoginViewModel(get()) }
}

// :feature:home
val homeModule = module {
viewModel { HomeViewModel(get()) }
}

Step 3: Update Application Setup

// Before
startKoin {
modules(appModule)
}

// After
startKoin {
modules(
networkModule,
dataModule,
loginModule,
homeModule
)
}

Summary

Multi-module apps with Koin:

  • Simple Setup - No special configuration needed, just load all modules
  • No EntryPoints - Unlike Hilt, dependencies resolve across modules automatically
  • Lazy Loading - Load feature modules on-demand with lazyModules()
  • Clear Boundaries - Use interfaces to define module APIs
  • Easy Testing - Test modules in isolation or together
  • Flexible - Works with any module structure (layered, feature-based, etc.)

Key Differences from Hilt:

  • ✅ No @InstallIn annotations needed
  • ✅ No component dependencies to configure
  • ✅ No @EntryPoint interfaces for cross-module access
  • ✅ Runtime module loading/unloading
  • ✅ Dynamic module configuration

Next Steps