Clean Architecture & Use Cases Explained
ARCHITECTURE › Patterns
An interviewer asking about Clean Architecture usually isn't testing whether you've memorized a diagram. They want to see whether you can structure a real app and explain **why** it's structured that way: why can't the domain layer import something from the UI, why does a ViewModel never touch a database directly.
Android's recommended architecture splits an app into three layers: **presentation (UI)**, **domain**, and **data**. Dependencies point in one direction only: UI depends on domain, and domain depends on data. Data and domain never depend on UI, and domain never has to know the UI exists at all.
// :domain module - pure Kotlin, no Android imports
class GetLatestNewsUseCase(private val repo: NewsRepository) { ... }
// :app (UI) module - allowed to import :domain
import com.example.domain.usecase.GetLatestNewsUseCase
This one-way arrow is the whole point. It's what lets you swap out the UI framework, or run the same business logic somewhere the UI layer has never heard of, without touching that logic at all.
There's a wrinkle in that one-way rule. A use case in the domain layer needs data, say, the latest news, but the code that actually fetches it, a Retrofit service or a Room database, lives in the outer data layer. If domain imported that class directly, the arrow would point outward and break the rule.
The fix is **dependency inversion**: the domain layer declares what it needs as an **interface**, and the data layer supplies the concrete class that implements it.
// domain layer - owns the abstraction
interface NewsRepository {
suspend fun getLatestNews(): List<News>
}
// data layer - owns the concrete implementation
class NewsRepositoryImpl(private val api: NewsApi) : NewsRepository {
override suspend fun getLatestNews() = api.fetchNews().map { it.toDomain() }
}
Domain only ever references its own NewsRepository interface, never NewsRepositoryImpl and never NewsApi. Something outside both layers, Hilt in most Android apps, wires the interface to the implementation at runtime. The dependency rule survives because domain still hasn't imported a single thing from data.
A **use case**, also called an interactor, wraps exactly one business operation. Fetching the latest news is a use case. Logging a user out is a use case. A class that does five unrelated things isn't.
The naming convention makes that single responsibility obvious: verb, present tense, plus noun, plus UseCase.
class GetLatestNewsWithAuthorsUseCase @Inject constructor(
private val newsRepository: NewsRepository
) {
suspend operator fun invoke(): List<NewsWithAuthor> =
newsRepository.getLatestNewsWithAuthors()
}
GetLatestNewsWithAuthorsUseCase reads almost like a sentence: get the latest news with authors. Compare that to a NewsManager or a NewsUseCaseHelper, neither tells you what single operation it performs, and that ambiguity is exactly what the naming convention exists to prevent.
Kotlin's operator fun invoke() lets you call a use case instance as if it were a plain function, no .execute(), no .run().
class GetLatestNewsUseCase(private val repository: NewsRepository) {
suspend operator fun invoke(): List<News> = repository.getLatestNews()
}
// Caller
val news = getLatestNewsUseCase() // not getLatestNewsUseCase.invoke()
That syntax is a hint at a deeper rule: use cases are meant to be **stateless**. They hold no mutable data of their own and have no lifecycle to manage, so there's nothing wrong with creating a brand new instance every time one gets injected. The danger runs the other way: if a use case did carry mutable state and one instance got shared across callers, one caller's result could leak into another's.
A use case must be **main-safe**: safe to call directly from the main thread. That doesn't mean it runs on the main thread, it means the *caller* doesn't have to think about threading at all. The use case itself is responsible for shifting any blocking work off the main thread before it does anything expensive.
class GetNewsUseCase(
private val newsRepository: NewsRepository,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend operator fun invoke(): List<News> =
withContext(ioDispatcher) { newsRepository.fetchNews() }
}
Notice the dispatcher is a constructor parameter with a default, not a hardcoded Dispatchers.IO buried inside the function body. That's deliberate: a test can inject a test dispatcher instead of the real one, and the ViewModel that calls this use case never has to wrap the call in withContext itself. It just calls the suspend function from viewModelScope and trusts that threading has already been handled underneath it.
The domain layer stays **pure Kotlin**. No Context, no View, no import from the Android SDK at all, not even something as innocuous-looking as android.text.format.DateFormat.
// BAD - couples domain to the Android SDK; can't unit-test on plain JVM
class FormatDateUseCase(private val context: Context) { ... }
// GOOD - pure Kotlin, no Android dependency
class FormatDateUseCase {
fun invoke(date: Long): String =
SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(date)
}
That restriction pays for itself twice over. First, testability: a pure Kotlin class runs in a plain JVM unit test, no Robolectric, no emulator, no Android stub. You give it a fake NewsRepository and assert on the result. Second, portability: since nothing ties it to the Android SDK, the same domain module can back a Wear tile, a TV app, or a Kotlin Multiplatform target without a single change.
None of this means every operation needs its own use case. A use case earns its keep when at least one of three things is true: the logic is **reused across multiple ViewModels**, it **combines results from more than one repository**, or it encodes **genuinely complex business rules**.
// Thin pass-through - adds boilerplate for no benefit
class GetNewsUseCase(private val repo: NewsRepository) {
suspend operator fun invoke() = repo.getNews()
}
// Justified - combines two repositories and maps the result
class GetNewsWithAuthorsUseCase(
private val newsRepo: NewsRepository,
private val authorRepo: AuthorRepository
) {
suspend operator fun invoke(): List<NewsWithAuthor> = ...
}
If a ViewModel would just call newsRepo.getNews() through an extra layer of indirection with no logic of its own added, skip the use case and call the repository directly. Treating every operation as needing a use case is dogma, not good architecture, and a good interview answer says so explicitly.
Use cases aren't limited to depending on repositories. A use case can just as legitimately depend on **other use cases**, letting you build a higher-level operation out of smaller, already-tested pieces instead of duplicating their logic.
class GetUserNewsUseCase @Inject constructor(
private val getUserUseCase: GetUserUseCase,
private val getLatestNewsUseCase: GetLatestNewsUseCase
) {
suspend operator fun invoke(): UserNews? {
val user = getUserUseCase() ?: return null
val news = getLatestNewsUseCase()
return UserNews(user, news)
}
}
This is composition, not a special case that needs extra machinery. GetUserNewsUseCase doesn't know or care that getUserUseCase and getLatestNewsUseCase are themselves use cases rather than repositories, it just calls them like any other suspend function it depends on, and short-circuits to null the same way a single use case would if a lookup failed.
Use cases aren't the only thing living in the domain layer. It also holds **domain models**, the entities your business logic actually operates on, and the **repository interfaces** those use cases depend on. What it does not hold is anything from the outer layers: no Activity or Fragment, no Room DAO, no Retrofit service interface, no @Composable function.
Domain models are usually distinct from the DTOs a network call returns or the entities a database stores, with small mapper functions bridging them.
// data layer - shaped by the API's JSON, tied to that schema
data class NewsDto(val headline: String, val author_id: Int)
// domain layer - stable, decoupled from the API or database shape
data class News(val title: String, val authorId: Int)
fun NewsDto.toDomain() = News(title = headline, authorId = author_id)
The payoff shows up when the API changes its field names or the database migrates: the mapper absorbs the change, and nothing in domain, and nothing in any ViewModel that consumes News, has to know it happened.
Put the whole picture together and here's what Android's own guidance says: the UI and data layers are strongly recommended for every app. The domain layer is **optional**, worth adding once an app is big enough that you're reusing data-layer logic across several ViewModels, or a single ViewModel has grown complex enough that pulling logic out of it clarifies what it's doing.
ViewModels in the UI layer are the ones that depend on use cases, or on a repository directly for the thin cases, call them, and expose the resulting state to the screen. Nothing below the ViewModel knows the UI exists.
If an interviewer asks you to defend this layering, the honest answer isn't 'it's best practice'. It's that pushing business logic into pure Kotlin classes with explicit, interface-based dependencies makes that logic testable with fake repositories and no Android runtime, reusable across every ViewModel that needs it, and safe to change without hunting through UI code. Use cases are a tool you reach for when those payoffs are worth the extra class, not a rule you apply everywhere.