Your ViewModel starts simple. It loads data from a repository and shows it on screen. Then product requirements arrive: filter tasks by priority, sort by due date, combine tasks with user data, validate input before saving.

The ViewModel grows. It becomes 500 lines of mixed UI logic and business rules. Testing one rule means setting up the entire ViewModel.

Use cases fix this. Each use case is one business operation. One class. One job. Easy to test. Easy to reuse across ViewModels.

Prerequisites: Tutorial #1: Architecture and Tutorial #3: Repository Pattern.


What is the Domain Layer?

The domain layer sits between the UI layer and the data layer:

UI Layer (ViewModel) → Domain Layer (Use Cases) → Data Layer (Repository)

It contains:

  • Use cases — one class per business operation
  • Domain models — data classes the whole app uses
  • Repository interfaces — contracts for the data layer

The domain layer has no Android dependencies. No Context, no Room, no Retrofit. Just pure Kotlin. This makes it easy to test and easy to share (even across KMP projects).


Why Use Cases?

Without Use Cases

@HiltViewModel
class TaskViewModel @Inject constructor(
    private val taskRepository: TaskRepository,
    private val userRepository: UserRepository,
    private val settingsRepository: SettingsRepository
) : ViewModel() {

    fun loadTasks() {
        viewModelScope.launch {
            val tasks = taskRepository.getTasks()
            val user = userRepository.getCurrentUser()
            val settings = settingsRepository.getSettings()

            // Filter by user's preferred categories
            val filtered = tasks.filter { task ->
                settings.preferredCategories.contains(task.category)
            }

            // Sort based on user's sort preference
            val sorted = when (settings.sortOrder) {
                SortOrder.DUE_DATE -> filtered.sortedBy { it.dueDate }
                SortOrder.PRIORITY -> filtered.sortedByDescending { it.priority }
                SortOrder.CREATED -> filtered.sortedByDescending { it.createdAt }
            }

            _state.update { it.copy(tasks = sorted) }
        }
    }
}

This ViewModel knows about filtering, sorting, and user preferences. If another screen needs the same filtered+sorted tasks, you copy-paste this logic.

With Use Cases

@HiltViewModel
class TaskViewModel @Inject constructor(
    private val getFilteredTasks: GetFilteredTasksUseCase
) : ViewModel() {

    fun loadTasks() {
        viewModelScope.launch {
            getFilteredTasks().collect { tasks ->
                _state.update { it.copy(tasks = tasks) }
            }
        }
    }
}

The ViewModel is simple. The business logic lives in GetFilteredTasksUseCase. Any screen can reuse it.


Creating Use Cases

A use case is a class with one public method. By convention, we use operator fun invoke() so you can call the use case like a function.

Basic Use Case

class GetTasksUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    operator fun invoke(): Flow<List<Task>> {
        return repository.getTasks()
    }
}

Usage in ViewModel:

// These two lines do the same thing:
val tasks = getTasksUseCase.invoke()
val tasks = getTasksUseCase()  // invoke() is called implicitly

Use Case with Parameters

class GetTaskByIdUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    operator fun invoke(taskId: Long): Flow<Task?> {
        return repository.getTaskById(taskId)
    }
}

Use Case that Combines Repositories

This is where use cases shine. They combine data from multiple sources:

class GetFilteredTasksUseCase @Inject constructor(
    private val taskRepository: TaskRepository,
    private val settingsRepository: SettingsRepository
) {
    operator fun invoke(): Flow<List<Task>> {
        return combine(
            taskRepository.getTasks(),
            settingsRepository.getSortOrder(),
            settingsRepository.getPreferredCategories()
        ) { tasks, sortOrder, categories ->

            val filtered = if (categories.isEmpty()) {
                tasks
            } else {
                tasks.filter { it.category in categories }
            }

            when (sortOrder) {
                SortOrder.DUE_DATE -> filtered.sortedBy { it.dueDate }
                SortOrder.PRIORITY -> filtered.sortedByDescending { it.priority }
                SortOrder.CREATED -> filtered.sortedByDescending { it.createdAt }
            }
        }
    }
}

Suspend Use Case (Write Operations)

For operations that don’t return a stream, use a suspend function:

class CreateTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(title: String, description: String): Result<Task> {
        // Validate input
        if (title.isBlank()) {
            return Result.Error("Title cannot be empty")
        }
        if (title.length > 100) {
            return Result.Error("Title must be under 100 characters")
        }

        val task = Task(
            id = 0,
            title = title.trim(),
            description = description.trim(),
            isCompleted = false,
            createdAt = System.currentTimeMillis()
        )

        return try {
            val created = repository.createTask(task)
            Result.Success(created)
        } catch (e: Exception) {
            Result.Error("Failed to create task: ${e.message}")
        }
    }
}

Input Validation in Use Cases

Validation is business logic. It belongs in use cases, not in Composables or ViewModels.

