Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 094 / 100

Use cases in Android: a practical Android & Kotlin guide

Reusable business logic.

Expona Admin7 min read

Use cases in Android: a practical Android & Kotlin guide

By day 94 in your modern Android journey, you've likely built repositories that fetch data and ViewModels that manage state. But there's a missing layer that separates well-structured apps from fragile ones: the use case. Use cases encapsulate business logic in a way that makes your codebase testable, reusable, and maintainable across features. This guide walks you through implementing use cases in Android with Kotlin, showing you exactly when they matter and when they're optional.

Why use cases matter in Android architecture

A use case represents a single, well-defined action your app can perform. Think of it as the bridge between your UI layer (ViewModels, Composables) and your data layer (repositories, APIs, databases). Without use cases, ViewModels quickly become dumping grounds for business logic: validation rules, data transformations, API sequencing, and state management all tangled together.

Use cases solve this by extracting that logic into isolated, testable classes. They also make logic reusable—multiple features might need to "fetch user profile and validate subscription status," and that's worth capturing once in a use case rather than duplicating across ViewModels.

One class per action: the single responsibility principle

The foundation of a good use case architecture is simple: one use case class per meaningful action. Not one per screen, not one per feature. One per action the business domain recognizes.

In an e-commerce app, these might be actions like:

  • GetUserCartUseCase
  • ApplyPromoCodeUseCase
  • CheckoutUseCase
  • TrackOrderUseCase

Each use case owns its inputs (parameters), business logic, and outputs. Here's a concrete example:

kotlin
class ApplyPromoCodeUseCase(
    private val promoRepository: PromoRepository,
    private val cartRepository: CartRepository,
    private val analyticsService: AnalyticsService
) {
    suspend operator fun invoke(promoCode: String, cartId: String): Result<PromoApplied> = runCatching {
        val promo = promoRepository.validatePromoCode(promoCode)
        
        if (!promo.isActive) {
            throw PromoInactiveException()
        }
        
        if (promo.minPurchaseAmount > cartRepository.getCartTotal(cartId)) {
            throw PromoMinimumNotMetException(promo.minPurchaseAmount)
        }
        
        analyticsService.logPromoAttempt(promoCode, success = true)
        
        cartRepository.applyPromo(cartId, promo.id)
        PromoApplied(promoCode, promo.discountAmount)
    }
}

data class PromoApplied(val code: String, val discount: Double)

Notice what's happening here:

  • The use case depends on repositories and services, not on UI framework classes.
  • It's a single, testable function that orchestrates multiple data sources.
  • It owns validation logic, sequencing, and error handling for this specific action.
  • It returns a domain model (PromoApplied), not a UI model.

This class is now reusable across your checkout screen, a promo dialog, or even a backend-triggered notification handler. You test it in isolation with mock repositories, no Android framework needed.

Operator fun invoke for clean calls

The operator fun invoke pattern is what makes use cases feel natural to call. Instead of:

kotlin
applyPromoCodeUseCase.execute(promoCode, cartId)

You write:

kotlin
applyPromoCodeUseCase(promoCode, cartId)

This is syntactic sugar, but it matters. The use case becomes callable like a function, making the code read clearly in your ViewModel or Composable:

kotlin
class CheckoutViewModel(
    private val applyPromoUseCase: ApplyPromoCodeUseCase,
    private val getCartUseCase: GetUserCartUseCase
) : ViewModel() {
    
    fun applyPromo(code: String) {
        viewModelScope.launch {
            val result = applyPromoUseCase(code, currentCartId)
            result.onSuccess { promo ->
                _uiState.value = _uiState.value.copy(
                    appliedPromo = promo,
                    totalDiscount = promo.discount
                )
            }.onFailure { error ->
                _uiState.value = _uiState.value.copy(
                    error = when (error) {
                        is PromoInactiveException -> "This promo code is no longer active"
                        is PromoMinimumNotMetException -> "Minimum purchase of ${error.amount} required"
                        else -> "Failed to apply promo"
                    }
                )
            }
        }
    }
}

The invoke syntax makes it clear that you're invoking a business action, not calling some arbitrary method. It's a signal to readers: "This is a use case, it does something meaningful in the domain."

For suspend functions (which most use cases are, since they do I/O), the pattern works seamlessly with coroutines. The ViewModel calls the use case in a coroutine scope, handles the result, and updates state. Clean separation of concerns.

Combine repositories for complex orchestration

Real business logic often involves coordinating multiple data sources. A use case should encapsulate that orchestration so your ViewModel doesn't have to.

