Skip to content
All posts
May 18, 20267 min read

Mastering Clean Architecture in Android: Use Cases, Repositories, and Data Sources

A deep dive into decoupling business logic from frameworks using the Use Case, Repository, and Data Source pattern to build scalable Android apps.

AndroidKotlinArchitectureBest Practices
Share:

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.

The Structural Problem: The "Fat ViewModel" Trap

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).

LayerResponsibilityDependencies
UI (Presentation)Displaying data & capturing user inputDomain Layer
Domain (Business)Pure business rules & Use CasesNone (Pure Kotlin)
DataFetching, caching, and mapping dataDomain Layer

The Domain Layer: The Heart of the App

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

code
android.*
imports allowed.

A Use Case represents a single, atomic action a user can take. Instead of a

code
UserRepository
with fifteen different methods, you create specific Use Cases like
code
GetUserDetailsUseCase
or
code
UpdateUserProfileUseCase
.

This granularity prevents the "God Class" syndrome. If the logic for calculating a user's loyalty points changes, you only touch the

code
CalculateLoyaltyPointsUseCase
, not the entire repository or the ViewModel.

kotlin
// 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

code
operator fun invoke
, you can call the use case as a function:
code
getUserUseCase(userId)
. This makes the code highly readable and encapsulates the logic perfectly.

The Data Layer: Repositories and Data Sources

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

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.

The Data Source

Data Sources are the low-level implementations. You should have a

code
RemoteDataSource
(handling API calls) and a
code
LocalDataSource
(handling Room/Preferences). This separation allows you to implement caching strategies (Offline-first) without leaking that complexity into your business logic.

kotlin
// 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

code
.toDomain()
and
code
.toEntity()
) to convert data between layers. Never let a
code
UserDto
(network model) leak into your ViewModel.

Integrating with the Presentation Layer

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:

code
UI
$\rightarrow$
code
ViewModel
$\rightarrow$
code
UseCase
$\rightarrow$
code
Repository
$\rightarrow$
code
DataSource
$\rightarrow$
code
API/DB
.

When 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

code
StateFlow
which the Jetpack Compose UI observes.

[!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.

Comparison: Traditional MVVM vs. Clean Architecture

To visualize the impact, let's compare how a simple "Fetch User" feature is handled in both patterns.

FeatureTraditional MVVMClean Architecture
Logic LocationViewModel or RepositoryUse Case
TestingRequires ViewModel mockingPure JUnit tests for Use Cases
DependencyViewModel $\rightarrow$ RepositoryViewModel $\rightarrow$ Use Case $\rightarrow$ Repository
Change ImpactChanging API requires changing Repo & VMChanging API only affects RemoteDataSource
ReusabilityLogic is locked in ViewModelUse Case can be reused across multiple VMs

Handling Dependencies with Hilt

Managing this many layers manually would lead to a massive, unmanageable

code
ServiceLocator
. This is where Hilt (Dependency Injection) becomes non-negotiable. Hilt allows us to bind the
code
UserRepository
interface to the
code
UserRepositoryImpl
implementation, ensuring the Domain layer remains agnostic of the Data layer.

kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindUserRepository(
        userRepositoryImpl: UserRepositoryImpl
    ): UserRepository
}

By using

code
@Binds
, we tell Hilt: "Whenever a Use Case asks for
code
UserRepository
, give it the
code
UserRepositoryImpl
." This keeps our Domain layer clean and our implementation swappable.

[!NOTE] For solo developers, Hilt significantly reduces the boilerplate of passing dependencies through constructors across five different layers.

Scaling for the Future

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.

Key Takeaways

  • Extract Business Logic: Move any logic involving validation, filtering, or calculation out of the ViewModel and into dedicated Use Case classes.
  • Strict Layering: Ensure the Domain layer has zero dependencies on Android frameworks or data libraries. Use interfaces to define data requirements and implement them in the Data layer.
  • Separate Data Sources: Split your Repository into
    code
    RemoteDataSource
    and
    code
    LocalDataSource
    to implement caching and offline-first capabilities without cluttering your business logic.
  • Map Your Models: Create separate data models for the Network (
    code
    Dto
    ), Database (
    code
    Entity
    ), and Domain (
    code
    DomainModel
    ). Use mappers to convert between them to prevent external API changes from breaking your UI.
Share:
S

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.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

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.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this