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:

AspectMVIMVVMMVC
State FlowUnidirectionalBi-directionalChaotic
TestabilityExcellentGoodPoor
BoilerplateModerateLowNone
Platform SuitabilityCross-platformAndroid-focusedWeb-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

PlatformDI ConsiderationKoin Solution
AndroidContext accessandroidContext()
iOSNSObject managementFactory providers
DesktopLong-lived servicesSingleton scope
WebLightweight instancesScoped modules

Performance Pro Tips

  1. Scope your Koin modules – Don’t make everything global
  2. Use state flattening – Keep MVI state objects lean
  3. Lazy-load dependencies – Especially for platform services
  4. 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

IssueSymptomSolution
Memory leaksApp slows over timeUse Koin’s scope API
State bloatSlow recompositionsSplit nested states
DI conflictsRuntime crashesQualifier annotations
Platform leaksiOS crashesExpect/actual cleanup

Migration Strategy

Already using another architecture? Here’s how to transition:

  1. Start with isolated features
  2. Wrap existing ViewModels in MVI contracts
  3. Gradually replace manual DI with Koin
  4. Migrate platform-specific code last

Final Checklist

Before shipping your MVI/Koin app:

  1. All business logic tested in shared module
  2. Koin modules organized by feature
  3. State objects are immutable
  4. Effects channel properly closed
  5. 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.

0 0 votes
Article Rating

Leave a Reply

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments