Architecture for Android applications in Kotlin using the MVVM pattern.**
- Delegation-friendly: solves the problem of oversized ViewModels
- Structured, uses the UDF (Unidirectional Data Flow) approach
- Performant: most of the code runs on the JVM without Android dependencies, and modules build faster
- Testable and predictable: everything is built around State and the JVM, with minimal Android dependencies
- Modern, Jetpack Compose–friendly
- Simple: minimal code required for implementation and a basic tech stack
- Language: Kotlin
- Threading: Coroutines + Flow
- Android X: Android lifecycle ViewModel
Groovy DSL:
dependencies {
implementation platform('com.yugyd.viewmodeldelegates:viewmodeldelegates-bom:{latest_version}')
implementation 'com.yugyd.viewmodeldelegates:viewmodeldelegates'
implementation 'com.yugyd.viewmodeldelegates:viewmodeldelegates-ui'
}Kotlin DSL:
dependencies {
implementation(platform("com.yugyd.viewmodeldelegates:viewmodeldelegates-bom:{latest_version}"))
implementation("com.yugyd.viewmodeldelegates:viewmodeldelegates")
implementation("com.yugyd.viewmodeldelegates:viewmodeldelegates-ui")
}Why: the library is split conceptually into:
- domain/runtime part (event routing + state store),
- ui part (binding/mapping to Compose-friendly model).
Create a single immutable state for the screen.
data class State(
val arguments: Arguments = Arguments(),
val isLoading: Boolean = false,
val isWarning: Boolean = false,
val data: String = "",
val navigationState: NavigationState? = null,
) {
data class Arguments(val userName: String = "")
sealed interface NavigationState {
object NavigateToFavourites : NavigationState
}
}Why:
- Immutable state +
copy()makes updates explicit and safe. navigationStateandshowErrorMessagemodel one-time effects (more on that later).
Define all inputs as a sealed interface/class:
sealed interface Event {
object LoadData : Event
object OnActionClicked : Event
object OnSnackbarDismissed : Event
object OnNavigationHandled : Event
}Why: UI communicates only via events; no direct mutation, no “call random method” style API.
interface SampleViewModel : JvmViewModel<Event, State> {
// Add State/Events here for encapsulation
}In the sample, the contract embeds Event and State inside the interface; that’s a good practice
for feature encapsulation.
class OnNavigationHandledViewModelDelegate : SampleViewModelDelegate {
override fun accept(
event: Event,
viewModel: ViewModelDelegates<Event, State>,
scope: CoroutineScope,
getState: () -> State
): Boolean {
if (event != Event.OnNavigationHandled) return false
viewModel.updateState { it.copy(navigationState = null) }
return true
}
}class LoadDataViewModelDelegate(
private val repository: SampleRepository,
) : SampleViewModelDelegate {
override fun accept(
event: Event,
viewModel: ViewModelDelegates<Event, State>,
scope: CoroutineScope,
getState: () -> State
): Boolean {
if (event != Event.LoadData) return false
// 1) Update state
viewModel.updateState {
it.copy(
isLoading = true,
isWarning = false,
message = "",
showErrorMessage = false,
)
}
// 2) Run async work in the provided scope
scope.launch {
// Add your logic
}
return true
}
}Why this structure is important:
- All async work is tied to the ViewModel lifecycle via
scope. - State updates are explicit and isolated via
updateState { }. - Each delegate handles a single event (returns
trueif handled,falseotherwise). - Delegates are pure Kotlin classes (no Android dependencies).
- Delegates encapsulate ViewModel and UseCase/Interactor logic.
- To reuse logic or store local state (e.g., a Job), you can use SharedDelegates, which can be attached to different ViewModelDelegates. Ensure a single instance via DI.
The sample uses a builder function (can be replaced by DI framework):
fun buildSampleBinder(): SampleBinder {
// ...
val viewModel = object : SampleViewModel,
JvmViewModel<Event, State> by DefaultViewModelFactory().create(
initialState = State(arguments = arguments),
viewModelDelegates = setOf(
LoadDataViewModelDelegate(repository),
OnActionClickedViewModelDelegate(),
OnNavigationHandledViewModelDelegate(),
OnSnackbarDismissedViewModelDelegate(),
),
initEvents = setOf(Event.LoadData),
logger = buildLogger(),
name = "SampleViewModel",
) {}
return SampleBinder(
viewModel = viewModel,
mapper = SampleMapper(),
)
}Why:
initEvents = setOf(Event.LoadData)triggers initial loading automatically.- Kotlin delegation
by factory.create(...)avoids boilerplate while still exposing a typedSampleViewModelinterface. - Delegates are composed without inheritance.
- You can set
autoInit = falseand trigger init events manually if needed; this is also useful for mocks in tests. - Logger can be customized or disabled (null) for production.
- ViewModel name is useful for logging.
DefaultViewModelFactorycan be wrapped in DI framework factories.
Sample SampleBinder.Model:
data class Model(
val isLoading: Boolean = false,
val isWarning: Boolean = false,
val data: String = "",
val navigationState: NavigationUiState? = null,
) {
sealed interface NavigationUiState {
object NavigateToFavourites : NavigationUiState
}
}Why:
- UI model can differ from domain state (formatting, UI flags, string resources, etc.).
- Use a wrapper for
NavigationStateannotated with@Immutableto eliminate mapping and make the code simpler.
class SampleMapper : StateToModelMapper<State, Model> {
override fun map(state: State): Model {
return Model(
isLoading = state.isLoading,
isWarning = state.isWarning,
data = state.data,
navigationState = when (state.navigationState) {
State.NavigationState.NavigateToFavourites -> Model.NavigationUiState.NavigateToFavourites
null -> null
},
)
}
}Why: mapping isolates UI from domain changes and keeps Compose code simple.
class SampleBinder(
private val viewModel: SampleViewModel,
mapper: SampleMapper,
) : ModelViewModelBinder<Event, State, Model>(
viewModel = viewModel,
initialModel = Model(),
stateToModelMapper = mapper,
) {
fun onActionClicked() = viewModel.accept(Event.OnActionClicked)
fun onSnackbarDismissed() = viewModel.accept(Event.OnSnackbarDismissed)
fun onNavigationHandled() = viewModel.accept(Event.OnNavigationHandled)
}Why:
- Binder exposes
modelas a stream for Compose. - Binder is the single place where UI triggers events.
@Composable
fun SampleScreen(binder: SampleBinder) {
val state by binder.model.collectAsStateWithLifecycle()
// ...
}class SimpleHomeBinder(
private val viewModel: HomeViewModel,
) : StateViewModelBinder<Event, State>(viewModel) {
fun onEvent(event: Event) = viewModel.accept(event)
}- One delegate = one responsibility (loading, navigation, snackbar).
- Keep delegates pure (no Android Context, no UI references).
- Prefer repository/use-case injection into delegates (constructor DI).
- Keep
Stateimmutable and updated only viacopy. - Model one-time effects (navigation/snackbar) as nullable fields or consumable flags.
- Always add “handled” events to clear one-time effects.
- Ensure each
Eventis handled by exactly one delegate.- In the sample, delegates are passed as a
setOf(...)(unordered). - If two delegates handle the same event, behavior can become ambiguous.
- Prefer designing events so they have a single clear owner.
- In the sample, delegates are passed as a
- Use the provided
scopefor async operations (it is lifecycle-bound). - Use
getState()inside coroutines when you need fresh state values.
- Use
StateToModelMapperto keep Compose simple and stable. - Put formatting/transformation logic in the mapper, not inside Composables.
In a typical MVVM project, ViewModels tend to grow into “God objects”:
- a huge
when(event)(or dozens of public methods), - mixed concerns (loading, error handling, navigation, analytics, validation),
- hard-to-test logic due to tight coupling and large state mutation blocks,
- inconsistent patterns between features.
View Model Delegates standardizes ViewModel logic as a composition of small event handlers ( “delegates”), while keeping:
- single immutable State (for rendering UI),
- Events (inputs from UI / lifecycle),
- deterministic state updates (
updateState { copy(...) }), - structured concurrency (delegates receive a
CoroutineScope), - optional UI Binder to map domain
State→ UIModel.
It enforces a predictable “unidirectional” flow:
UI → Event → Delegate → State update → UI re-render
and improves maintainability by making your ViewModel:
- modularity (each delegate handles a single responsibility.)
- composable (add/remove delegates),
- testable (test each delegate in isolation),
The sample project demonstrates the usage of the library in a simple screen with loading, warning, data display, snackbar, and navigation.
- Language: Kotlin
- Architecture: clean
- UI: Compose, Material 3
- Navigation: Jetpack Compose Navigation 3
Copyright 2025 Roman Likhachev
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.