210 reads

This Tiny Kotlin Library Might Be the Cleanest Way to Build Cross-Platform Apps

by Android InsightsMay 7th, 2025
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

SimpleMVI is a lightweight yet powerful solution for implementing the MVI pattern in Kotlin multiplatform projects.

People Mentioned

Mention Thumbnail

Company Mentioned

Mention Thumbnail
featured image - This Tiny Kotlin Library Might Be the Cleanest Way to Build Cross-Platform Apps
Android Insights HackerNoon profile picture
0-item

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:

  • 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:

  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:

  • 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:

  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:

  • 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:

  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:

  • 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

  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:

  • 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 detected
  • strictMode = 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 thread
  • StoreIsNotInitializedException - when attempting to use an uninitialized Store
  • StoreIsAlreadyDestroyedException - 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:

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.

Trending Topics

blockchaincryptocurrencyhackernoon-top-storyprogrammingsoftware-developmenttechnologystartuphackernoon-booksBitcoinbooks