Consider a travel booking scenario: checking availability, calculating price with dynamic pricing rules, applying loyalty discounts, checking payment eligibility, and logging analytics. A ViewModel that did all this directly would be hundreds of lines. A use case encapsulates it:

kotlin
class BookFlightUseCase(
    private val flightRepository: FlightRepository,
    private val pricingRepository: PricingRepository,
    private val loyaltyRepository: LoyaltyRepository,
    private val paymentRepository: PaymentRepository,
    private val bookingRepository: BookingRepository,
    private val analyticsService: AnalyticsService
) {
    suspend operator fun invoke(
        flightId: String,
        passengerId: String,
        paymentMethodId: String
    ): Result<BookingConfirmed> = runCatching {
        // Check availability
        val flight = flightRepository.getFlight(flightId)
        if (!flight.hasAvailableSeats()) {
            throw NoSeatsAvailableException()
        }
        
        // Calculate price with dynamic pricing
        val basePrice = pricingRepository.calculatePrice(flight, passengerId)
        
        // Apply loyalty discount
        val loyaltyMember = loyaltyRepository.getLoyaltyStatus(passengerId)
        val discount = if (loyaltyMember.isActive) {
            basePrice * loyaltyMember.discountRate
        } else 0.0
        
        val finalPrice = basePrice - discount
        
        // Verify payment eligibility
        val paymentMethod = paymentRepository.getPaymentMethod(paymentMethodId)
        if (!paymentMethod.canChargeAmount(finalPrice)) {
            throw PaymentNotEligibleException(finalPrice)
        }
        
        // Perform booking
        val booking = bookingRepository.createBooking(
            flightId = flightId,
            passengerId = passengerId,
            price = finalPrice,
            paymentMethodId = paymentMethodId
        )
        
        analyticsService.logBooking(flightId, finalPrice, loyaltyMember.tier)
        
        BookingConfirmed(booking.id, finalPrice, discount)
    }
}

data class BookingConfirmed(val bookingId: String, val finalPrice: Double, val discount: Double)

This use case is testable with mock repositories, reusable (mobile app, web app, backend service can all use it), and keeps your ViewModel lean. The ViewModel simply calls it and reacts to the result.

The orchestration logic—the "what depends on what" and "what happens after that"—lives in one place where it's obvious and maintainable. If business rules change (loyalty discount applies before or after taxes, for example), you update one place.

Optional for simple apps: when you don't need use cases

Not every app needs explicit use cases. If your app is simple—a few screens, mostly CRUD operations, minimal business logic—your repository layer might be sufficient.

Use cases are worth the structure when you have:

  • Complex business logic (calculations, validations, conditional flows)
  • Multiple repositories that need to be orchestrated
  • Shared business actions across multiple features
  • A team that benefits from clear, testable layers

If you have a note-taking app with "create note," "delete note," "search notes," and little else, calling repositories directly from your ViewModel is fine. You're not gaining much from an extra layer.

But if you have a fintech, e-commerce, or SaaS product with domain-specific business rules, use cases pay for themselves in clarity, testability, and reusability within weeks.

Start with repositories. Add use cases when you notice yourself duplicating business logic across ViewModels or when a single ViewModel's logic becomes hard to test and understand. There's no shame in starting simple.

Common mistakes

  • Use cases that are too thin. A use case that just wraps a single repository call adds no value. Combine that logic with its orchestration purpose in mind.
  • Mixing UI concerns into use cases. Use cases should return domain models, not UI states. Let the ViewModel handle presentation logic.
  • Use cases that depend on Android framework classes. If your use case imports android.content, androidx.lifecycle, or any UI library, you've put UI logic in the domain layer. It's now hard to test and reuse.
  • Creating one use case per screen. That's not use cases, that's just moving ViewModel logic somewhere else. Use cases are per business action, not per UI component.
  • Over-engineering error handling. Not every exception needs a custom class. Use sealed classes for domain-relevant errors, standard exceptions for library/system errors.
  • Forgetting to test use cases in isolation. The whole point is testability. If you're not writing unit tests for your use cases, you're not capturing the benefit.

Key takeaways

  • Use cases encapsulate business logic into reusable, testable classes with a clear single responsibility—one use case per business action.
  • The operator fun invoke pattern makes use cases callable as functions, improving readability and signaling their purpose in your codebase.
  • Use cases orchestrate multiple repositories, keeping your ViewModel lean and your business logic cohesive and maintainable.
  • Use cases are optional for simple CRUD apps but essential for any product with complex business rules, shared logic, or a need for clear testability.
  • Use cases must remain free of UI framework dependencies and return domain models, not UI models, to stay reusable and testable.
  • Start simple with repositories; add use cases when you see business logic being duplicated or when your ViewModel becomes difficult to test.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles