
Delegate-based ViewModel composition enabling smaller, testable MVVM modules; unidirectional state flow, pure delegates, explicit state/effect handling, fast builds and Compose-friendly UI binding.
Architecture for Android applications in Kotlin using the MVVM pattern.**
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:
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:
copy() makes updates explicit and safe.navigationState and showErrorMessage model 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:
scope.updateState { }.true if handled, false otherwise).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.by factory.create(...) avoids boilerplate while still exposing a typed
SampleViewModel interface.autoInit = false and trigger init events manually if needed; this is also useful for
mocks in tests.DefaultViewModelFactory can 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:
NavigationState annotated with @Immutable to 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:
model as a stream for Compose.@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)
}State immutable and updated only via copy.Event is handled by exactly one delegate.
setOf(...) (unordered).scope for async operations (it is lifecycle-bound).getState() inside coroutines when you need fresh state values.StateToModelMapper to keep Compose simple and stable.In a typical MVVM project, ViewModels tend to grow into “God objects”:
when(event) (or dozens of public methods),View Model Delegates standardizes ViewModel logic as a composition of small event handlers ( “delegates”), while keeping:
updateState { copy(...) }),CoroutineScope),State → UI Model.It enforces a predictable “unidirectional” flow:
UI → Event → Delegate → State update → UI re-render
and improves maintainability by making your ViewModel:
The sample project demonstrates the usage of the library in a simple screen with loading, warning, data display, snackbar, and navigation.
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.
Architecture for Android applications in Kotlin using the MVVM pattern.**
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:
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:
copy() makes updates explicit and safe.navigationState and showErrorMessage model 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:
scope.updateState { }.true if handled, false otherwise).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.by factory.create(...) avoids boilerplate while still exposing a typed
SampleViewModel interface.autoInit = false and trigger init events manually if needed; this is also useful for
mocks in tests.DefaultViewModelFactory can 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:
NavigationState annotated with @Immutable to 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:
model as a stream for Compose.@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)
}State immutable and updated only via copy.Event is handled by exactly one delegate.
setOf(...) (unordered).scope for async operations (it is lifecycle-bound).getState() inside coroutines when you need fresh state values.StateToModelMapper to keep Compose simple and stable.In a typical MVVM project, ViewModels tend to grow into “God objects”:
when(event) (or dozens of public methods),View Model Delegates standardizes ViewModel logic as a composition of small event handlers ( “delegates”), while keeping:
updateState { copy(...) }),CoroutineScope),State → UI Model.It enforces a predictable “unidirectional” flow:
UI → Event → Delegate → State update → UI re-render
and improves maintainability by making your ViewModel:
The sample project demonstrates the usage of the library in a simple screen with loading, warning, data display, snackbar, and navigation.
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.