class UpdateUserProfileUseCase @Inject constructor(
    private val repository: UserRepository
) {
    suspend operator fun invoke(
        name: String,
        email: String,
        bio: String
    ): Result<User> {
        // Validate
        val errors = mutableListOf<String>()

        if (name.isBlank()) errors.add("Name is required")
        if (name.length > 50) errors.add("Name must be under 50 characters")

        if (!email.matches(Regex("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"))) {
            errors.add("Invalid email address")
        }

        if (bio.length > 500) errors.add("Bio must be under 500 characters")

        if (errors.isNotEmpty()) {
            return Result.Error(errors.joinToString(", "))
        }

        // Execute
        return try {
            val user = repository.updateProfile(
                name = name.trim(),
                email = email.trim().lowercase(),
                bio = bio.trim()
            )
            Result.Success(user)
        } catch (e: Exception) {
            Result.Error("Failed to update profile: ${e.message}")
        }
    }
}

The ViewModel just calls the use case and updates the UI based on the result:

fun updateProfile(name: String, email: String, bio: String) {
    viewModelScope.launch {
        _state.update { it.copy(isLoading = true) }
        when (val result = updateUserProfile(name, email, bio)) {
            is Result.Success -> _state.update {
                it.copy(isLoading = false, user = result.data, error = null)
            }
            is Result.Error -> _state.update {
                it.copy(isLoading = false, error = result.message)
            }
            is Result.Loading -> { /* handled above */ }
        }
    }
}

Use Cases with Flow Transformations

Use cases can transform, filter, and combine flows:

Search Use Case

class SearchTasksUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    operator fun invoke(query: String): Flow<List<Task>> {
        if (query.isBlank()) {
            return repository.getTasks()
        }

        return repository.getTasks().map { tasks ->
            tasks.filter { task ->
                task.title.contains(query, ignoreCase = true) ||
                task.description.contains(query, ignoreCase = true)
            }
        }
    }
}

Dashboard Use Case (Multiple Sources)

class GetDashboardUseCase @Inject constructor(
    private val taskRepository: TaskRepository,
    private val userRepository: UserRepository
) {
    operator fun invoke(): Flow<DashboardData> {
        return combine(
            taskRepository.getTasks(),
            userRepository.getCurrentUser()
        ) { tasks, user ->
            DashboardData(
                userName = user?.name ?: "Guest",
                totalTasks = tasks.size,
                completedTasks = tasks.count { it.isCompleted },
                pendingTasks = tasks.count { !it.isCompleted },
                urgentTasks = tasks.filter {
                    !it.isCompleted && it.dueDate != null &&
                    it.dueDate < System.currentTimeMillis() + 24 * 60 * 60 * 1000
                }
            )
        }
    }
}

data class DashboardData(
    val userName: String,
    val totalTasks: Int,
    val completedTasks: Int,
    val pendingTasks: Int,
    val urgentTasks: List<Task>
)

Sync Use Case (Background Operation)

class SyncTasksUseCase @Inject constructor(
    private val taskRepository: TaskRepository
) {
    suspend operator fun invoke(): Result<Int> {
        return try {
            val syncedCount = taskRepository.syncWithServer()
            Result.Success(syncedCount)
        } catch (e: Exception) {
            Result.Error("Sync failed: ${e.message}")
        }
    }
}

Error Handling Pattern

Define a sealed Result class in the domain layer:

// domain/Result.kt
sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Error(val message: String, val cause: Throwable? = null) : Result<Nothing>
    data object Loading : Result<Nothing>
}

// Extension functions for convenience
fun <T> Result<T>.getOrNull(): T? = (this as? Result.Success)?.data

fun <T> Result<T>.getOrDefault(default: T): T =
    (this as? Result.Success)?.data ?: default

fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {
    is Result.Success -> Result.Success(transform(data))
    is Result.Error -> this
    is Result.Loading -> this
}

Every use case that can fail returns Result<T>. The ViewModel handles each case explicitly. No uncaught exceptions.


Wiring Use Cases with Hilt

Use cases with @Inject constructor are automatically available to Hilt:

class GetTasksUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    operator fun invoke(): Flow<List<Task>> {
        return repository.getTasks()
    }
}

No module needed. Hilt sees @Inject on the constructor and knows how to create it (as long as TaskRepository is already provided via a @Binds or @Provides method).

The ViewModel receives use cases through its constructor:

@HiltViewModel
class TaskViewModel @Inject constructor(
    private val getTasks: GetTasksUseCase,
    private val createTask: CreateTaskUseCase,
    private val deleteTask: DeleteTaskUseCase,
    private val searchTasks: SearchTasksUseCase
) : ViewModel()

When NOT to Use Use Cases

Use cases add a layer of indirection. For simple operations, they can be overkill.

Skip use cases when:

  • The use case just calls one repository method with no transformation
  • You have a small app with 2-3 screens
  • The operation has no business logic (just CRUD)

