Skip to content

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) β†’ S reducer, applied via scan()
  • 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