Techbase Academy
Android · Lesson 63

The Domain Layer and Use Cases

13 phasesDomain Layer for FeeTrackerAndroid Studio
Phase 1 of 13
Lesson Introduction

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 invoke and 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.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 13
Prerequisite Concepts
  • Layers and the dependency rule (lesson 60).
  • UI layer state production (lesson 61).
  • Repositories and fakes (lesson 62).
  • Kotlin operator functions and invoke (lessons 11 and 12).
  • Coroutines and Flow (lesson 33).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 13
Conceptual Understanding: What the Domain Layer Is For
text
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
✏️ Your Task
Practise what you just learned about Conceptual Understanding: What the Domain Layer Is For. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 13
Step-by-Step Build 1: A First Use Case

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:

kotlin
// domain/model/StudentBalance.kt
data class StudentBalance(
 val student: Student,
 val paid: Long,
 val balance: Long
) {
 val isOwing: Boolean get() = balance > 0
}
kotlin
// 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 invoke lets callers write getStudentBalances() 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

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: A First Use Case. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 13
Step-by-Step Build 2: Suspend Use Cases and Main-Safety

For one-shot actions, a use case is a suspend operator fun invoke:

kotlin
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:

  1. Checks rules that do not depend on the UI.
  2. Calls repositories in the right order.
  3. Returns a Result so 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. Add withContext(defaultDispatcher) only inside use cases that do heavy CPU work themselves.

Calling it from the ViewModel

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Suspend Use Cases and Main-Safety. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 13
Step-by-Step Build 3: Use Cases that Call Use Cases

Use cases can build on each other, which keeps each one small:

kotlin
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

text
StudentsViewModel ─► GetStudentBalancesUseCase ─► StudentsRepository
                                                └► PaymentsRepository

Arrows go down only. A use case must never call a ViewModel.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Use Cases that Call Use Cases. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 13
Step-by-Step Build 4: Domain Models

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:

kotlin
// 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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Domain Models. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 13
Step-by-Step Build 5: Testing Use Cases

Use cases are the easiest code to test, because they are plain Kotlin with injected dependencies.

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Testing Use Cases. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 13
Step-by-Step Build 6: When Not to Use a Use Case

The pass-through anti-pattern

kotlin
// 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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: When Not to Use a Use Case. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 13
Step-by-Step Build 7: Extracting a Use Case from a Fat ViewModel

You will meet this situation in real projects, so practise the safe way to do it. Suppose StudentsViewModel has grown to include this block:

kotlin
// 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:

  1. 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.
  2. 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.
  3. Name the magic number. The literal 450_000 is a business rule. Give it a name or a constructor parameter so it appears in one place only.
  4. Switch the ViewModel to call the use case, then delete the old block. Tests still pass, so behaviour is preserved.
  5. 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.
  6. 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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: Extracting a Use Case from a Fat ViewModel. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 13
Guided Practice Exercises
🎯 Your Challenge

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.

🎯 Your Challenge

Exercise 2: Fix the use case

Why is this a poor use case, and how do you improve it?

kotlin
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.

🎯 Your Challenge

Exercise 3: Write a use case

Write GetTopPayersUseCase(count: Int) returning the students with the largest total payments, highest first, from PaymentsRepository.observePayments().

kotlin
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)
}
✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 13
Common Beginner Mistakes (and How to Fix Them)

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.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 13
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Domain Layer for FeeTracker

Project goal: Add a small, well-tested domain layer to FeeTracker.

Requirements:

  1. Create domain/model/StudentBalance.
  2. Implement GetStudentBalancesUseCase combining two repositories (with fakes if you lack the real ones).
  3. Implement GetOwingStudentsUseCase that builds on it.
  4. Implement RecordPaymentUseCase with validation (positive amount, student exists).
  5. Implement FormatMoneyUseCase for Naira formatting.
  6. Use two use cases from two different ViewModels (Students and Dashboard) to prove reuse.
  7. Write tests for each use case, including edge cases: no payments, overpayment (negative balance), unknown student, zero amount.
  8. 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:

kotlin
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.

1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close