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:

  1. Unidirectional Data Flow — data moves in one direction, forming a cycle: from user action to model change, then to view update.

  2. Immutable State — the application state is not changed directly; instead, a new state is created based on the previous one.

  3. Determinism — the same user actions with the same initial state always lead to the same result.

In MVI architecture:

In addition to these core components, MVI often includes:

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:

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:

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:

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:

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:

  1. Focuses on domain logic, without imposing solutions for the UI layer
  2. Adheres to the "simplicity above all" principle, providing a minimal set of necessary components
  3. Is optimized for Kotlin Multiplatform, ensuring compatibility with various platforms
  4. Strictly controls thread safety, guaranteeing that interaction with state occurs only on the main thread
  5. Provides flexible error handling configuration through the configuration system

The main advantages of SimpleMVI compared to alternatives:

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:

  1. Creation - instantiating the Store object with necessary dependencies

  2. Initialization - calling the init() method, preparing internal components

  3. Active use - processing intents through the accept(intent) method

  4. Destruction - calling the destroy() method, releasing resources

It's important to understand that:

State Management

Store provides the following capabilities for working with state:

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:

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:

  1. Determines the type of the received intent
  2. Performs the necessary business logic
  3. Updates the state
  4. 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:

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:

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

  1. Logging — recording all events for debugging

  2. Analytics — tracking user actions

  3. Performance metrics — measuring intent processing time

  4. Debugging — visualizing data flow through UI

  5. 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:

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

Error Handling

SimpleMVI has special exceptions:

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:

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.