MVI Architecture Library (Kotlin Multiplatform)
A Kotlin Multiplatform library implementing the Model-View-Intent (MVI) pattern for predictable, testable, and maintainable UI state management.
This library provides a complete MVI architecture with opinionated implementations designed to handle complex presentation logic while maintaining 100% unit testability.
Why MVI?
The Model-View-Intent pattern offers several advantages for modern application development:
- Predictability: Unidirectional data flow eliminates ambiguity about how state changes
- Debuggability: Every state transition is explicit and logged, making issues easy to trace
- Testability: Pure reducer functions are trivial to test without mocking
- Maintainability: Complex UI logic remains comprehensible as it grows
- Time travel debugging: State history can be captured and replayed
Trade-offs
Screens written with this library tend to be verbose. This is a deliberate trade-off. The verbosity comes from:
- Explicit intent definitions for every user action
- Comprehensive state modeling with all possible UI states
- Separation of concerns between view state, commands, and intents
However, this verbosity makes sense when you optimize for:
- Handling highly complex presentation logic with multiple async operations, conditional flows, and edge cases
- Complete unit testability of all business logic without framework dependencies
- Team collaboration where explicit contracts between layers prevent confusion
- Long-term maintenance where clarity trumps brevity
If your screens are simple forms with minimal logic, this pattern may be overkill. But for complex user flows (multi-step wizards, real-time updates, intricate validation), the structure pays dividends.
Core Concepts
Architecture Overview
User Action → Intent → Reducer → New State → View Update
↓
Async Command → executeAsyncCommand() → Async Intent → Reducer → ...
The library enforces a strict unidirectional data flow:
- Intents represent all possible events (user actions or async results)
- Reducer is a pure function
(State, Intent) -> State that computes state transitions
- State is an immutable data class containing all UI-relevant information
- View State is a simplified representation of state, optimized for UI binding
- Commands are ephemeral instructions for one-time effects (navigation, toasts, etc.)
Key Opinions
1. Separation of User and Async Intents
The library distinguishes between:
- User Intents: Direct user actions (button clicks, text input)
- Async Intents: Internal events (network responses, timer ticks)
This separation provides different backpressure strategies:
- User intents drop latest to maintain UI responsiveness
- Async intents suspend to preserve ordering and reliability
2. Commands for Side Effects
Commands represent one-time effects that shouldn't be modeled as persistent state:
- View Commands: Navigation, toasts, haptic feedback
- Async Commands: Trigger background operations
Commands are automatically cleared after emission to prevent re-execution on configuration changes.
3. Pure Reducer Functions
Reducers must be pure: same inputs always produce the same output. This makes:
- Unit testing trivial (no mocking required)
- Debugging deterministic (replay any state transition)
- Reasoning about logic straightforward
4. Explicit Async State Modeling
The Async<T> type makes "loading" states explicit:
sealed interface Async<out T> {
data object Determining : Async<Nothing>
value class Determined<T>(val value: T) : Async<T>
}
This eliminates ambiguous null values and makes async operations visible in the type system.
5. Built-in Subscription Safety
The library can detect and prevent multiple subscriptions to state flows, catching UI binding bugs during development:
multiSubscriptionBehaviour = MultiSubscriptionBehaviour.ThrowError
Installation
This library is not yet published to a public repository. Recommended consumption methods:
Option 1: Git Submodule
git submodule add https://github.com/yourorg/mvi.git libs/mvi
Then in settings.gradle.kts:
includeBuild("libs/mvi")
Option 2: Included Build
Clone the repository locally and reference it:
settings.gradle.kts:
includeBuild("../path/to/mvi")
Then in your module's build.gradle.kts:
dependencies {
implementation("au.lovecraft:mvi")
}
Quick Start
1. Define Your State and Intents
2. Implement the Reducer
fun loginReducer(state: LoginState, intent: LoginIntent): LoginState = when (intent) {
is LoginUserIntent.UsernameChanged -> state.copy(username = intent.value)
is LoginUserIntent.PasswordChanged -> state.copy(password = intent.value)
is LoginUserIntent.LoginButtonClicked -> state.copy(
loginResult = Async.Determining,
commands = listOf(LoginAsyncCommand.AuthenticateUser(state.username, state.password))
)
LoginAsyncIntent.LoginCompleted -> state.copy(
loginResult = Async.Determined(intent.success),
commands = (intent.success) {
listOf(LoginViewCommand.NavigateToHome)
} {
listOf(LoginViewCommand.ShowInvalidCredentialsError)
}
)
}
3. Create the ViewModel
4. Bind to the UI
5. Unit Test Everything
Features
Async State Management
The Async<T> type provides explicit modeling of asynchronous operations:
data class ProfileState(
val userData: Async<User> = Async.Determining,
) : MviState<ProfileState, ProfileCommand>
when (viewState.userData) {
Async.Determining -> LoadingIndicator()
is Async.Determined -> UserProfile(viewState.userData.value)
}
Structured Concurrency
The Scopes helper bundles coroutine dispatchers for consistent concurrency management:
val scopes = Scopes(
mainDispatcher = Dispatchers.Main,
logicDispatcher = Dispatchers.Default,
ioDispatcher = Dispatchers.IO
)
viewModelScopes.io.launch { }
viewModelScopes.logic.launch { }
Intent Channel Configuration
User and async intents have different backpressure strategies:
- Async intents: Rendezvous channel with suspension (preserves ordering)
- User intents: Capacity 1 with DROP_LATEST (maintains responsiveness)
This ensures the UI remains responsive under load while internal operations maintain correctness.
Command Deduplication
Commands are automatically cleared between state emissions, but repeated commands are supported via a heartbeat mechanism. This ensures navigation commands always fire, even if they're identical to previous commands.
Supported Platforms
Configured for Kotlin Multiplatform:
- Android
- JVM
- iOS
- (Add additional targets as needed)
When to Use This Library
Good Fit
- Complex user flows with multiple steps and async operations
- Applications requiring high testability (fintech, healthcare)
- Teams collaborating on large codebases
- Real-time features with concurrent state updates
- Legacy code migrations seeking structure
Consider Alternatives
- Simple CRUD screens with minimal logic
- Prototypes prioritizing speed over structure
- Single-developer projects with simple requirements
Building
Requirements:
./gradlew build
./gradlew test
Roadmap
- Publish artifacts to Maven Central
- Add sample applications demonstrating complex flows
- Provide Android Studio / IntelliJ IDEA templates
- Create migration guides from other patterns (MVVM, MVC)
License
This software is released under the LGPL License.
See LICENSE.md for details.