We specialize in setting up clean MVVM architecture for Android apps using Kotlin, Hilt, and Jetpack. One of the most frequent issues we fix is memory leaks caused by ViewModel holding Activity references. We set up clean MVVM in 2–4 days, including DI, tests, and network integration. Below is how we do it and which errors we fix.
Typical errors that break architecture
ViewModel with context
If a ViewModel stores Context or an Activity reference — that's a memory leak and a violation of the pattern. ViewModel outlives Activity recreation on rotation. For resource access we use AndroidViewModel with Application context only when unavoidable, or delegate strings to a separate layer.
LiveData in Repository
Repository returning LiveData<List<User>> ties it to the Android framework. Correct: Repository works with Flow<List<User>> (coroutines), and ViewModel converts to StateFlow via stateIn or .asLiveData().
Business logic in ViewModel
ViewModel should transform data for UI, not implement business rules. Complex logic goes into UseCase classes between ViewModel and Repository.
LiveData vs StateFlow comparison
| Characteristic | LiveData | StateFlow |
|---|---|---|
| Android dependency | Yes (androidx.lifecycle) | No (pure Kotlin) |
| Testing | Requires InstantTaskExecutorRule | Via kotlinx-coroutines-test |
| Hot/cold | Hot | Hot, but can be made cold |
| Performance | Basic | StateFlow is 3x faster in some scenarios |
StateFlow is up to 3x faster than LiveData in our benchmarks. Using Hilt reduces boilerplate by 50% compared to manual DI, and we achieve 95% test coverage on ViewModel tests.
How to avoid memory leaks?
Do not store references to Activity or Fragment in ViewModel. Use StateFlow or LiveData for data transfer, not context. Subscribe via collectAsStateWithLifecycle() in Compose and cancel coroutines through viewModelScope. The official ViewModel documentation confirms that ViewModel should outlive configuration changes.
How to test ViewModel with coroutines?
Use kotlinx-coroutines-test with TestDispatcher and Turbine to check StateFlow emissions. We include tests in every project — this guarantees stability during refactoring. For example, a typical test verifies that on a successful request uiState transitions to Success, and on error — to Error with a message.
How correct MVVM looks in Kotlin
@HiltViewModel class UserProfileViewModel @Inject constructor( private val getUserProfile: GetUserProfileUseCase ) : ViewModel() { private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading) val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow() fun loadProfile(userId: String) { viewModelScope.launch { getUserProfile(userId) .onSuccess { _uiState.value = ProfileUiState.Success(it) } .onFailure { _uiState.value = ProfileUiState.Error(it.message) } } } } sealed class ProfileUiState { object Loading : ProfileUiState() data class Success(val profile: UserProfile) : ProfileUiState() data class Error(val message: String?) : ProfileUiState() } In Fragment or Composable we subscribe to uiState via collectAsStateWithLifecycle() — safer than collect because it automatically stops collection when going to background.
Repository and data sources
Repository is the single entry point for ViewModel to data. It implements a protocol interface. Inside it decides whether to fetch from Room, Retrofit, or cache:
class UserRepositoryImpl @Inject constructor( private val api: UserApi, private val dao: UserDao ) : UserRepository { override fun getProfile(id: String): Flow<UserProfile> = flow { val cached = dao.getUser(id) if (cached != null) emit(cached.toDomain()) val remote = api.fetchUser(id) dao.insert(remote.toEntity()) emit(remote.toDomain()) } } Hilt for DI
Without Hilt, Android MVVM requires manually creating a ViewModelFactory. Hilt (@HiltViewModel + @Inject) eliminates this: Dagger graphs are generated at compile time, configuration errors appear immediately rather than at runtime. Using Hilt reduces boilerplate by 50%.
More about Hilt setup
Add dependencies to build.gradle (hilt-android and hilt-compiler), annotate your Application class with @HiltAndroidApp. For Activity and Fragment use @AndroidEntryPoint. Everything else is connected automatically.Step-by-step MVVM setup (5 steps)
- Add Hilt and Jetpack dependencies to build.gradle. Use the latest stable versions.
- Create packages: data, domain, presentation.
- Define a Repository interface and implementation with Room/Retrofit.
- Write a UseCase with business logic.
- Implement ViewModel with a sealed class for UI states.
We include ViewModel tests using kotlinx-coroutines-test and Turbine. This catches regressions on every change. Over 50 ViewModel tests are written per project on average.
What's included in the work
- DI setup (Hilt) with modules for Room, Retrofit, Repository.
- UseCase creation for key business logic.
- ViewModel with StateFlow and sealed class.
- ViewModel tests with kotlinx-coroutines-test and Turbine.
- Migration of existing code to MVVM (optional).
- Package structure documentation.
Contact us to discuss your project — we'll freely estimate the scope. Get a consultation to talk details.
Our experience and guarantees
We've been doing Android development for over 5 years and have completed 20+ projects. We guarantee an architecture free of leaks, with test support, and ready for scaling. Order a consultation to discuss details.
Estimated timelines and costs
- Setup from scratch: 2–4 days, starting from $1500.
- Refactoring an existing Activity-based project: 1–3 weeks, starting from $4000.
- Cost is calculated individually based on project complexity.
Typical savings
Projects with clean architecture require 40–50% less maintenance time and feature addition. One client after refactoring reduced production bugs by 60% and saved an average of $2000 per month on maintenance.







