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.

──depends on──▶

Domain

Plain Kotlin business logic and use cases. No Android imports at all.

──depends on──▶

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.

Note: Every class and layer shown here is correct, real Kotlin as it would appear in an actual Android Studio project — but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself.

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.

Up next: Modularization takes this same idea of separation a step further, splitting TrailLog across separate Gradle modules rather than just separate layers within one module.