Building Rock-Solid Apps with MVI and Koin in Kotlin Multiplatform
Architecting a Kotlin Multiplatform app without solid patterns is like trying to assemble IKEA furniture without instructions – possible, but you’ll likely end up with extra parts and wobbly results. Let’s explore how combining MVI (Model-View-Intent) architecture with Koin dependency injection creates maintainable, testable apps that work across platforms.
Why This Combo Works
MVI and Koin together act like a GPS and fuel system for your app:
- MVI keeps your UI state predictable
- Koin manages dependencies without ceremony
- Both work seamlessly across Android, iOS, and other platforms
Here’s a quick comparison of state management solutions:
Aspect | MVI | MVVM | MVC |
---|---|---|---|
State Flow | Unidirectional | Bi-directional | Chaotic |
Testability | Excellent | Good | Poor |
Boilerplate | Moderate | Low | None |
Platform Suitability | Cross-platform | Android-focused | Web-era |
MVI Core Components
Let’s break down a login feature implementation:
<em>// Contract</em>
interface LoginContract {
data class State(
val email: String = "",
val isLoading: Boolean = false,
val error: String? = null
)
sealed class Intent {
data class EmailUpdated(val text: String) : Intent()
object Submit : Intent()
}
sealed class Effect {
object NavigateHome : Effect()
data class ShowToast(val message: String) : Effect()
}
}
<em>// ViewModel</em>
class LoginViewModel(
private val authRepo: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(LoginContract.State())
val state: StateFlow<LoginContract.State> = _state
private val _effects = Channel<LoginContract.Effect>()
val effects: Flow<LoginContract.Effect> = _effects.receiveAsFlow()
fun process(intent: LoginContract.Intent) {
when (intent) {
is LoginContract.Intent.EmailUpdated -> updateEmail(intent.text)
LoginContract.Intent.Submit -> performLogin()
}
}
private fun performLogin() = viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
when (val result = authRepo.login(_state.value.email)) {
is Success -> _effects.send(LoginContract.Effect.NavigateHome)
is Failure -> _effects.send(LoginContract.Effect.ShowToast("Login failed"))
}
_state.update { it.copy(isLoading = false) }
}
}
Code language: HTML, XML (xml)
Koin Setup Made Simple
Configure dependencies in shared module:
val appModule = module {
single<AuthRepository> { AuthRepositoryImpl(get()) }
viewModel { LoginViewModel(get()) }
}
val networkModule = module {
single { HttpClient() }
}
<em>// Platform-specific modules</em>
expect val platformModule: Module
Code language: JavaScript (javascript)
Android implementation:
actual val platformModule = module {
single { AndroidContext.getApplicationContext() }
}
Code language: JavaScript (javascript)
iOS implementation:
actual val platformModule = module {
single { NSBundle.mainBundle }
}
Code language: JavaScript (javascript)
Testing the Combo
Verify your architecture works as intended:
class LoginViewModelTest {
@Test
fun `login shows loading state`() = runTest {
val mockRepo = mockk<AuthRepository> {
coEvery { login(any()) } coAnswers { delay(100); Success }
}
val vm = LoginViewModel(mockRepo)
vm.process(LoginContract.Intent.Submit)
assertTrue(vm.state.value.isLoading)
coVerify { mockRepo.login(any()) }
}
}
class KoinTest : KoinTest {
@Test
fun `verify dependency graph`() {
startKoin { modules(appModule, networkModule) }
get<HttpClient>() shouldNotBe null
get<AuthRepository>() shouldBeInstanceOf<AuthRepositoryImpl>()
}
}
Code language: HTML, XML (xml)
Platform-Specific DI Handling
Platform | DI Consideration | Koin Solution |
---|---|---|
Android | Context access | androidContext() |
iOS | NSObject management | Factory providers |
Desktop | Long-lived services | Singleton scope |
Web | Lightweight instances | Scoped modules |
Performance Pro Tips
- Scope your Koin modules – Don’t make everything global
- Use state flattening – Keep MVI state objects lean
- Lazy-load dependencies – Especially for platform services
- Dispose resources – Coroutine scopes are your friends
class AnalyticsService : Closeable {
override fun close() {
<em>// Cleanup tracking resources</em>
}
}
val analyticsModule = module {
single { AnalyticsService() }
}
<em>// Cleanup when Koin stops</em>
stopKoin().close()
Code language: HTML, XML (xml)
Common Pitfalls and Fixes
Issue | Symptom | Solution |
---|---|---|
Memory leaks | App slows over time | Use Koin’s scope API |
State bloat | Slow recompositions | Split nested states |
DI conflicts | Runtime crashes | Qualifier annotations |
Platform leaks | iOS crashes | Expect/actual cleanup |
Migration Strategy
Already using another architecture? Here’s how to transition:
- Start with isolated features
- Wrap existing ViewModels in MVI contracts
- Gradually replace manual DI with Koin
- Migrate platform-specific code last
Final Checklist
Before shipping your MVI/Koin app:
- All business logic tested in shared module
- Koin modules organized by feature
- State objects are immutable
- Effects channel properly closed
- Platform resources properly released
This combination has powered production apps handling millions of users across platforms. It takes some upfront discipline, but pays off in long-term maintainability and happiness for developers and users alike.