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.
A deep dive into decoupling business logic from frameworks using the Use Case, Repository, and Data Source pattern to build scalable Android apps.
On this page
Spaghetti code kills productivity. When your ViewModel is 1,000 lines long and handles API calls, database queries, and business validation, you aren't building a feature—you're building a maintenance nightmare. To scale an app, you must separate what the app does from how it gets the data.
As a solo developer managing over 20 apps, I cannot afford to spend weeks refactoring a single feature because I decided to switch from Retrofit to Ktor or Room to Realm. I need a system where the core business logic remains untouched regardless of the external tools I use. That is the promise of Clean Architecture.
Most Android developers start with a simple MVVM approach: View $\rightarrow$ ViewModel $\rightarrow$ Repository. On paper, this works. In practice, the ViewModel becomes a dumping ground for logic. It starts by calling a repository, but then it needs to filter a list, format a date, and check a user permission. Suddenly, your ViewModel is doing three different jobs.
When business logic lives in the ViewModel, it is tied to the Android Lifecycle. You cannot easily unit test it without mocking the ViewModel's internal state, and you certainly cannot reuse that logic in another part of the app.
Clean Architecture solves this by introducing a strict hierarchy of dependencies. The rule is simple: Dependencies point inwards. The inner layers (Domain) know nothing about the outer layers (Data/UI).
| Layer | Responsibility | Dependencies |
|---|---|---|
| UI (Presentation) | Displaying data & capturing user input | Domain Layer |
| Domain (Business) | Pure business rules & Use Cases | None (Pure Kotlin) |
| Data | Fetching, caching, and mapping data | Domain Layer |
The Domain layer is the most critical part of your application. It contains your Entities (plain data classes) and your Use Cases (Interactors). This layer must be written in pure Kotlin—no
android.*A Use Case represents a single, atomic action a user can take. Instead of a
UserRepositoryGetUserDetailsUseCaseUpdateUserProfileUseCaseThis granularity prevents the "God Class" syndrome. If the logic for calculating a user's loyalty points changes, you only touch the
CalculateLoyaltyPointsUseCase// Domain Layer: Pure Kotlin
data class User(val id: String, val name: String, val email: String)
interface UserRepository {
suspend fun getUserById(userId: String): User
}
class GetUserUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke(userId: String): Result<User> {
return try {
val user = userRepository.getUserById(userId)
if (user.name.isBlank()) {
Result.failure(Exception("User name cannot be empty"))
} else {
Result.success(user)
}
} catch (e: Exception) {
Result.failure(e)
}
}
}[!IMPORTANT] By using the
, you can call the use case as a function:codeoperator fun invoke. This makes the code highly readable and encapsulates the logic perfectly.codegetUserUseCase(userId)
While the Domain layer defines what needs to happen via interfaces, the Data layer implements how it happens. This is where we split the responsibility further into Repositories and Data Sources.
The Repository acts as the mediator. It doesn't know if the data comes from a REST API, a local SQLite database, or a hardcoded mock for testing. Its only job is to coordinate the Data Sources to provide the Domain layer with the requested Entity.
Data Sources are the low-level implementations. You should have a
RemoteDataSourceLocalDataSource// Data Layer: Implementation
class UserRemoteDataSource(private val api: UserApiService) {
suspend fun fetchUser(id: String): UserDto = api.getUser(id)
}
class UserLocalDataSource(private val dao: UserDao) {
suspend fun saveUser(user: UserEntity) = dao.insertUser(user)
suspend fun getUser(id: String): UserEntity? = dao.getUserById(id)
}
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUserById(userId: String): User {
// Simple caching logic: check local first, then remote
val localUser = localDataSource.getUser(userId)
return if (localUser != null) {
localUser.toDomain()
} else {
val remoteUser = remoteDataSource.fetchUser(userId)
localDataSource.saveUser(remoteUser.toEntity())
remoteUser.toDomain()
}
}
}[!TIP] Always use Mapper classes or extension functions (like
andcode.toDomain()) to convert data between layers. Never let acode.toEntity()(network model) leak into your ViewModel.codeUserDto
Now that we have a clean pipeline, the ViewModel becomes thin. Its only responsibility is to manage the UI state and trigger the Use Case. It doesn't care where the data comes from or how it's validated; it simply observes the result.
Using Kotlin StateFlow and Coroutines, the flow looks like this:
UIViewModelUseCaseRepositoryDataSourceAPI/DBWhen the user clicks a button, the ViewModel calls the Use Case. The Use Case executes the business logic and returns a result. The ViewModel then updates a
StateFlow[!WARNING] Avoid the temptation to inject the Repository directly into the ViewModel for "simplicity." While it saves a few lines of code now, it bypasses the business logic layer and leads back to the "Fat ViewModel" problem as the app grows.
To visualize the impact, let's compare how a simple "Fetch User" feature is handled in both patterns.
| Feature | Traditional MVVM | Clean Architecture |
|---|---|---|
| Logic Location | ViewModel or Repository | Use Case |
| Testing | Requires ViewModel mocking | Pure JUnit tests for Use Cases |
| Dependency | ViewModel $\rightarrow$ Repository | ViewModel $\rightarrow$ Use Case $\rightarrow$ Repository |
| Change Impact | Changing API requires changing Repo & VM | Changing API only affects RemoteDataSource |
| Reusability | Logic is locked in ViewModel | Use Case can be reused across multiple VMs |
Managing this many layers manually would lead to a massive, unmanageable
ServiceLocatorUserRepositoryUserRepositoryImpl@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
userRepositoryImpl: UserRepositoryImpl
): UserRepository
}By using
@BindsUserRepositoryUserRepositoryImpl[!NOTE] For solo developers, Hilt significantly reduces the boilerplate of passing dependencies through constructors across five different layers.
When you build 20+ apps, you realize that patterns are more important than frameworks. Today we use Jetpack Compose; tomorrow there might be something else. Today we use Room; tomorrow we might move to a NoSQL cloud solution.
Clean Architecture is an insurance policy. By isolating the business logic in Use Cases and abstracting data access through Repositories and Data Sources, you ensure that your core intellectual property—the rules that make your app valuable—is not hostage to a third-party library.
The initial overhead of creating more files (one file per Use Case) feels like "over-engineering" at first. However, the moment you need to implement a complex feature—like a multi-step onboarding flow that requires data from three different APIs and a local database—you will be grateful that your logic is segmented into small, testable, and independent units.
RemoteDataSourceLocalDataSourceDtoEntityDomainModelSudarshan 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