Between the UI layer and the data layer there is an optional layer: the domain layer. It holds business logic that is complex, or reused by several screens. Its building block is the use case (some teams call it an interactor): a small class that does one thing such as "get the students who owe fees" or "format a receipt".
This lesson covers Domain Layer and The Domain layer - MAD Skills from the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture), based on the official page https://developer.android.com/topic/architecture/domain-layer.
By the end you will be able to:
- Decide whether you need a domain layer, and say why it is optional.
- Write use cases with
operator fun invokeand the standard naming convention. - Combine several repositories in a use case.
- Make use cases main-safe and testable.
- Reuse logic across ViewModels without copying it.
- Recognise over-engineering, such as pass-through use cases.
Big idea: a use case is a named piece of business logic. It lives in the domain layer when putting it in a ViewModel would make that ViewModel too big, or would force you to copy it into another ViewModel.
Honesty note: many successful apps have no domain layer. The official guidance calls it optional. Treat everything in this lesson as a tool for when you feel the pain it solves.
- Layers and the dependency rule (lesson 60).
- UI layer state production (lesson 61).
- Repositories and fakes (lesson 62).
- Kotlin
operatorfunctions andinvoke(lessons 11 and 12). - Coroutines and Flow (lesson 33).
UI layer ViewModel
│
▼
Domain layer Use cases ← optional
│
▼
Data layer Repositories
The official page lists the reasons to add use cases:
| Reason | Example |
|---|---|
| Avoid code duplication | Two ViewModels both need "students who owe fees" |
| Improve readability of ViewModels | Move a 40-line calculation out of the ViewModel |
| Improve testability | Test business rules with plain JUnit |
| Avoid large classes | Split logic that grew too big |
| Combine data from several repositories | Students + payments → balances |
If none of these apply, skip the layer. A useful rule: add a use case the first time you copy logic into a second ViewModel, or the first time a ViewModel becomes hard to read.
What a use case may and may not do
- A use case depends on repositories (data layer) and other use cases.
- It never depends on the UI layer, on
Context, or on Android framework classes. That keeps it plain Kotlin, easy to test, and reusable (for example in a Kotlin Multiplatform module, lessons 66 to 70). - It has no state of its own. State belongs in the UI layer (screen state) or in the data layer (data). A use case may transform a flow, but it does not hold data.
- It does one thing.
Naming convention
The recommended pattern is verb in present tense + noun/what (optional) + UseCase:
| Good | Poor |
|---|---|
FormatDateUseCase |
DateUseCase |
GetOwingStudentsUseCase |
OwingStudentsManager |
RecordPaymentUseCase |
PaymentHelper |
Back to FeeTracker. Business rule: a student owes fees when their term fee minus total payments is greater than zero. Two screens need it: the Students screen (show a badge) and the Dashboard (count defaulters).
Without a domain layer, both ViewModels would contain this arithmetic. With one:
// domain/model/StudentBalance.kt
data class StudentBalance(
val student: Student,
val paid: Long,
val balance: Long
) {
val isOwing: Boolean get() = balance > 0
}
// domain/GetStudentBalancesUseCase.kt
class GetStudentBalancesUseCase(
private val studentsRepository: StudentsRepository,
private val paymentsRepository: PaymentsRepository,
private val termFeeNaira: Long = 450_000,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
operator fun invoke(): Flow<List<StudentBalance>> =
combine(
studentsRepository.observeStudents(),
paymentsRepository.observePayments()
) { students, payments ->
val paidByStudent = payments.groupBy { it.studentName }
.mapValues { (_, list) -> list.sumOf { it.amountNaira } }
students.map { student ->
val paid = paidByStudent[student.name] ?: 0L
StudentBalance(student, paid, balance = termFeeNaira - paid)
}
}.flowOn(defaultDispatcher)
}
What makes this a good use case:
- One responsibility: produce balances.
- Two repositories combined, a job that would otherwise bloat a ViewModel.
operator fun invokelets callers writegetStudentBalances()as if the class were a function.- Injected dispatcher: the grouping work is CPU-bound, so it runs on
Default, and tests can replace it. - No Android imports.
Using it in a ViewModel
class StudentsViewModel(getBalances: GetStudentBalancesUseCase) : ViewModel() {
val uiState: StateFlow<StudentsUiState> = getBalances()
.map<List<StudentBalance>, StudentsUiState> { StudentsUiState.Success(it) }
.catch { emit(StudentsUiState.Error("Could not load balances")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StudentsUiState.Loading)
}
class DashboardViewModel(getBalances: GetStudentBalancesUseCase) : ViewModel() {
val owingCount: StateFlow<Int> = getBalances()
.map { balances -> balances.count { it.isOwing } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
}
Both ViewModels are now only a few lines, and the rule exists once.
For one-shot actions, a use case is a suspend operator fun invoke:
class RecordPaymentUseCase(
private val paymentsRepository: PaymentsRepository,
private val studentsRepository: StudentsRepository
) {
suspend operator fun invoke(studentName: String, amountNaira: Long): Result<Payment> {
if (amountNaira <= 0) {
return Result.failure(IllegalArgumentException("Amount must be positive"))
}
if (!studentsRepository.exists(studentName)) {
return Result.failure(IllegalArgumentException("Unknown student"))
}
return paymentsRepository.record(studentName, amountNaira)
}
}
This is validation plus coordination: classic domain logic. The use case:
- Checks rules that do not depend on the UI.
- Calls repositories in the right order.
- Returns a
Resultso the ViewModel can decide how to show the outcome.
Main-safety. A use case should be safe to call from the main thread. Because repositories are already main-safe, this one needs no
withContext. AddwithContext(defaultDispatcher)only inside use cases that do heavy CPU work themselves.
Calling it from the ViewModel
class PayViewModel(private val recordPayment: RecordPaymentUseCase) : ViewModel() {
private val _uiState = MutableStateFlow(PayUiState())
val uiState: StateFlow<PayUiState> = _uiState.asStateFlow()
fun pay(studentName: String, amountText: String) {
val amount = amountText.toLongOrNull()
if (amount == null) {
_uiState.update { it.copy(userMessage = "Enter a number") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(saving = true) }
recordPayment(studentName, amount)
.onSuccess { _uiState.update { s -> s.copy(saving = false, userMessage = "Payment saved") } }
.onFailure { e -> _uiState.update { s -> s.copy(saving = false, userMessage = e.message) } }
}
}
}
The text-to-number conversion is UI input handling, so it stays in the ViewModel. The "amount must be positive" rule is business logic, so it lives in the use case. Drawing that line clearly is the skill of this lesson.
Use cases can build on each other, which keeps each one small:
class GetOwingStudentsUseCase(
private val getBalances: GetStudentBalancesUseCase
) {
operator fun invoke(): Flow<List<StudentBalance>> =
getBalances().map { list -> list.filter { it.isOwing }.sortedByDescending { it.balance } }
}
class FormatMoneyUseCase(private val locale: Locale = Locale("en", "NG")) {
operator fun invoke(naira: Long): String =
NumberFormat.getCurrencyInstance(locale).format(naira)
}
Note the second one: formatting is a small, pure, reusable rule. The official page also uses a date-formatting use case as an example. If it needs a Context (for resource strings or the device locale from Android), pass in what you need as a plain parameter or an interface instead of the framework class.
Dependency direction check
StudentsViewModel ─► GetStudentBalancesUseCase ─► StudentsRepository
└► PaymentsRepository
Arrows go down only. A use case must never call a ViewModel.
A use case may use domain models that differ from data models. StudentBalance above is a domain model created by combining two data models. You may also create domain models that hide data-layer details, for example when the data layer's model has database annotations:
// data layer
@Entity data class StudentEntity(@PrimaryKey val id: Int, val name: String, val formCode: Int)
// domain layer
data class Student(val id: Int, val name: String, val form: Form)
enum class Form { SS1, SS2, SS3 }
The mapper (StudentEntity.toDomain()) can live in the data layer or in the domain layer. The official guidance is flexible: choose the arrangement your team finds clearest, and be consistent. Do not create a separate domain copy of every data class if the two are identical. Duplicating classes adds work without benefit.
Use cases are the easiest code to test, because they are plain Kotlin with injected dependencies.
class GetStudentBalancesUseCaseTest {
private val ada = Student(1, "Ada", "SS2", "")
private val tunde = Student(2, "Tunde", "SS1", "")
@Test
fun balance_is_term_fee_minus_payments() = runTest {
val students = FakeStudentsRepository(listOf(ada, tunde))
val payments = FakePaymentsRepository(
listOf(Payment(1, "Ada", 100_000, Instant.EPOCH), Payment(2, "Ada", 50_000, Instant.EPOCH))
)
val useCase = GetStudentBalancesUseCase(
students, payments, termFeeNaira = 450_000,
defaultDispatcher = StandardTestDispatcher(testScheduler)
)
val result = useCase().first()
assertEquals(300_000, result.first { it.student.name == "Ada" }.balance)
assertEquals(450_000, result.first { it.student.name == "Tunde" }.balance)
}
@Test
fun student_with_zero_balance_is_not_owing() = runTest {
val students = FakeStudentsRepository(listOf(ada))
val payments = FakePaymentsRepository(listOf(Payment(1, "Ada", 450_000, Instant.EPOCH)))
val result = GetStudentBalancesUseCase(
students, payments, 450_000, StandardTestDispatcher(testScheduler)
)().first()
assertFalse(result.single().isOwing)
}
}
class RecordPaymentUseCaseTest {
@Test
fun rejects_non_positive_amounts() = runTest {
val useCase = RecordPaymentUseCase(FakePaymentsRepository(), FakeStudentsRepository())
val result = useCase("Ada", 0)
assertTrue(result.isFailure)
}
}
No Android runtime, no emulator, just JUnit and fakes (lesson 62). Tests run in milliseconds.
The pass-through anti-pattern
// Adds a file and a layer, but does nothing
class GetStudentsUseCase(private val repository: StudentsRepository) {
operator fun invoke() = repository.observeStudents()
}
If every use case just forwards one repository call, you have bought indirection with no benefit. The ViewModel could call the repository directly. Keep use cases for real logic: combining, transforming, validating or reusing.
Quick decision guide
| Situation | Decision |
|---|---|
| Logic used by one ViewModel and is short | Keep it in the ViewModel (or a private function) |
| Logic used by two or more ViewModels | Use case |
| Logic combines data from several repositories | Use case |
| Logic is a simple read from one repository | Call the repository directly |
| Logic is UI-only (formatting for display, scroll state) | Composable or plain state holder |
| Logic is data-source specific (caching, merging network and disk) | Repository |
Following the official guidance, add the domain layer gradually. Start without it, and extract a use case when a ViewModel grows or when logic is needed in a second place.
You will meet this situation in real projects, so practise the safe way to do it. Suppose StudentsViewModel has grown to include this block:
// inside StudentsViewModel (before)
val overdue: StateFlow<List<String>> = combine(students, payments) { s, p ->
val paid = p.groupBy { it.studentName }.mapValues { (_, l) -> l.sumOf { it.amountNaira } }
s.filter { (paid[it.name] ?: 0L) < 450_000 }.map { it.name }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
Follow these steps, running your tests after each one:
- Write a test first that describes the current behaviour (a student who paid less than the fee appears in the list). This is your safety net.
- Copy the logic into a new class with
operator fun invoke, taking the two flows' sources as constructor parameters. Do not delete the original yet. - Name the magic number. The literal
450_000is a business rule. Give it a name or a constructor parameter so it appears in one place only. - Switch the ViewModel to call the use case, then delete the old block. Tests still pass, so behaviour is preserved.
- Move the test to the use case's test class and make the ViewModel test shorter, because the ViewModel now only needs to check that it exposes whatever the use case emits.
- Look for the second caller. If the Dashboard can now reuse it in one line, the extraction has paid for itself. If nothing else needs it and the ViewModel is still readable, ask honestly whether the extraction was worthwhile.
This "test, extract, switch, delete" routine is a refactoring: behaviour stays the same while structure improves. It is much safer than rewriting several layers at once.
The domain layer and the rest of your toolbox
A final comment about scale. In a small project with one module, a domain layer is just a package named domain. In a larger project it may become its own Gradle module (lesson 65), which makes the dependency rule enforceable by the build: if the domain module does not depend on the Android UI module, a developer cannot accidentally import a composable there. Because use cases are plain Kotlin, they are also the most natural code to share with other platforms when you adopt Kotlin Multiplatform (lessons 66 to 70). Keeping Android classes out of the domain layer is therefore not only tidy; it keeps your options open.
Exercise 1: Where does it belong?
Place each piece of logic in ViewModel, use case, repository or composable: (a) converting the text "5000" to a number; (b) rejecting a negative payment; (c) merging payments from Room with newly downloaded ones; (d) showing a red badge for owing students; (e) calculating "days overdue" used by two screens.
(a) ViewModel (UI input handling). (b) Use case (business rule). (c) Repository (resolving sources). (d) Composable (display logic). (e) Use case, because two screens share it.
Exercise 2: Fix the use case
Why is this a poor use case, and how do you improve it?
class StudentUseCase(private val context: Context, private val repo: StudentsRepository) {
var cache: List<Student> = emptyList()
fun get(): List<Student> { cache = repo.blockingGetAll(); return cache }
fun title() = context.getString(R.string.students)
}
It has several problems: a vague name, three responsibilities (fetching, caching, UI text), an Android Context dependency, mutable state (cache), and a blocking call that is not main-safe. Split it: a repository owns caching; the UI owns stringResource; a focused use case such as GetStudentsUseCase (only if it adds logic) exposes a Flow<List<Student>> and has no framework imports.
Exercise 3: Write a use case
Write GetTopPayersUseCase(count: Int) returning the students with the largest total payments, highest first, from PaymentsRepository.observePayments().
class GetTopPayersUseCase(
private val paymentsRepository: PaymentsRepository,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
operator fun invoke(count: Int): Flow<List<Pair<String, Long>>> =
paymentsRepository.observePayments()
.map { payments ->
payments.groupBy { it.studentName }
.mapValues { (_, list) -> list.sumOf { it.amountNaira } }
.entries
.sortedByDescending { it.value }
.take(count)
.map { it.key to it.value }
}
.flowOn(defaultDispatcher)
}
Mistake #1: A use case for everything. Fix: add one only for real logic or reuse.
Mistake #2: Pass-through use cases. Fix: call the repository directly.
Mistake #3: Android classes in the domain layer. Fix: keep it plain Kotlin.
Mistake #4: Use cases that hold state. Fix: state lives in the UI or data layer.
Mistake #5: Domain code calling the UI. Fix: respect the dependency direction.
Mistake #6: Doing heavy work without a dispatcher. Fix: inject Default and use flowOn or withContext.
Mistake #7: Vague names. Fix: verb + noun + UseCase.
Mistake #8: Copying every data class into a domain class. Fix: separate models only when they differ.
Mistake #9: Business rules in composables. Fix: move them to a use case and test them.
Mistake #10: Mocking repositories in use-case tests. Fix: use fakes.
Question 1. Is the domain layer required?
No. It is optional and should be added when logic is complex, reused, or combines several repositories.
Question 2. What does operator fun invoke give you?
The ability to call a use case instance like a function: getBalances().
Question 3. Name the recommended naming pattern for use cases.
Verb in present tense, optionally a noun, then UseCase, for example GetOwingStudentsUseCase.
Question 4. Which layers may a use case depend on?
The data layer (repositories) and other use cases; never the UI layer.
Question 5. What is a pass-through use case and why avoid it?
A class that only forwards a single repository call. It adds indirection without logic, reuse or testability benefits.
Go deeper: official resources
Project goal: Add a small, well-tested domain layer to FeeTracker.
Requirements:
- Create
domain/model/StudentBalance. - Implement
GetStudentBalancesUseCasecombining two repositories (with fakes if you lack the real ones). - Implement
GetOwingStudentsUseCasethat builds on it. - Implement
RecordPaymentUseCasewith validation (positive amount, student exists). - Implement
FormatMoneyUseCasefor Naira formatting. - Use two use cases from two different ViewModels (Students and Dashboard) to prove reuse.
- Write tests for each use case, including edge cases: no payments, overpayment (negative balance), unknown student, zero amount.
- In a short note, list one use case you chose not to create and why.
Stretch goal: define "overpaid" students (negative balance) and surface them in a separate list without touching either ViewModel's logic.
Overpayment handling in the model and a test that locks it in:
data class StudentBalance(val student: Student, val paid: Long, val balance: Long) {
val isOwing: Boolean get() = balance > 0
val isOverpaid: Boolean get() = balance < 0
}
class GetOverpaidStudentsUseCase(private val getBalances: GetStudentBalancesUseCase) {
operator fun invoke(): Flow<List<StudentBalance>> =
getBalances().map { list -> list.filter { it.isOverpaid } }
}
class OverpaidTest {
@Test
fun overpayment_is_reported() = runTest {
val students = FakeStudentsRepository(listOf(Student(1, "Ada", "SS2", "")))
val payments = FakePaymentsRepository(listOf(Payment(1, "Ada", 500_000, Instant.EPOCH)))
val balances = GetStudentBalancesUseCase(students, payments, 450_000, StandardTestDispatcher(testScheduler))
val result = GetOverpaidStudentsUseCase(balances)().first()
assertEquals(-50_000, result.single().balance)
}
}
The "not created" note (example): GetStudentsUseCase was not created because it would only forward observeStudents(); the ViewModel calls the repository directly.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 63 project”.
On github.com click New repository, name it android-lesson-63-the-domain-layer-and-use-cases, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 63 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: What the Domain Layer Is For
- Step-by-Step Build 1: A First Use Case
- Step-by-Step Build 2: Suspend Use Cases and Main-Safety
- Step-by-Step Build 3: Use Cases that Call Use Cases
- Step-by-Step Build 4: Domain Models
- Step-by-Step Build 5: Testing Use Cases
- Step-by-Step Build 6: When Not to Use a Use Case
- Step-by-Step Build 7: Extracting a Use Case from a Fat ViewModel
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check