Use use cases when:

  • Business logic combines multiple repositories
  • The same logic is needed in multiple ViewModels
  • Validation rules are complex
  • You want to test business logic without the ViewModel

The Pragmatic Approach

Start without use cases. When your ViewModel grows beyond 200 lines or when two ViewModels need the same logic, extract a use case.

Don’t create a GetUserByIdUseCase that just calls repository.getUserById(id). That adds nothing.


Testing Use Cases

Use cases are the easiest layer to test. No Android dependencies. No UI. Just pure Kotlin.

class CreateTaskUseCaseTest {

    private lateinit var useCase: CreateTaskUseCase
    private lateinit var fakeRepository: FakeTaskRepository

    @Before
    fun setup() {
        fakeRepository = FakeTaskRepository()
        useCase = CreateTaskUseCase(fakeRepository)
    }

    @Test
    fun `returns error for empty title`() = runTest {
        val result = useCase("", "Some description")
        assertTrue(result is Result.Error)
        assertEquals("Title cannot be empty", (result as Result.Error).message)
    }

    @Test
    fun `returns error for title over 100 chars`() = runTest {
        val longTitle = "a".repeat(101)
        val result = useCase(longTitle, "Description")
        assertTrue(result is Result.Error)
    }

    @Test
    fun `trims title and description`() = runTest {
        val result = useCase("  Buy groceries  ", "  From the store  ")
        assertTrue(result is Result.Success)
        assertEquals("Buy groceries", (result as Result.Success).data.title)
    }

    @Test
    fun `creates task successfully`() = runTest {
        val result = useCase("Buy groceries", "Milk, eggs, bread")
        assertTrue(result is Result.Success)
        assertEquals(1, fakeRepository.tasks.size)
    }
}
class GetFilteredTasksUseCaseTest {

    @Test
    fun `filters by category`() = runTest {
        val taskRepo = FakeTaskRepository().apply {
            tasks.addAll(listOf(
                Task(1, "Work task", category = "work", isCompleted = false),
                Task(2, "Home task", category = "home", isCompleted = false),
                Task(3, "Work task 2", category = "work", isCompleted = false)
            ))
        }
        val settingsRepo = FakeSettingsRepository().apply {
            preferredCategories = setOf("work")
        }

        val useCase = GetFilteredTasksUseCase(taskRepo, settingsRepo)
        val result = useCase().first()

        assertEquals(2, result.size)
        assertTrue(result.all { it.category == "work" })
    }
}

Complete Example: Task Manager Domain Layer

Here is the full domain layer for a task manager app:

// domain/model/Task.kt
data class Task(
    val id: Long = 0,
    val title: String,
    val description: String = "",
    val isCompleted: Boolean = false,
    val priority: Priority = Priority.MEDIUM,
    val category: String = "",
    val dueDate: Long? = null,
    val createdAt: Long = System.currentTimeMillis()
)

enum class Priority { LOW, MEDIUM, HIGH }
enum class SortOrder { DUE_DATE, PRIORITY, CREATED }

// domain/repository/TaskRepository.kt
interface TaskRepository {
    fun getTasks(): Flow<List<Task>>
    fun getTaskById(id: Long): Flow<Task?>
    suspend fun createTask(task: Task): Task
    suspend fun updateTask(task: Task)
    suspend fun deleteTask(taskId: Long)
    suspend fun toggleCompleted(taskId: Long)
    suspend fun syncWithServer(): Int
}

// domain/usecase/GetTasksUseCase.kt
class GetTasksUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    operator fun invoke(): Flow<List<Task>> = repository.getTasks()
}

// domain/usecase/CreateTaskUseCase.kt
class CreateTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(title: String, description: String): Result<Task> {
        if (title.isBlank()) return Result.Error("Title is required")
        if (title.length > 100) return Result.Error("Title too long")

        val task = Task(title = title.trim(), description = description.trim())
        return try {
            Result.Success(repository.createTask(task))
        } catch (e: Exception) {
            Result.Error("Failed to create task")
        }
    }
}

// domain/usecase/ToggleTaskUseCase.kt
class ToggleTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(taskId: Long) {
        repository.toggleCompleted(taskId)
    }
}

// domain/usecase/DeleteTaskUseCase.kt
class DeleteTaskUseCase @Inject constructor(
    private val repository: TaskRepository
) {
    suspend operator fun invoke(taskId: Long) {
        repository.deleteTask(taskId)
    }
}

Four use cases. Each does one thing. Each is independently testable. The ViewModel stays clean.


What’s Next?

In this tutorial, you learned:

  • What the domain layer is and why it exists
  • How to create use cases with operator fun invoke()
  • Combining multiple repositories in one use case
  • Input validation in use cases
  • Error handling with sealed Result classes
  • When NOT to over-engineer with use cases
  • Testing use cases with fake repositories

Next up: Android Tutorial #5: Multi-Module Architecture — where you will split your app into separate Gradle modules with enforced boundaries between layers.



This is part 4 of the Android Development Tutorial series.