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. Unidirectional Data Flow — data moves in one direction, forming a cycle: from user action to model change, then to view update. Unidirectional Data Flow — data moves in one direction, forming a cycle: from user action to model change, then to view update. Unidirectional Data Flow Immutable State — the application state is not changed directly; instead, a new state is created based on the previous one. Immutable State — the application state is not changed directly; instead, a new state is created based on the previous one. Immutable State Determinism — the same user actions with the same initial state always lead to the same result. Determinism — the same user actions with the same initial state always lead to the same result. Determinism 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. Model represents the immutable application state that fully describes the data needed to display the UI. Model View passively displays the current state and transmits user actions as Intents. View Intent describes the intentions of the user or system that can potentially change the application state. Intent 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). Reducer — a function that takes the current state and Intent, and returns a new state. Reducer SideEffect — side effects that don't affect the state but require interaction with external systems (e.g., navigation, notifications, API requests). SideEffect 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 Model — data and business logic Model View — user interface View Controller — handling user input Controller 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 Model — data and business logic Model View — passive user interface View Presenter — mediator between Model and View Presenter 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 Model — data and business logic Model View — user interface View ViewModel — transforms data from Model into a format convenient for View ViewModel 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 Predictability — a deterministic approach to state management Predictability Immutability — state is not changed but replaced Immutability Unidirectional data flow — clear and transparent sequence of events Unidirectional data flow 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 Focuses on domain logic, without imposing solutions for the UI layer Focuses on domain logic Adheres to the "simplicity above all" principle, providing a minimal set of necessary components Adheres to the "simplicity above all" principle Is optimized for Kotlin Multiplatform, ensuring compatibility with various platforms Is optimized for Kotlin Multiplatform Strictly controls thread safety, guaranteeing that interaction with state occurs only on the main thread Strictly controls thread safety Provides flexible error handling configuration through the configuration system Provides flexible error handling configuration 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 Fewer dependencies and smaller library size compared to more complex solutions Fewer dependencies and smaller library size Lower entry threshold for understanding and use Lower entry threshold Full Kotlin approach using modern language constructs Full Kotlin approach Convenient DSL for describing business logic Convenient DSL Clear separation of responsibilities between components Clear separation of responsibilities 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() } 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 Creation - instantiating the Store object with necessary dependencies Creation - instantiating the Store object with necessary dependencies Creation Initialization - calling the init() method, preparing internal components Initialization - calling the init() method, preparing internal components Initialization init() Active use - processing intents through the accept(intent) method Active use - processing intents through the accept(intent) method Active use accept(intent) Destruction - calling the destroy() method, releasing resources Destruction - calling the destroy() method, releasing resources Destruction destroy() 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 All public Store methods must be called only on the main thread (marked with the @MainThread annotation) only on the main thread @MainThread After calling destroy(), the Store cannot be used; attempts to access a destroyed Store will result in an error destroy() The Store must be initialized with the init() method before use init() 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 Access to the current state via the state property Access to the current state via the state property Access to the current state state Observing state changes via the states flow Observing state changes via the states flow Observing state changes states Processing side effects via the sideEffects flow Processing side effects via the sideEffects flow Processing side effects sideEffects 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. StateFlow Flow 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 // 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() } 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) CoroutineScope - for launching asynchronous operations CoroutineScope Current state getter function (getState) Current state getter function getState State reduction function (reduce) State reduction function reduce New intent sending function (onNewIntent) New intent sending function onNewIntent Side effect sending function (postSideEffect) 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: onIntent(intent: Intent) Determines the type of the received intent Performs the necessary business logic Updates the state Generates side effects if necessary 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 } } 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 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 } } 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 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) } 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: LoggingMiddleware val loggingMiddleware = LoggingMiddleware<MyIntent, MyState, MySideEffect>( name = "MyStore", logger = DefaultLogger ) val loggingMiddleware = LoggingMiddleware<MyIntent, MyState, MySideEffect>( name = "MyStore", logger = DefaultLogger ) LoggingMiddleware captures all events in the Store and outputs them to the log: LoggingMiddleware 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 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") } } 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 ) ) 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 Logging — recording all events for debugging Logging — recording all events for debugging Logging Analytics — tracking user actions Analytics — tracking user actions Analytics Performance metrics — measuring intent processing time Performance metrics — measuring intent processing time Performance metrics Debugging — visualizing data flow through UI Debugging — visualizing data flow through UI Debugging Testing — verifying the correctness of event sequences Testing — verifying the correctness of event sequences Testing 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>") // 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 } 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() // 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 Android iOS macOS wasm js Platform-specific code isolation mechanisms use expect/actual: 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 } // 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") } // 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 } } 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 } 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) } } 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 } } } 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 strictMode = true - the library operates in strict mode and throws exceptions when errors are detected strictMode = true strictMode = false (default) - the library operates in lenient mode and only logs errors without interrupting execution strictMode = false 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 NotOnMainThreadException - when attempting to call Store methods not from the main thread NotOnMainThreadException StoreIsNotInitializedException - when attempting to use an uninitialized Store StoreIsNotInitializedException StoreIsAlreadyDestroyedException - when attempting to use an already destroyed Store StoreIsAlreadyDestroyedException 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() } // 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 SimpleMVI GitHub repository — source code of the library with usage examples SimpleMVI GitHub repository SimpleMVI Documentation — official documentation with detailed API description and recommendations SimpleMVI Documentation 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.