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
)
}
}
}
Using includes() for Module Dependencies (Recommended)
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()
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)
}
}
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
@InstallInannotations needed - ✅ No component dependencies to configure
- ✅ No
@EntryPointinterfaces for cross-module access - ✅ Runtime module loading/unloading
- ✅ Dynamic module configuration
Next Steps
- Testing - Testing strategies for multi-module apps
- Best Practices - Overall Koin best practices
- Modules - Core Koin modules documentation
- Lazy Modules - Deep dive into lazy module loading