Clean Architecture
Clean Architecture is a set of principles for organizing a codebase into clearly separated layers, each with one job, connected by a single strict rule: inner layers never know anything about outer layers. TrailLog's business logic — "get the list of trails," "save a new trail" — shouldn't need to know or care whether that data ultimately comes from Room, a network call, or something else entirely. That single rule is what the rest of this lesson unpacks.
Three Layers, One Dependency Direction
Presentation
Activities, Fragments, ViewModels. See Architecture: MVVM.
Domain
Plain Kotlin business logic and use cases. No Android imports at all.
Data
Repository implementations, Room, Retrofit. See The Repository Pattern.
The arrows above show the allowed direction of dependency, and it only runs one way: Presentation is allowed to depend on Domain, and Domain defines interfaces that Data implements — but Domain itself never imports anything from Data, and neither Domain nor Data ever imports anything from Presentation. An Activity can know about a use case; a use case can never know an Activity exists.
The Use Case: One Class, One Action
A use case is a small class representing exactly one specific action the app can
perform — nothing more. Instead of TrailListViewModel calling
trailRepository.getTrails() directly, it goes through a dedicated use case:
class GetTrailsUseCase(private val repository: TrailRepository) {
suspend operator fun invoke(): List<Trail> = repository.getTrails()
}
The operator fun invoke() convention is a genuinely elegant piece of Kotlin here: marking
a function operator fun invoke lets you call an instance of the class as if it
were a function itself. So instead of writing getTrailsUseCase.invoke(), callers simply
write getTrailsUseCase() — it reads exactly like calling a plain function, while still
being a real object that Hilt (see Dependency Injection
with Hilt) can construct and inject like anything else:
class TrailListViewModel @Inject constructor(
private val getTrailsUseCase: GetTrailsUseCase
) : ViewModel() {
fun loadTrails() {
viewModelScope.launch {
_trails.value = getTrailsUseCase()
}
}
}
Why a Pure-Kotlin Domain Layer Matters
Because GetTrailsUseCase imports nothing from the Android SDK — no Context,
no Activity, nothing that only exists on a real device — it's trivially unit-testable as
plain JVM code with a fake TrailRepository, exactly like the classes in
Unit Testing (JUnit & MockK). It's also fully reusable: the
same GetTrailsUseCase would work unchanged if TrailLog ever shipped a second UI surface,
like a widget or a wearable companion app, since none of its logic is tied to any particular screen or
framework class.
Being Honest About the Overhead
Clean Architecture adds real structural cost: more files, more interfaces, more indirection between a button tap and the code that actually runs. For a larger app maintained by a team over a long period, that overhead genuinely pays for itself — clear boundaries mean one developer can work on the Data layer while another works on Presentation, with the Domain layer as a stable contract between them. For a small solo weekend project, it's probably overkill. Worth noting: TrailLog itself has stayed with plain MVVM plus the Repository pattern for most of this track, exactly because that simpler structure was the right fit for a single running example — introducing full Clean Architecture layering is a deliberate scaling decision, not a mandatory starting point.