MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Not every architectural choice is equally important. After 22 apps, here are the decisions that compound positively — and the debates that waste your time.
On this page
Android architecture discussions are full of noise. Developers argue about repository pattern variations, use case granularity, and module boundaries while shipping apps where the real problems are unstructured ViewModels and no error state handling.
Here are the architectural choices that actually make a difference over time.
Every screen has three states: Loading, Success, Error. If you're not modeling all three explicitly, you're accumulating debt.
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Success(val items: List<Item>) : HomeUiState
data class Error(val message: String) : HomeUiState
}In your ViewModel:
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun loadItems() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
repository.getItems()
.onSuccess { items -> _uiState.value = HomeUiState.Success(items) }
.onFailure { e -> _uiState.value = HomeUiState.Error(e.message ?: "Unknown error") }
}
}In Compose, the
whenwhen (val state = uiState) {
is HomeUiState.Loading -> LoadingScreen()
is HomeUiState.Success -> ItemList(state.items)
is HomeUiState.Error -> ErrorScreen(state.message)
}Apps without explicit error states ship with invisible failure modes. Users see blank screens, infinite spinners, or silent failures. This is the single highest-ROI architectural decision you can make.
Every piece of data your app uses comes from a repository. ViewModels don't call network APIs directly. ViewModels don't query Room directly. They call the repository.
class ItemRepository @Inject constructor(
private val localDataSource: ItemLocalDataSource,
private val remoteDataSource: ItemRemoteDataSource
) {
fun getItems(): Flow<List<Item>> = localDataSource.getItems()
.onStart { syncIfStale() }
private suspend fun syncIfStale() {
if (isDataStale()) {
val items = remoteDataSource.fetchItems()
localDataSource.upsertItems(items)
}
}
}This gives you:
If your ViewModel has
@Inject constructor(private val api: ApiService)State flows down. Events flow up. No exceptions.
// ✅ Correct
@Composable
fun ItemScreen(
uiState: HomeUiState,
onRetry: () -> Unit,
onItemClick: (String) -> Unit
) { ... }
// ❌ Wrong — ViewModel reference in Composable
@Composable
fun ItemScreen(viewModel: HomeViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// calling viewModel directly in composables breaks testability
}Pass state and lambdas. Let the caller (the screen-level Composable or NavHost entry point) own the ViewModel reference.
This makes every Composable previewable in isolation and independently testable.
Use cases (interactors) belong in the domain layer. They encapsulate a single piece of business logic. But they're only worth creating when:
// Worth creating — complex, reusable
class CalculateSubscriptionStatusUseCase @Inject constructor(
private val subscriptionRepository: SubscriptionRepository,
private val clock: Clock
) {
suspend operator fun invoke(userId: String): SubscriptionStatus {
val subscription = subscriptionRepository.getSubscription(userId)
return when {
subscription == null -> SubscriptionStatus.None
subscription.expiresAt < clock.now() -> SubscriptionStatus.Expired
subscription.autoRenews -> SubscriptionStatus.Active
else -> SubscriptionStatus.ActiveCancelled
}
}
}// Not worth creating — trivial passthrough
class GetItemsUseCase @Inject constructor(
private val repository: ItemRepository
) {
fun invoke() = repository.getItems() // just call the repository directly
}The use case that just delegates to a repository is ceremony. It adds a file, adds a Hilt binding, adds an injection point — for zero architectural value. Put the call in the ViewModel directly.
[!TIP] Create a use case when you can write a meaningful test for it. If the only test is "it calls the repository," delete the use case.
Single-module vs multi-module: for solo projects under 10 engineers, the build time savings from multi-module rarely justify the maintenance overhead. Start with a well-organized single module with clear package structure:
com.example.app
├── data/
│ ├── local/
│ ├── remote/
│ └── repository/
├── domain/
│ ├── model/
│ └── usecase/
└── presentation/
├── home/
├── detail/
└── settings/Migrate to modules when build times exceed 3 minutes or team size exceeds 5 engineers.
Both work. The key principle is that each layer depends only on the layer below it. Whether you call it DataSource or DAO doesn't matter. What matters is that your ViewModel doesn't know whether data came from the network or the database.
With Hilt,
@HiltViewModelWhen in doubt about an architectural decision, ask:
"Can I test this without a device or emulator?"
If yes, the architecture is sound. The answer being "no" is a smell:
ContextSharedPreferencesTestability and good architecture are the same thing. They're not separate concerns.
Across 22 apps, the architecture that holds up:
Composable ──(events)──→ ViewModel ──(calls)──→ Repository
↑ │ │
└────────(state)─────────┘ Local SourceSource + Remote SourceEach layer:
This isn't revolutionary. It's the pattern that survives real-world maintenance.
[!NOTE] The best architecture is the one your future self can understand at 9pm on a Friday when something is broken in production. Optimize for clarity, not cleverness.
Sudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read