Architecture
MVI Data Flow
YAMV implements strict unidirectional data flow:
flowchart LR
UI(["π₯οΈ UI"])
UI -->|"dispatch(Intention)"| Store
Store(["πͺ Store"])
Store -->|"routes to"| Features
Features(["π§© Features"])
Features -->|"OutcomeβΉSβΊ"| Store
Store -->|"StateFlowβΉSβΊ"| UI
Store -.->|"effects"| UI
The cycle: UI dispatches an intention β Store routes it to matching features β features emit outcomes β outcomes update state β UI observes new state.
Outcomes come in three kinds:
StateOutcomeβ pure(S) β Sreducer, applied viascan()EffectOutcomeβ side effect (navigation, toast, analytics)IntentionOutcomeβ re-dispatches another intention back into the cycle
Outcome Types
flowchart LR
feature(["π§© Feature"]) --> outcome{"OutcomeβΉSβΊ"}
outcome -->|StateOutcome| reducer["π reduces state\n`(S) β S`"]
outcome -->|EffectOutcome| effect["β‘ side effect"]
outcome -->|IntentionOutcome| intention["π re-dispatches\nintention"]
Declare outcome subclasses as standalone classes β they are decoupled from features and testable in isolation. See Features Guide.
Feature Abstraction Levels
Sealed Feature<S> has two raw shapes; four typed wrappers cover the common cases. Choose the simplest one that fits:
| Type | API | When to use |
|---|---|---|
Feature.FlowFeature<S> |
(Flow<Any>) -> Flow<Outcome<S>> |
Low-level; multi-intention or untyped |
Feature.FlowUnitFeature<S> |
(Flow<Any>) -> Flow<Unit> |
Low-level fire-and-forget (no state contribution) |
TypedFeature<S, I> |
(Flow<I>) -> Flow<Outcome<S>> |
Typed stream β outcomes |
FunctionTypedFeature<S, I> |
suspend (I) -> Outcome<S> |
One outcome per intention |
ActionTypedFeature<S, I> |
suspend (I) -> Unit |
One side effect per intention, no state contribution |
TypedUnitFeature<S, I> |
(Flow<I>) -> Flow<Unit> |
Typed streamed side effects, no state contribution |
All four typed wrappers call .wrap() to become a Feature<S> β automatic with @AutoFeature (Hilt) or the Koin mviStore { add(β¦) } DSL, manual otherwise.
Use functionTypedFeature<S, I> { } builder for inline definitions.
Dispatcher Architecture
CoroutineDispatcherConfig controls which dispatcher each part of the pipeline runs on:
MviRuntime owns CoroutineScope(SupervisorJob() + reducerDispatcher)
β
βββ launch(reducerDispatcher) ββ Reducer collector: scan outcomes β update StateFlow
βββ launch(reducerDispatcher) ββ Effect collector: forward EffectOutcomes
βββ launch(intentionDispatcher) ββ IntentionOutcome collector: re-dispatch
β
βββ FeatureRouter initialized with this scope
β
βββ launch(featureDispatcher) ββ Feature A coroutine
βββ launch(featureDispatcher) ββ Feature B coroutine
βββ launch(featureDispatcher) ββ Feature C coroutine
Default dispatchers (DefaultCoroutineDispatcherConfig):
| Operation | Dispatcher | Rationale |
|---|---|---|
| Intention dispatch | Main |
UI-safe, async launch |
Reducers (scan) |
Main |
State mutations must be serialized |
| Effects | Main |
Observers expect UI thread |
| Features | Default |
Non-blocking, concurrent processing |
Customizing Dispatchers
Override CoroutineDispatcherConfig to control dispatcher assignment per intention or feature:
class CustomDispatcherConfig : CoroutineDispatcherConfig {
// Single-threaded dispatcher for sequential feature processing
private val singleThread = Dispatchers.Default.limitedParallelism(1)
override fun provideIntentionDispatcher(intention: Any?) = Dispatchers.Main
override fun provideReducerDispatcher() = Dispatchers.Main
override fun provideFeatureDispatcher(feature: Any) = when (feature) {
is NetworkFeature -> Dispatchers.IO
else -> singleThread
}
}
Per-feature dispatcher: Features can implement HasFeatureDispatcher to declare their own dispatcher, which takes precedence over CoroutineDispatcherConfig:
class NetworkFeature : TypedFeature<MyState, FetchData>, HasFeatureDispatcher {
override val featureDispatcher = Dispatchers.IO
// ...
}
Features with HasFeatureScope (via DefaultFeatureScope) automatically expose the scope's dispatcher.
Per-state config (Hilt): Annotate a CoroutineDispatcherConfig class with @AutoDispatcherConfig to have the Dagger module generated automatically:
// Global default β applies to all states without a specific override
@AutoDispatcherConfig
class IoDispatcherConfig : CoroutineDispatcherConfig { ... }
// Per-state β overrides default for CounterState only
@AutoDispatcherConfig(CounterState::class)
class CounterDispatcherConfig : CoroutineDispatcherConfig { ... }
// Multi-state β same config for several states
@AutoDispatcherConfig(TimerState::class, AnimationState::class)
class SharedConfig : CoroutineDispatcherConfig { ... }
Precedence: per-state > global default > DefaultCoroutineDispatcherConfig()
See Code Generation β @AutoDispatcherConfig for generated output details.
Subscription safety
FeatureRouter uses CompletableDeferred to ensure all features are subscribed to the intention SharedFlow before the first intention is dispatched. This prevents race conditions at startup.
Exception Handling
MviExceptionHandler controls what happens when an exception occurs in the MVI pipeline. The default handler rethrows and cancels the runtime scope β a broken feature means illegal application state.
// Default: fail fast (recommended)
val runtime = MviRuntime(
features = features,
defaultState = MyState(),
)
// Custom: log + rethrow
val runtime = MviRuntime(
features = features,
defaultState = MyState(),
exceptionHandler = MviExceptionHandler { context, e ->
crashlytics.recordException(e)
throw e // still fail fast, but reported
},
)
The handler receives MviErrorContext with:
| Field | Description |
|---|---|
source: ErrorSource |
Where it happened: REDUCER, FEATURE, EFFECT, INTENTION_REDISPATCH |
intention: Any? |
The intention being processed (when available) |
feature: Feature<*>? |
The feature that failed (for FEATURE source) |
Fail-fast behavior (default): When the handler rethrows, the entire CoroutineScope is cancelled β all collectors (reducers, effects, intention re-dispatch) and all features stop. The runtime is dead.
Degraded mode (opt-in): If the handler does not rethrow, the pipeline continues with the previous state. This is the user's explicit choice β the framework does not silently swallow exceptions.
Lifecycle
sequenceDiagram
participant UI as π₯οΈ UI
participant Store as πͺ MviRetainedStore
participant Runtime as βοΈ MviRuntime
participant Router as π¦ FeatureRouter
participant F as π§© Features
Note over Store,Runtime: Construction
Store->>Runtime: create MviRuntime
Runtime->>Router: initialize(scope, config, exceptionHandler)
Router->>F: launch coroutine per feature
F-->>Router: subscribe to intentionFlow
Note over Router: CompletableDeferred β
Note over UI,F: Dispatch
UI->>Store: dispatch(intention)
Store->>Runtime: scope.launch { dispatchIntention }
Runtime->>Router: intentionFlow.emit(intention)
Router->>F: intention broadcast
F-->>Router: OutcomeβΉSβΊ
Router-->>Runtime: outcomeFlow
Runtime-->>Runtime: scan β StateFlow
Runtime-->>UI: StateFlowβΉSβΊ updated
Note over UI,F: Cleanup
UI->>Store: onCleared()
Store->>Runtime: clear()
Runtime->>Router: shutdown()
Router->>F: cancel all jobs + scopes