Skip to content
ArceApps Logo ArceApps
ES

Use Cases in Android: Clean and Reusable Business Logic

8 min read
Use Cases in Android: Clean and Reusable Business Logic

🏛️ Theory: Single Responsibility at the Class Level

In basic MVVM, the ViewModel usually accumulates too much logic.

  • Calls the repository.
  • Filters the data.
  • Combines two sources.
  • Validates business rules.

The Use Case extracts that logic into a small, focused, reusable class.

Rule: A Use Case does ONE thing. Its name should be a verb. GetUserProfile, BuyItem, LogOut.

This guide is the complete version of the short article I originally published. I’ve rewritten it after months of using it in real projects, seeing where Use Cases shine and where they become dead weight. If you want to understand how they fit with the data layer, check out Repository Pattern: The Real Data Abstraction — they’re two sides of the same coin.

🏗️ Anatomy of a Use Case

A pure Use Case in Kotlin leverages the invoke operator to look like a function.

class GetActiveUsersUseCase @Inject constructor(
    private val userRepository: UserRepository
) {
    // The invoke operator lets you call the class like a function: useCase()
    operator fun invoke(): Flow<List<User>> {
        return userRepository.getUsers()
            .map { list ->
                // Business Logic: Filter inactive
                list.filter { it.isActive && it.lastLogin > yesterday }
            }
    }
}

Three elements worth highlighting:

  1. @Inject constructor — dependency injection lets you test the Use Case with a fake repository without touching Dagger/Hilt/Koin manually.
  2. operator fun invoke() — lets you call useCase() as if it were a function. This is Kotlin leveraging its nature: the invoke operator is called when you use parentheses.
  3. Returns Flow<List<User>> — not “complete and done” List<User>. The Use Case is a reactive data source, not a one-shot query. This is key to living well with Compose.

🔄 Why Are They Useful?

1. Reusability

Imagine you need the list of active users on both the “Dashboard” screen and the “Admin” screen.

  • Without Use Case: You duplicate the .filter { ... } filter in two ViewModels. If the rule changes (“now active means logged in today”), you have to change two places.
  • With Use Case: Both ViewModels inject GetActiveUsersUseCase. Change one place, fix everything.

2. Screaming Architecture

When you open your domain/usecases package, you should be able to shout what your app does just by reading the filenames:

  • LoginUser.kt
  • GetTransferHistory.kt
  • MakeTransfer.kt

You don’t need to read code to understand the “features”. This is brutal for onboarding new devs to the team: instead of “read 50 files and deduce what the app does”, the usecases/ package is the user manual.

3. Simplified Testing

Testing a ViewModel is easy, but testing a Use Case is trivial. No LiveData, no ViewModelScope, just pure logic.

@Test
fun `should return only active users`() = runTest {
    // Arrange
    val users = listOf(activeUser, inactiveUser)
    every { repo.getUsers() } returns flowOf(users)
    
    // Act
    val result = useCase().first()

    // Assert
    assertEquals(1, result.size)
    assertEquals(activeUser, result[0])
}

The test is linear, no coroutine gymnastics, no LiveData observers. Three lines, one responsibility. This is what lets a team maintain tests with discipline: tests that are a pleasure to write.

🧩 Variants I’ve Found Useful

Variant 1: Use Case with Parameters

The Use Cases in the example are “parameter-less” (data lives in the repo). But many real cases do need them:

class TransferMoneyUseCase @Inject constructor(
    private val accountRepo: AccountRepository,
    private val transactionRepo: TransactionRepository
) {
    sealed class Result {
        data class Success(val transactionId: String) : Result()
        data class InsufficientFunds(val missing: BigDecimal) : Result()
        data class DailyLimitExceeded(val limit: BigDecimal) : Result()
    }

    suspend operator fun invoke(
        fromAccountId: String,
        toAccountId: String,
        amount: BigDecimal
    ): Result {
        // Validation
        val from = accountRepo.getAccount(fromAccountId)
            ?: return Result.InsufficientFunds(BigDecimal.ZERO)

        if (from.balance < amount) {
            return Result.InsufficientFunds(amount - from.balance)
        }

        // Daily limit (business rule)
        val todayTotal = transactionRepo.sumToday(fromAccountId)
        if (todayTotal + amount > DAILY_LIMIT) {
            return Result.DailyLimitExceeded(DAILY_LIMIT - todayTotal)
        }

        // Execution
        val txId = transactionRepo.executeTransfer(
            from = fromAccountId,
            to = toAccountId,
            amount = amount
        )
        return Result.Success(txId)
    }
}

sealed class Result for returns is brutal: the caller has to handle each case in when, and the compiler warns them if they forget one. No exceptions, no callbacks, no ambiguity.

Variant 2: Use Case Combining Multiple Repositories

class GetDashboardDataUseCase @Inject constructor(
    private val userRepo: UserRepository,
    private val statsRepo: StatsRepository,
    private val notificationsRepo: NotificationsRepository
) {
    operator fun invoke(): Flow<DashboardData> = combine(
        userRepo.observeCurrentUser(),
        statsRepo.observeDailyStats(),
        notificationsRepo.observeUnread()
    ) { user, stats, notifications ->
        DashboardData(user, stats, notifications)
    }
}

combine waits for all three sources to have emitted at least once, then emits a new DashboardData every time any of them changes. Perfect for screens that need data from N repositories.

Variant 3: Use Case with Explicit Input/Output

