androidengineers.Book a session

Architecture Patterns

Repositories & Use Cases: Boundaries

article20 minMedium

The Problem: ViewModel Knows Too Much

When a ViewModel talks directly to Retrofit, Room, and SharedPreferences, it becomes impossible to test without running on a device. It also violates the Single Responsibility Principle — the ViewModel should manage UI state, not orchestrate data sources.

// Anti-pattern: ViewModel doing too much
class UserViewModel : ViewModel() {
    private val api = RetrofitClient.userApi    // knows Retrofit
    private val db = AppDatabase.getInstance()   // knows Room
    private val prefs = SharedPreferences...     // knows SharedPreferences

    fun loadUser(id: String) {
        viewModelScope.launch {
            val cached = db.userDao().findById(id)   // SQL knowledge
            if (cached == null) {
                val remote = api.getUser(id)          // HTTP knowledge
                db.userDao().insert(remote.toEntity()) // mapping knowledge
            }
        }
    }
}

The Repository Pattern

A Repository is a single, abstract source of truth for a domain entity. It decides whether to fetch from network, cache, or database. The caller (ViewModel or UseCase) doesn't care.

// Interface defines the contract — ViewModel only knows this
interface UserRepository {
    suspend fun getUser(id: String): Result<User>
    fun observeUser(id: String): Flow<User?>
    suspend fun refreshUser(id: String): Result<Unit>
}

// Concrete implementation knows all data sources
class UserRepositoryImpl @Inject constructor(
    private val remoteDataSource: UserRemoteDataSource,
    private val localDataSource: UserLocalDataSource,
    private val networkMonitor: NetworkMonitor
) : UserRepository {

    override suspend fun getUser(id: String): Result<User> {
        // Cache-first strategy
        val cached = localDataSource.getUser(id)
        if (cached != null && !cached.isStale()) {
            return Result.success(cached.toDomain())
        }
        return refreshUser(id).map { localDataSource.getUser(id)!!.toDomain() }
    }

    override fun observeUser(id: String): Flow<User?> =
        localDataSource.observeUser(id)   // Room Flow — always fresh from DB
            .map { entity -> entity?.toDomain() }

    override suspend fun refreshUser(id: String): Result<Unit> = runCatching {
        if (!networkMonitor.isConnected()) throw NoNetworkException()
        val remoteUser = remoteDataSource.fetchUser(id)
        localDataSource.saveUser(remoteUser.toEntity())
    }
}

Layer Mapping

Each layer uses its own data model. Mapping at the boundary keeps layers independent:

// Network model (Retrofit)
data class UserDto(
    @SerializedName("user_id") val userId: String,
    @SerializedName("full_name") val fullName: String,
    @SerializedName("email_address") val emailAddress: String
)

// Database model (Room)
@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val id: String,
    val name: String,
    val email: String,
    val cachedAt: Long
)

// Domain model (pure Kotlin — no framework dependencies)
data class User(
    val id: String,
    val name: String,
    val email: String
)

// Extension functions for mapping
fun UserDto.toEntity() = UserEntity(
    id = userId,
    name = fullName,
    email = emailAddress,
    cachedAt = System.currentTimeMillis()
)

fun UserEntity.toDomain() = User(id = id, name = name, email = email)

fun UserEntity.isStale(): Boolean =
    System.currentTimeMillis() - cachedAt > TimeUnit.MINUTES.toMillis(15)

The UseCase (Interactor) Pattern

A UseCase represents a single business operation. It composes one or more repositories and applies business rules. Each UseCase has one public method — typically invoke() — which lets you call it like a function.

// Use case wraps a single piece of business logic
class GetUserProfileUseCase @Inject constructor(
    private val userRepository: UserRepository,
    private val analyticsRepository: AnalyticsRepository
) {
    suspend operator fun invoke(userId: String): Result<UserProfile> {
        analyticsRepository.track(Event.ProfileViewed(userId))
        return userRepository.getUser(userId).map { user ->
            UserProfile(
                user = user,
                isCurrentUser = userRepository.getCurrentUserId() == userId
            )
        }
    }
}

