05 - Adapters
Concept
1. Why
Presentation is framework-neutral on purpose, but apps run on a framework. A Flutter app's page logic lives in a Riverpod notifier. A Kotlin app's lives in an AndroidX ViewModel, which survives rotation and owns viewModelScope.
An adapter gives the same Event → Action → State + Effect shape inside the framework's own class, so a page gets the framework's lifecycle and Sinew's structure together. Adapters are optional. They contain no logic of their own, only binding and wiring.
2. Shape
| Adapter | Platform | Binds | Holds |
|---|---|---|---|
sinew_riverpod | Flutter | Riverpod Notifier | EventActionNotifier and StateEffectNotifier mixins, hooks, AsyncValue ↔ ViewState conversions. Depends on sinew_presentation and sinew_models. |
sinew_riverpod_providers | Flutter | Riverpod providers | ready providers for Sinew services. Depends on network, storage and security. |
sinew-viewmodel | Kotlin | AndroidX ViewModel (multiplatform lifecycle) | EventActionViewModel and StateEffectViewModel base classes, the ObserveEffects composable |
| (no Koin adapter) | Kotlin | Koin | dropped in review: the ~10 lines of Koin wiring are an example in the Kotlin Architecture note |
Riverpod
A Riverpod notifier already extends Riverpod's own base class, so the adapter is a mixin that adds the presentation API on top:
class OrderListNotifier extends <Riverpod notifier base> with EventActionNotifier<OrderListState, OrderListEvent, OrderListAction, OrderListEffect> {
build() = OrderListState() // no side effects; loading starts from the Opened event
mapEventToAction(event) = … // as in Presentation
onAction(action) = …
}
| The mixin provides | Implemented with |
|---|---|
onEvent(event) | mapEventToAction, then onAction |
setState(reduce) | state = reduce(state) |
effects, emitEffect(effect) | an EffectEmitter, created on first use and disposed through the notifier's own dispose hook |
Hooks (flutter_hooks) for the page:
| Hook | Does |
|---|---|
useEffects(effects, onEffect) | subscribes once, and cancels when the page leaves. onEffect gets a mounted context. |
useOnOpened(onEvent, event) | sends the opening event once, after the first frame, because Riverpod forbids changing a provider while the tree builds |
useNetworkChecker() | rebuilds when connectivity changes (in sinew_riverpod_providers, because it needs the network checker) |
Conversions with Riverpod's AsyncValue. ViewState is modelled on it, so an AsyncNotifier page and a Sinew page can live side by side during a migration:
AsyncValue<T> | ViewState<T> |
|---|---|
AsyncLoading, previous value kept | Loading(data: previous) |
AsyncData(value) | Done(data) |
AsyncError(error, stackTrace), previous value kept | Failed(data: previous, error, stackTrace). An error that isn't an AppException becomes UnexpectedException with layer VM. |
The mapping is one-to-one in both directions. ViewState.toAsyncValue() drops Done's backend message, which AsyncValue has no place for.
Ready providers (sinew_riverpod_providers): one per Sinew service, built from a config provider the app overrides once at its root:
ProviderScope(overrides = [ sinewConfigProvider.overrideWithValue(config), tokenSourceProvider.overrideWithValue(myTokenSource) ], child = app)
// then: ref.watch(sinewHttpProvider), ref.watch(networkCheckerProvider), ref.watch(secureStoreProvider), …
AndroidX ViewModel
A Kotlin class can't extend both ViewModel and Sinew's base, so the adapter offers ViewModel subclasses with the same API, running actions in viewModelScope:
class OrderListViewModel(orders, cancelOrder) : EventActionViewModel<OrderListState, OrderListEvent, OrderListAction, OrderListEffect>(OrderListState()) {
override fun mapEventToAction(event) = …
override suspend fun onAction(action) = …
}
@Composable OrderListRoute(onOrderSelected) {
viewModel = … // the app's DI
state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.onEvent(Opened) }
ObserveEffects(viewModel.effects) { effect -> … } // collects only while at least STARTED
OrderListScreen(state, viewModel::onEvent, onOrderSelected)
}
| The adapter provides | Implemented with |
|---|---|
state: StateFlow<S>, setState | MutableStateFlow, update |
effects: Flow<F>, emitEffect | a buffered Channel, the same EffectEmitter |
onEvent | mapEventToAction, then viewModelScope.launch { onAction(action) } |
ObserveEffects(effects, onEffect) | repeatOnLifecycle(STARTED) on the main dispatcher, so effects aren't handled while the screen is stopped, and buffered ones arrive when it starts again |
3. API
| Name | Signature (pseudocode) | Package |
|---|---|---|
EventActionNotifier<S, E, A, F> | mixin on a Riverpod notifier: onEvent, setState, effects, emitEffect; abstract mapEventToAction, onAction | sinew_riverpod |
StateEffectNotifier<S, F> | mixin: setState, effects, emitEffect | sinew_riverpod |
useEffects | useEffects<F>(Stream<F> effects, void Function(BuildContext, F) onEffect) | sinew_riverpod |
useOnOpened | useOnOpened<E>(void Function(E) onEvent, E event) | sinew_riverpod |
toViewState, toAsyncValue | AsyncValue<T>.toViewState(module, function): ViewState<T>, ViewState<T>.toAsyncValue(): AsyncValue<T> | sinew_riverpod |
useNetworkChecker | useNetworkChecker(): NetworkStatus | sinew_riverpod_providers |
| providers | sinewConfigProvider, tokenSourceProvider (app overrides); sinewHttpProvider, networkCheckerProvider, secureStoreProvider, keyValueStoreProvider, fieldCipherProvider | sinew_riverpod_providers |
EventActionViewModel<S, E, A, F> | abstract class : ViewModel: state, effects, onEvent; protected setState, emitEffect; abstract mapEventToAction, suspend onAction | sinew-viewmodel |
StateEffectViewModel<S, F> | abstract class : ViewModel: state, effects; protected setState, emitEffect | sinew-viewmodel |
ObserveEffects | @Composable ObserveEffects(effects: Flow<F>, onEffect: suspend (F) -> Unit) | sinew-viewmodel |
4. Build steps
sinew-viewmodel:StateEffectViewModel, thenEventActionViewModel, tested with the lifecycle test helpers. Tests:onEventrunsonActioninviewModelScope;- clearing the ViewModel cancels a running action;
- effects are buffered and delivered once.
ObserveEffects, tested with a Compose UI test: an effect emitted while the screen is stopped is handled once after it starts.sinew_riverpodmixins, tested with aProviderContainer. Tests:- events in, state out;
- effects delivered once;
- disposing the provider disposes the emitter.
toViewState/toAsyncValue, tested for every row of the conversion table, including a non-AppExceptionerror.- The hooks, tested in widget tests:
useOnOpenedsends once across rebuilds, anduseEffectscancels on unmount. - The ready providers, tested so that a missing
sinewConfigProvideroverride fails with a clear message naming the provider.
Done when
- The same order-list logic runs unchanged on the neutral base, the Riverpod mixin and the ViewModel base. Only the class header differs.
- Rotation (Kotlin) and rebuilds (Flutter) neither re-run the opening load nor replay an effect.
- No adapter contains page logic.
5. Edge cases
| Case | Decided behavior |
|---|---|
The app forgets to override sinewConfigProvider | Reading any provider that needs it throws immediately, with a message naming the override to add. |
A Riverpod notifier is rebuilt (build runs again) | build returns the initial state and has no side effects. Loading starts only from the page's opening event, guarded by mapEventToAction. |
| An effect arrives while the screen is stopped (Kotlin) | It stays buffered in the channel. ObserveEffects delivers it when the screen starts again. |
Two ObserveEffects on one effect flow | Not supported: a Channel delivers each effect to one collector. The rule is one collector per screen. |
| A Kotlin app without Compose | EventActionViewModel works on its own. ObserveEffects is the only Compose-dependent part, so it sits in the same module but has no effect on non-Compose callers beyond the dependency. |
| A Flutter app without Riverpod | It uses the neutral bases from sinew_presentation directly. |
Implementation
1. Why
A Kotlin app's page logic lives in an AndroidX ViewModel, which survives rotation and owns viewModelScope. A class can't extend both ViewModel and Sinew's neutral base, so sinew-viewmodel provides ViewModel subclasses with the same API. It also provides ObserveEffects, which collects effects only while the screen is visible.
2. Shape
public abstract class StateEffectViewModel<S, F>(initial: S) : ViewModel() {
private val _state = MutableStateFlow(initial)
public val state: StateFlow<S> = _state.asStateFlow()
protected val currentState: S get() = _state.value
private val emitter = EffectEmitter<F>()
public val effects: Flow<F> = emitter.effects
protected fun setState(reduce: S.() -> S) { _state.update(reduce) }
protected fun emitEffect(effect: F) { emitter.emit(effect) }
override fun onCleared() { emitter.dispose() }
}
public abstract class EventActionViewModel<S, E, A, F>(initial: S) : StateEffectViewModel<S, F>(initial) {
public fun onEvent(event: E) {
val action = mapEventToAction(event) ?: return
viewModelScope.launch { onAction(action) }
}
protected abstract fun mapEventToAction(event: E): A?
protected abstract suspend fun onAction(action: A)
}
@Composable
public fun <F> ObserveEffects(effects: Flow<F>, onEffect: suspend (F) -> Unit) {
val owner = LocalLifecycleOwner.current
LaunchedEffect(effects, owner) {
owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) { effects.collect(onEffect) }
}
}
}
| Rule | Why |
|---|---|
| The multiplatform lifecycle artifacts (JetBrains build of AndroidX Lifecycle) | The same ViewModel and repeatOnLifecycle in commonMain, for all four targets |
ObserveEffects collects on Dispatchers.Main.immediate | An effect is handled in the same frame it's received, so it isn't lost between a lifecycle stop and the collection ending |
The route sends Opened from LaunchedEffect(Unit). Paged slices rely on the pager's Initial-once rule; plain slices on the opened flag. | A rotation re-sends Opened without reloading |
On the screen
@Composable
internal fun OrderListRoute(onOrderSelected: (String) -> Unit, viewModel: OrderListViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val localizer = rememberSinewLocalizer()
val snackbar = LocalSnackbar.current
LaunchedEffect(Unit) { viewModel.onEvent(OrderListEvent.Opened) }
ObserveEffects(viewModel.effects) { effect ->
when (effect) {
is OrderListEffect.Cancelled -> snackbar.show("Order ${effect.number} cancelled")
is OrderListEffect.Failed -> snackbar.showError(effect.error.displayMessage(localizer))
}
}
OrderListScreen(state = state, onEvent = viewModel::onEvent, onOrderSelected = onOrderSelected)
}
3. API
| Name | Kotlin |
|---|---|
StateEffectViewModel<S, F> | public abstract class : ViewModel() |
EventActionViewModel<S, E, A, F> | public abstract class : StateEffectViewModel<S, F> |
ObserveEffects | @Composable public fun <F> ObserveEffects(effects: Flow<F>, onEffect: suspend (F) -> Unit) |
There are no AsyncValue conversions on Kotlin: that's a Riverpod type.
4. Build steps
- Both ViewModel bases, tested with
ViewModelStore+TestScope(Dispatchers.setMain). Tests:onEventruns inviewModelScope;clear()cancels a running action and disposes the emitter.
ObserveEffects, tested withrunComposeUiTestand a test lifecycle owner: an effect sent while the owner isCREATEDis handled once after it moves toSTARTED.
Done when
- The sample order list runs on all four targets on
EventActionViewModel. - A rotation on Android neither reloads the list nor replays a snackbar (instrumented test).
5. Edge cases
| Case | Decided behavior |
|---|---|
Two ObserveEffects on the same effects flow | Not supported: the channel delivers each effect to one collector. One collector per screen. |
| A non-Compose Android screen (Views) | It collects effects with repeatOnLifecycle itself. ObserveEffects is only a convenience. |
| The view model is shared by two screens | Each screen gets state. Only one should collect effects. |
- Duplicated code between
EventActionHandlerandEventActionViewModel, about 15 lines, rather than a delegate object. It keeps both classes readable, and the sharedEffectEmitterholds the subtle part.