In the world of mobile development, choosing the right application architecture plays a critical role in ensuring code quality, maintainability, and scalability. Each year brings new approaches, libraries, and frameworks designed to simplify the development process and make code more structured. In recent years, the MVI architecture (Model-View-Intent) has gained particular popularity by offering an elegant solution for managing application state and organizing unidirectional data flow.
In this article, we'll examine SimpleMVI—a lightweight yet powerful solution for implementing the MVI pattern in Kotlin multiplatform projects. We'll explore the library's core components, its features, and analyze practical examples that will help you understand how to apply SimpleMVI in your projects.
What is the MVI Pattern and Its Core Concepts
Model-View-Intent (MVI) is an architectural pattern for user interface development, inspired by functional programming and reactive systems. MVI is based on three key principles:
-
Unidirectional Data Flow — data moves in one direction, forming a cycle: from user action to model change, then to view update.
-
Immutable State — the application state is not changed directly; instead, a new state is created based on the previous one.
-
Determinism — the same user actions with the same initial state always lead to the same result.
In MVI architecture:
- Model represents the immutable application state that fully describes the data needed to display the UI.
- View passively displays the current state and transmits user actions as Intents.
- Intent describes the intentions of the user or system that can potentially change the application state.
In addition to these core components, MVI often includes:
- Reducer — a function that takes the current state and Intent, and returns a new state.
- SideEffect — side effects that don't affect the state but require interaction with external systems (e.g., navigation, notifications, API requests).
Brief History of Architectural Patterns
UI architectural patterns have evolved significantly over time:
MVC (Model-View-Controller)
One of the first patterns that divided the application into three components:
- Model — data and business logic
- View — user interface
- Controller — handling user input
The main problem with MVC is the tight coupling between components and unclear separation of responsibilities, which complicates testing and maintenance.
MVP (Model-View-Presenter)
An improvement over MVC, where:
- Model — data and business logic
- View — passive user interface
- Presenter — mediator between Model and View
MVP solves the testability problem but often leads to bloated Presenters and tight coupling between Presenter and View.
MVVM (Model-View-ViewModel)
The next step in evolution:
- Model — data and business logic
- View — user interface
- ViewModel — transforms data from Model into a format convenient for View
MVVM uses the concept of data binding, which reduces the amount of boilerplate code but can cause problems with tracking data flow.
MVI (Model-View-Intent)
A modern approach that emphasizes:
- Predictability — a deterministic approach to state management
- Immutability — state is not changed but replaced
- Unidirectional data flow — clear and transparent sequence of events
MVI is particularly effective for complex, data-rich applications with numerous user interactions and asynchronous operations.
Why SimpleMVI Was Created and Its Place Among Other Libraries
SimpleMVI was developed to provide developers with a simple yet powerful tool for implementing the MVI pattern in Kotlin Multiplatform projects. Unlike many other libraries, SimpleMVI:
- Focuses on domain logic, without imposing solutions for the UI layer
- Adheres to the "simplicity above all" principle, providing a minimal set of necessary components
- Is optimized for Kotlin Multiplatform, ensuring compatibility with various platforms
- Strictly controls thread safety, guaranteeing that interaction with state occurs only on the main thread
- Provides flexible error handling configuration through the configuration system
The main advantages of SimpleMVI compared to alternatives:
- Fewer dependencies and smaller library size compared to more complex solutions
- Lower entry threshold for understanding and use
- Full Kotlin approach using modern language constructs
- Convenient DSL for describing business logic
- Clear separation of responsibilities between components
SimpleMVI doesn't aim to solve all application architecture problems but provides a reliable foundation for organizing business logic that can be integrated with any solutions for UI, navigation, and other aspects of the application.
Core Concepts and Components of SimpleMVI
SimpleMVI offers a minimalist approach to implementing MVI architecture, focusing on three key components: Store, Actor, and Middleware. Each of these components has a unique role in ensuring unidirectional data flow and managing application state.
Store — The Central Element of the Architecture
Definition and Role of Store
Store is the heart of SimpleMVI—it's a container that holds the application state, processes intents, and generates side effects. Store encapsulates all the data-related logic, providing a single source of truth for the user interface.
public interface Store<in Intent : Any, out State : Any, out SideEffect : Any> {
// Current state
public val state: State
// State flow
public val states: StateFlow<State>
// Side effects flow
public val sideEffects: Flow<SideEffect>
// Store initialization
@MainThread
public fun init()
// Intent processing
@MainThread
public fun accept(intent: Intent)
// Store destruction
@MainThread
public fun destroy()
}
Store Lifecycle
Store has a clearly defined lifecycle:
-
Creation - instantiating the Store object with necessary dependencies
-
Initialization - calling the
init()
method, preparing internal components -
Active use - processing intents through the
accept(intent)
method -
Destruction - calling the
destroy()
method, releasing resources
It's important to understand that:
- All public Store methods must be called only on the main thread (marked with the
@MainThread
annotation) - After calling
destroy()
, the Store cannot be used; attempts to access a destroyed Store will result in an error - The Store must be initialized with the
init()
method before use
State Management
Store provides the following capabilities for working with state:
-
Access to the current state via the
state
property -
Observing state changes via the
states
flow -
Processing side effects via the
sideEffects
flow
SimpleMVI uses classes from Kotlin Coroutines for flow implementation: StateFlow
for states and regular Flow
for side effects, ensuring compatibility with standard approaches to reactive programming in Kotlin.
Convenient Extensions for Store
SimpleMVI provides convenient operators for working with intents:
// Instead of store.accept(intent)
store + MyStore.Intent.LoadData
// Instead of store.accept(intent)
store += MyStore.Intent.LoadData
Actor — Business Logic Implementation
Actor Working Principles
Actor is the component responsible for business logic in SimpleMVI. It accepts intents, processes them, and can produce a new state and side effects. Actor is the mediator between the user interface and application data.
public interface Actor<Intent : Any, State : Any, out SideEffect : Any> {
@MainThread
public fun init(
scope: CoroutineScope,
getState: () -> State,
reduce: (State.() -> State) -> Unit,
onNewIntent: (Intent) -> Unit,
postSideEffect: (sideEffect: SideEffect) -> Unit,
)
@MainThread
public fun onIntent(intent: Intent)
@MainThread
public fun destroy()
}
Each Actor has access to:
- CoroutineScope - for launching asynchronous operations
- Current state getter function (
getState
) - State reduction function (
reduce
) - New intent sending function (
onNewIntent
) - Side effect sending function (
postSideEffect
)
Intent Processing
The onIntent(intent: Intent)
method is called by the Store when receiving a new intent and is the main entry point for business logic. Inside this method, the Actor:
- Determines the type of the received intent
- Performs the necessary business logic
- Updates the state
- Generates side effects if necessary
DefaultActor and DslActor: Different Implementation Approaches
SimpleMVI offers two different approaches to Actor implementation:
1. DefaultActor - Object-Oriented Approach
class CounterActor : DefaultActor<CounterIntent, CounterState, CounterSideEffect>() {
override fun handleIntent(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> {
reduce { copy(count = count + 1) }
}
is CounterIntent.Decrement -> {
reduce { copy(count = count - 1) }
}
is CounterIntent.Reset -> {
reduce { CounterState() }
sideEffect(CounterSideEffect.CounterReset)
}
}
}
override fun onInit() {
// Initialization code
}
override fun onDestroy() {
// Cleanup code
}
}
DefaultActor advantages:
- Familiar OOP approach
- Convenient for complex business logic
- Well-suited for large projects
2. DslActor - Functional Approach with DSL
val counterActor = actorDsl<CounterIntent, CounterState, CounterSideEffect> {
onInit {
// Initialization code
}
onIntent<CounterIntent.Increment> {
reduce { copy(count = count + 1) }
}
onIntent<CounterIntent.Decrement> {
reduce { copy(count = count - 1) }
}
onIntent<CounterIntent.Reset> {
reduce { CounterState() }
sideEffect(CounterSideEffect.CounterReset)
}
onDestroy {
// Cleanup code
}
}
DslActor advantages:
- More declarative approach
- Less boilerplate code
- Better suited for small and medium projects
- Type-safe intent handling
Both approaches provide the same functionality, and the choice between them depends on the developer's preferences and project specifics.
Middleware — Extending Functionality
Purpose of Middleware
Middleware in SimpleMVI acts as an observer of events in the Store. Middleware cannot modify events but can react to them, making it ideal for implementing cross-functional logic such as logging, analytics, or debugging.
public interface Middleware<Intent : Any, State : Any, SideEffect : Any> {
// Called when Store is initialized
public fun onInit(state: State)
// Called when a new intent is received
public fun onIntent(intent: Intent, state: State)
// Called when state changes
public fun onStateChanged(oldState: State, newState: State)
// Called when a side effect is generated
public fun onSideEffect(sideEffect: SideEffect, state: State)
// Called when Store is destroyed
public fun onDestroy(state: State)
}
Logging and Debugging Capabilities
SimpleMVI includes a built-in Middleware implementation for logging — LoggingMiddleware
:
val loggingMiddleware = LoggingMiddleware<MyIntent, MyState, MySideEffect>(
name = "MyStore",
logger = DefaultLogger
)
LoggingMiddleware
captures all events in the Store and outputs them to the log:
MyStore | Initialization
MyStore | Intent | LoadData
MyStore | Old state | State(isLoading=false, data=null)
MyStore | New state | State(isLoading=true, data=null)
MyStore | SideEffect | ShowLoading
MyStore | Destroying
This is useful for debugging as it allows you to track the entire data flow in the application.
Implementing Custom Middleware
Creating your own Middleware is very simple:
class AnalyticsMiddleware<Intent : Any, State : Any, SideEffect : Any>(
private val analytics: AnalyticsService
) : Middleware<Intent, State, SideEffect> {
override fun onInit(state: State) {
analytics.logEvent("store_initialized")
}
override fun onIntent(intent: Intent, state: State) {
analytics.logEvent("intent_received", mapOf("intent" to intent.toString()))
}
override fun onStateChanged(oldState: State, newState: State) {
analytics.logEvent("state_changed")
}
override fun onSideEffect(sideEffect: SideEffect, state: State) {
analytics.logEvent("side_effect", mapOf("effect" to sideEffect.toString()))
}
override fun onDestroy(state: State) {
analytics.logEvent("store_destroyed")
}
}
Middleware can be combined, creating a chain of handlers:
val store = createStore(
name = storeName<MyStore>(),
initialState = MyState(),
actor = myActor,
middlewares = listOf(
loggingMiddleware,
analyticsMiddleware,
debugMiddleware
)
)
Key Use Cases for Middleware
-
Logging — recording all events for debugging
-
Analytics — tracking user actions
-
Performance metrics — measuring intent processing time
-
Debugging — visualizing data flow through UI
-
Testing — verifying the correctness of event sequences
It's important to remember that Middleware is a passive observer and cannot modify the events it receives.
Working with the Library
Installation and Setup
Adding the dependency to your project:
// build.gradle.kts
implementation("io.github.arttttt.simplemvi:simplemvi:<version>")
Creating Your First Store
The simplest way to create a Store is to declare a class implementing the Store interface:
class CounterStore : Store<CounterStore.Intent, CounterStore.State, CounterStore.SideEffect> by createStore(
name = storeName<CounterStore>(),
initialState = State(),
actor = actorDsl {
onIntent<Intent.Increment> {
reduce { copy(count = count + 1) }
}
onIntent<Intent.Decrement> {
reduce { copy(count = count - 1) }
}
}
) {
sealed interface Intent {
data object Increment : Intent
data object Decrement : Intent
}
data class State(val count: Int = 0)
sealed interface SideEffect
}
Using the Store
// Creating an instance
val counterStore = CounterStore()
// Initialization
counterStore.init()
// Sending intents
counterStore.accept(CounterStore.Intent.Increment)
// or using operators
counterStore + CounterStore.Intent.Increment
counterStore += CounterStore.Intent.Decrement
// Getting the current state
val currentState = counterStore.state
// Subscribing to the state flow
val statesJob = launch {
counterStore.states.collect { state ->
// Useful work
}
}
// Subscribing to side effects
val sideEffectsJob = launch {
counterStore.sideEffects.collect { sideEffect ->
// Processing side effects
}
}
// Releasing resources
counterStore.destroy()
Kotlin Multiplatform Support
SimpleMVI supports various platforms through Kotlin Multiplatform:
- Android
- iOS
- macOS
- wasm js
Platform-specific code isolation mechanisms use expect/actual
:
// Common code
public expect fun isMainThread(): Boolean
// Android implementation
public actual fun isMainThread(): Boolean {
return Looper.getMainLooper() == Looper.myLooper()
}
// iOS implementation
public actual fun isMainThread(): Boolean {
return NSThread.isMainThread
}
// wasm js implementation
public actual fun isMainThread(): Boolean {
return true // JavaScript is single-threaded
}
Logging is similarly implemented for different platforms:
// Common code
public expect fun logV(tag: String, message: String)
// Android implementation
public actual fun logV(tag: String, message: String) {
Log.v(tag, message)
}
// iOS/wasm js implementation
public actual fun logV(tag: String, message: String) {
println("$tag: $message")
}
Practical Example: Counter
Store Data Model Definition
class CounterStore : Store<CounterStore.Intent, CounterStore.State, CounterStore.SideEffect> {
// Intents - user actions
sealed interface Intent {
data object Increment : Intent
data object Decrement : Intent
data object Reset : Intent
}
// State
data class State(
val count: Int = 0,
val isPositive: Boolean = true
)
// Side effects - one-time events
sealed interface SideEffect {
data object CounterReset : SideEffect
}
}
Store Implementation
class CounterStore : Store<CounterStore.Intent, CounterStore.State, CounterStore.SideEffect> by createStore(
name = storeName<CounterStore>(),
initialState = State(),
actor = actorDsl {
onIntent<Intent.Increment> {
reduce {
copy(
count = count + 1,
isPositive = count + 1 >= 0
)
}
}
onIntent<Intent.Decrement> {
reduce {
copy(
count = count - 1,
isPositive = count - 1 >= 0
)
}
}
onIntent<Intent.Reset> {
reduce { State() }
sideEffect(SideEffect.CounterReset)
}
}
) {
// Data model defined above
}
Connecting to UI (Android Example)
class CounterViewModel : ViewModel() {
private val store = CounterStore()
init {
// Built-in extension for automatic lifecycle management
attachStore(store)
}
val state = store.states.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = store.state
)
val sideEffects = store.sideEffects
fun increment() {
store.accept(CounterStore.Intent.Increment)
}
fun decrement() {
store.accept(CounterStore.Intent.Decrement)
}
fun reset() {
store.accept(CounterStore.Intent.Reset)
}
}
Advanced Features
Library Configuration
SimpleMVI provides a flexible configuration system:
configureSimpleMVI {
// Strict error handling mode (throws exceptions)
strictMode = true
// Logger configuration
logger = object : Logger {
override fun log(message: String) {
// Your logging implementation
}
}
}
Error Handling Modes
strictMode = true
- the library operates in strict mode and throws exceptions when errors are detectedstrictMode = false
(default) - the library operates in lenient mode and only logs errors without interrupting execution
Error Handling
SimpleMVI has special exceptions:
NotOnMainThreadException
- when attempting to call Store methods not from the main threadStoreIsNotInitializedException
- when attempting to use an uninitialized StoreStoreIsAlreadyDestroyedException
- when attempting to use an already destroyed Store
Testing Components
Thanks to clean separation of responsibilities, SimpleMVI components are easy to test:
// Example of Store testing
@Test
fun `increment should increase counter by 1`() {
// Arrange
val store = CounterStore()
store.init()
// Act
store.accept(CounterStore.Intent.Increment)
// Assert
assertEquals(1, store.state.count)
assertTrue(store.state.isPositive)
// Cleanup
store.destroy()
}
Conclusion
As mobile development becomes increasingly complex and the requirements for code quality and application maintainability grow, choosing the right architecture becomes a critical decision. SimpleMVI offers a modern, elegant approach to code organization based on MVI pattern principles and adapted for multiplatform development with Kotlin.
Key Benefits of SimpleMVI
To summarize, the following strengths of the library can be highlighted:
1. Minimalist and Pragmatic Approach
SimpleMVI provides only the necessary components for implementing the MVI pattern, without unnecessary abstractions and complexities. The library follows the "simplicity above all" principle, making it easy to understand and use even for developers who are just getting acquainted with MVI architecture.
2. Full Kotlin Multiplatform Support
Built on Kotlin from the ground up, SimpleMVI is optimized for multiplatform development. The library isolates platform-specific code through the expect/actual mechanism, ensuring compatibility with Android, iOS, macOS, and wasm js.
3. Predictable State Management
Strict adherence to the principles of state immutability and unidirectional data flow makes applications built on SimpleMVI more predictable and less error-prone. Each state change occurs through a clearly defined process, which simplifies debugging and testing.
4. Built-in Protection Against Common Problems
The library provides strict thread safety control, ensuring that interaction with state occurs only on the main thread. This prevents many common errors related to multithreading that can be difficult to detect and fix.
5. Convenient DSL for Declarative Logic Description
Thanks to DSL support, SimpleMVI allows describing business logic in a declarative style, making the code more readable and understandable. This is especially evident when using DslActor, which allows defining intent handling in a type-safe manner.
6. Flexibility and Extensibility
Despite its minimalist approach, SimpleMVI provides mechanisms for extending functionality through the Middleware system. This makes it easy to add capabilities such as logging, analytics, or debugging without affecting the core business logic.
Typical Use Cases
SimpleMVI is particularly well-suited for the following scenarios:
1. Kotlin Multiplatform Projects
If you're developing an application that needs to work on multiple platforms (Android and iOS, web applications), SimpleMVI allows you to use a single architectural approach and shared business logic code.
2. Applications with Complex State and User Interactions
For applications that manage complex state and handle numerous user interactions, the MVI approach provides a clear structure and predictability. SimpleMVI simplifies the implementation of such an approach.
3. Projects with an Emphasis on Testability
Thanks to clear separation of responsibilities between components and predictable data flow, applications built with SimpleMVI are easily unit testable. This makes the library an excellent choice for projects where code quality and testability are a priority.
4. Migration of Existing Projects to MVI Architecture
SimpleMVI can be introduced gradually, starting with individual modules or features, making it suitable for gradual migration of existing projects to MVI architecture.
5. Educational Projects and Prototypes
Due to its simplicity and minimalism, SimpleMVI is well-suited for teaching MVI principles and for rapid prototyping.
Resources for Further Learning
For those who want to deepen their knowledge of SimpleMVI and MVI architecture in general, I recommend the following resources:
- SimpleMVI GitHub repository — source code of the library with usage examples
- SimpleMVI Documentation — official documentation with detailed API description and recommendations
Final Thoughts
SimpleMVI represents a balanced solution for organizing application business logic using modern approaches to architecture. The library offers a clear structure and predictable data flow without imposing unnecessary complexity.
When choosing an architecture for your project, remember that there is no universal solution suitable for all cases. SimpleMVI can be an excellent choice for projects where simplicity, predictability, and multiplatform support are valued, but for some scenarios, other libraries or approaches may be more appropriate.
Experiment, explore different architectural solutions, and choose what best suits the needs of your project and team. And remember: the best architecture is one that helps you effectively solve the tasks at hand, not one that creates additional complexity.