// Another use case: different operation, same repositories
class UpdateUserProfileUseCase @Inject constructor(
    private val userRepository: UserRepository,
    private val validationService: ProfileValidationService
) {
    suspend operator fun invoke(update: ProfileUpdate): Result<Unit> {
        val validationError = validationService.validate(update)
        if (validationError != null) {
            return Result.failure(ValidationException(validationError))
        }
        return userRepository.updateUser(update)
    }
}

// ViewModel is now clean
@HiltViewModel
class UserProfileViewModel @Inject constructor(
    private val getUserProfileUseCase: GetUserProfileUseCase,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val userId = checkNotNull(savedStateHandle.get<String>("userId"))

    private val _state = MutableStateFlow<UiState<UserProfile>>(UiState.Loading)
    val state: StateFlow<UiState<UserProfile>> = _state.asStateFlow()

    init {
        viewModelScope.launch {
            _state.value = getUserProfileUseCase(userId).fold(
                onSuccess = { UiState.Success(it) },
                onFailure = { UiState.Error(it.message ?: "Unknown error") }
            )
        }
    }
}

When Use Cases Add Value vs Overengineering

Use cases earn their keep when:

  • Business logic spans multiple repositories — e.g., "Post a comment" needs CommentRepository + NotificationRepository + AnalyticsRepository
  • Complex validation or transformation before touching repositories
  • Reuse across multiple ViewModels — same logic needed in two screens
  • You write a business rule test — use cases are plain Kotlin, trivial to unit test

Skip use cases when:

// This use case adds nothing — it's a single repository call
class GetUserUseCase @Inject constructor(private val repo: UserRepository) {
    suspend operator fun invoke(id: String) = repo.getUser(id)
}
// Just call repo.getUser(id) directly from the ViewModel

The rule of thumb: if the use case body is a single repository call with no transformation, it's ceremony without value.


Data Layer Boundaries

┌─────────────────────────────────────────┐
│            UI Layer                      │
│   Fragment / Activity / Compose          │
└──────────────┬──────────────────────────┘
               │ observes StateFlow
┌──────────────▼──────────────────────────┐
│          Domain Layer (optional)         │
│   ViewModel   UseCase   Domain Models    │
└──────────────┬──────────────────────────┘
               │ calls interface
┌──────────────▼──────────────────────────┐
│           Data Layer                     │
│   Repository ← RemoteDS ← Retrofit       │
│              ← LocalDS  ← Room           │
└─────────────────────────────────────────┘

Each boundary is crossed with a mapping and an interface. The inner layer (domain) never imports the outer layer (data).


Testing the Layers in Isolation

// Test repository without network
class UserRepositoryTest {

    private val fakeRemote = FakeUserRemoteDataSource()
    private val fakeLocal = FakeUserLocalDataSource()
    private val fakeNetwork = FakeNetworkMonitor(isConnected = true)
    private val repository = UserRepositoryImpl(fakeRemote, fakeLocal, fakeNetwork)

    @Test
    fun `getUser returns cached when fresh`() = runTest {
        fakeLocal.saveUser(UserEntity(id = "1", name = "Alice", email = "a@b.com", cachedAt = System.currentTimeMillis()))
        val result = repository.getUser("1")
        assertTrue(result.isSuccess)
        assertEquals("Alice", result.getOrThrow().name)
        // Remote was NOT called
        assertEquals(0, fakeRemote.fetchCount)
    }
}

// Test use case without repository
class GetUserProfileUseCaseTest {

    private val fakeRepo = FakeUserRepository()
    private val fakeAnalytics = FakeAnalyticsRepository()
    private val useCase = GetUserProfileUseCase(fakeRepo, fakeAnalytics)

    @Test
    fun `tracks analytics when profile viewed`() = runTest {
        fakeRepo.setUser(User(id = "1", name = "Alice", email = "a@b.com"))
        useCase("1")
        assertTrue(fakeAnalytics.trackedEvents.any { it is Event.ProfileViewed })
    }
}

Key Takeaways

ConceptSummary
RepositoryAbstract data source; decides cache vs network; single source of truth
Interface boundaryViewModel/UseCase only sees interface, not implementation
Layer mappingEach layer owns its model; map at the boundary
UseCaseSingle business operation; composable; operator fun invoke()
When to skip UseCaseSingle repository call with no business logic
TestabilityRepositories and use cases are plain Kotlin — fast unit tests

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Repositories & Use Cases: Boundaries | Android System Design | Android Engineers