For large teams, defining input and output as data classes makes contracts obvious:

class SearchProductsUseCase @Inject constructor(
    private val productRepo: ProductRepository
) {
    data class Input(
        val query: String,
        val minPrice: BigDecimal? = null,
        val maxPrice: BigDecimal? = null,
        val category: String? = null,
        val sortBy: SortBy = SortBy.RELEVANCE
    )

    sealed class Output {
        data class Success(val products: List<Product>) : Output()
        data class Empty(val reason: String) : Output()
        data class Error(val exception: Throwable) : Output()
    }

    operator fun invoke(input: Input): Flow<Output> { ... }
}

More boilerplate, yes. But when the team grows, the code becomes self-documenting. The contract is in the method signature. Juniors don’t have to guess what parameters each Use Case accepts.

⚠️ Should I Create Use Cases for Everything?

It’s the million-dollar question. What about a Use Case that just calls the repository?

class GetUser(val repo: Repo) {
    operator fun invoke(id: String) = repo.getUser(id)
}

This is called a “Pass-through” (Proxy).

  • Purists: YES. Maintain architectural consistency. Protect against future changes.
  • Pragmatists: NO. If it’s just a proxy, call the Repo from the ViewModel. Create the Use Case only when you add real logic.

Recommendation: On large teams, be a purist (consistency > brevity). On small projects, be pragmatic. Personally, I use a rule of three: if I’ve called the repo from three different places with the same logic, I extract it to a Use Case. Before that, I leave it inline.

🚫 Common Mistakes I’ve Made (And Paid For)

1. Use Case that knows about UI. If your Use Case imports Context or View, you’re doing it wrong. Use Cases are UI-agnostic. If you need to show an error message, return it as Result.Error(message: String) and let the UI paint it.

2. Use Case that does IO directly. If your Use Case does withContext(Dispatchers.IO), it’s probably badly injected. Repositories should already be Main-Safe. Putting withContext in the Use Case duplicates the responsibility.

3. Use Case with undeclared side effects. A Use Case that “casually” does logging, analytics, or writes to DataStore is a time bomb. These side effects should be explicit in the method signature or be a separate event.

4. 300-line Use Case. If your Use Case needs scrolling, it’s several Use Cases dressed as one. Divide by business operation, not by data entity.

🧪 Advanced Testing

Beyond the basic test, there are three patterns I use with Use Cases:

Tests with Turbine for Flows

@Test
fun `emits Loading then Success then Empty based on repo state`() = runTest {
    every { repo.getUsers() } returns flowOf(
        emptyList(),
        listOf(activeUser),
        listOf(activeUser, premiumUser)
    )

    useCase().test {
        assertEquals(UiState.Loading, awaitItem())
        assertEquals(UiState.Empty("No active users"), awaitItem())
        assertEquals(UiState.Success(listOf(activeUser, premiumUser)), awaitItem())
        awaitComplete()
    }
}

Parameterized Tests with JUnit 5

@ParameterizedTest
@MethodSource("invalidEmails")
fun `rejects invalid emails`(email: String) {
    assertFalse(useCase(email))
}

companion object {
    @JvmStatic
    fun invalidEmails() = listOf(
        "", "no-at-sign.com", "two@@signs.com", "trailing@"
    )
}

Integration Tests with Real Repository

For Use Cases combining multiple sources, a unit test with mocks isn’t enough. You need an integration test that verifies the repository composition actually works:

@Test
fun `dashboard use case combines three real sources`() = runTest {
    val fakeUserRepo = FakeUserRepository(initialUser)
    val fakeStatsRepo = FakeStatsRepository(initialStats)
    val fakeNotificationsRepo = FakeNotificationsRepository(initialNotifications)

    val useCase = GetDashboardDataUseCase(
        fakeUserRepo, fakeStatsRepo, fakeNotificationsRepo
    )

    useCase().test {
        val initial = awaitItem()
        assertEquals(initialUser, initial.user)
        assertEquals(initialStats, initial.stats)

        // Simulate change in one source
        fakeStatsRepo.update(newStats)
        val updated = awaitItem()
        assertEquals(newStats, updated.stats)

        cancelAndIgnoreRemainingEvents()
    }
}

🎯 Conclusion

Use Cases are the definitive tool to encapsulate the “intelligence” of your application. The ViewModel becomes a simple UI orchestrator, and the Repository a simple data store. The real magic happens in the domain.

If I had to leave you with one piece of advice: don’t create Use Cases just to create them. Create one when you have a concrete reason (real reuse, complexity worth a class, isolated test). Pragmatism here is more important than purity.

Bibliography and References

Share this post:

You might also be interested in

Clean Architecture: The Ultimate Guide for Modern Android
Architecture October 15, 2025

Clean Architecture: The Ultimate Guide for Modern Android

Demystifying Clean Architecture: A deep dive into layers, dependencies, and data flow to build indestructible Android apps.

Read more
MVVM Architecture in Android: The Comprehensive Guide (2025)
Android October 1, 2025

MVVM Architecture in Android: The Comprehensive Guide (2025)

Master the Model-View-ViewModel pattern from basic concepts to advanced implementations with practical examples of a Minesweeper game for Android.

Read more
OpenSpec for Mobile Development: Spec-Driv...
SDD May 17, 2026

OpenSpec for Mobile Development: Spec-Driv...

How to apply OpenSpec in Android and Kotlin projects to keep AI agents aligned with architecture, with practical examples of change proposals, task validation, and living files.

Read more