Mengorganisasi kode mobile dengan arsitektur yang matang: MVVM dan MVI, Repository pattern untuk memisahkan sumber data, dependency injection untuk modularitas, serta satu arah aliran data — sambil menyusun arsitektur clean untuk Fitku

Sejauh ini kita sudah bisa membuat aplikasi di berbagai platform — tetapi aplikasi yang tumbuh akan kacau tanpa arsitektur. Di episode 8 ini kita belajar bagaimana tim profesional mengorganisasi kode mobile: MVVM/MVI, Repository pattern, dan dependency injection. Inilah yang membedakan demo app dari aplikasi production yang bisa dipelihara bertahun-tahun.
Mengapa arsitektur penting? Karena 90% biaya aplikasi ada di maintenance. Fitur baru, ganti API, atau tambah anggota tim — semua lebih murah jika kode terstruktur rapi. Arsitektur bukan teori belaka: ia adalah keputusan praktis untuk mengendalikan kompleksitas.
Tanpa arsitektur, aktivitas seperti "simpan workout" menyebar: sebagian di UI, sebagian di ViewModel, sebagian langsung ke database. Konsekuensinya: sulit diuji, sulit diganti backend, dan bug bermunculan di tempat tak terduga.
Tujuannya satu arah aliran data (unidirectional) dan pemisahan tanggung jawab yang jelas antar lapisan.
MVVM adalah standar de facto arsitektur mobile (dan dipakai bawaan oleh Android ViewModel):
Contoh di Android:
class HomeViewModel(
private val repository: WorkoutRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun loadToday() {
viewModelScope.launch {
_uiState.update {
it.copy(
steps = repository.fetchStepsToday(),
workouts = repository.fetchWorkoutsToday()
)
}
}
}
fun startWorkout(name: String) {
viewModelScope.launch {
repository.saveWorkout(Workout(name = name, startedAt = clock.now()))
}
}
}
data class HomeUiState(
val steps: Int = 0,
val workouts: List<Workout> = emptyList(),
val isLoading: Boolean = false,
)View hanya membaca uiState dan memanggil fungsi ViewModel — tanpa logika bisnis di UI. Di SwiftUI, padanannya ObservableObject + @MainActor.
MVI memperketat MVVM dengan satu arah aliran data yang eksplisit: UI mengirim Intent, reducer menghasilkan State baru.
sealed interface HomeIntent {
data object Refresh : HomeIntent
data class StartWorkout(val name: String) : HomeIntent
}
data class HomeState(
val steps: Int = 0,
val workouts: List<Workout> = emptyList(),
val error: String? = null,
)
fun homeReducer(state: HomeState, intent: HomeIntent): HomeState = when (intent) {
HomeIntent.Refresh -> state.copy(steps = loadSteps())
is HomeIntent.StartWorkout -> state.copy(
workouts = state.workouts + saveWorkout(intent.name)
)
}Kelebihan MVI: aliran data dapat diprediksi dan mudah di-debug (setiap perubahan state bisa dilacak). Kekurangan: boilerplate lebih banyak. Di ekosistem Flutter, pola ini diimplementasikan Bloc; di web, Anda mengenalnya sebagai Redux.
Note
MVVM dan MVI bukan "musuh". Banyak tim memakai MVVM sebagai dasar dan elemen MVI (sealed interface intent, immutable state) sebagai penyempurna. Pilih yang sesuai kompleksitas fitur, bukan yang paling fashionable.
Repository menjadi satu-satunya pintu data untuk lapisan di atasnya. UI/ViewModel tidak perlu tahu data berasal dari API atau database:
interface WorkoutRepository {
suspend fun fetchWorkouts(userId: String): List<Workout>
suspend fun saveWorkout(workout: Workout)
}
class WorkoutRepositoryImpl(
private val api: WorkoutApi,
private val localDb: WorkoutDao,
) : WorkoutRepository {
override suspend fun fetchWorkouts(userId: String): List<Workout> =
localDb.fetchCached() ?: api.fetchWorkouts(userId).also { localDb.insert(it) }
override suspend fun saveWorkout(workout: Workout) {
localDb.insert(workout) // offline-first: simpan dulu
api.uploadWorkout(workout) // sinkronisasi
}
}Manfaatnya langsung terasa: episode 10 (networking), 11 (database), dan 23 (offline sync) semuanya hidup di balik interface repository ini — lapisan UI tidak berubah saat sumber data diganti.
Dependency Injection menyerahkan pembuatan objek ke "container" terpusat, sehingga tiap class menerima dependensinya — bukan menciptakannya sendiri. Tool populer: Hilt/Dagger (Android), Koin, dan di KMP Koin/Ktor + koin-annotations.
val appModule = module {
single<WorkoutRepository> { WorkoutRepositoryImpl(get(), get()) }
single<WorkoutApi> { RetrofitFitkuApi(createRetrofit()) }
single<WorkoutDao> { FitkuDatabase.get(context).workoutDao() }
viewModel { HomeViewModel(get()) }
}Keuntungan: kode mudah diuji (mock dependensi), mudah diganti, dan ketergantungannya transparan. Tanpa DI, class-class saling meng-instantiate sendiri dan jadilah spaghetti.
Struktur akhir fitur "home" di Fitku menjadi:
com.fitku/
├── ui/ → composable/SwiftUI views (murni tampilan)
│ ├── home/
│ │ ├── HomeScreen.kt
│ │ └── HomeViewModel.kt
├── domain/ → model & use case (tidak tahu framework)
│ ├── model/Workout.kt
│ └── repository/WorkoutRepository.kt
└── data/ → implementasi data (API, DB)
├── remote/WorkoutApi.kt
└── local/WorkoutDao.ktAturan sederhananya: UI → domain → data, ketergantungan hanya satu arah. Lapisan data boleh berubah total tanpa menyentuh UI.
Tip
Jangan terburu-buru menaruh semua di "clean architecture" 5 lapis untuk aplikasi kecil. Mulai dari tiga lapisan (UI/domain/data) dengan MVVM + Repository — lalu tambah use case dan layer tambahan saat benar-benar dibutuhkan. Arsitektur adalah alat, bukan tujuan.
WorkoutDto) dari model domain (Workout).StateFlow/StateObject dengan thread yang jelas.Pada episode 8 ini, kalian telah mengorganisasi kode dengan arsitektur profesional:
Di episode 9 selanjutnya kita masuk ke jantung interaktivitas: State Management Mobile — perbandingan Zustand/Redux di RN, Provider/Riverpod/Bloc di Flutter, dan pola state di Android/iOS native untuk aplikasi skala menengah. Sampai jumpa!