5 min read

Clean Architecture on Android: Which Layers Actually Earn Their Keep

After 12 years of shipping, inheriting and deleting Clean Architecture on Android: the two boundaries that consistently pay for themselves, the three that usually don't, and the single test to apply before adding a layer.

AndroidArchitectureClean ArchitectureEngineeringKotlin
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Layers are a trade, not a virtue. The two that reliably pay for themselves on Android are a domain model the UI can't corrupt and a repository boundary. The three that usually don't: a use case per repository method, mappers between structurally identical types, and interfaces with exactly one implementation. Before adding a layer, name the specific change it makes cheaper. If you can't, you're paying a tax today for an option you may never exercise.

The problem with the standard diagram

Clean Architecture is usually introduced as concentric circles with dependencies pointing inward. That's a sound principle. What gets lost is that every boundary has a cost — files, indirection, onboarding time, and a place for bugs to hide during a refactor — and the diagram shows none of it.

So teams implement all the boundaries, get the costs immediately, and get the benefits only if the anticipated change ever arrives. Let's separate them.

What consistently pays: a domain model the UI can't corrupt

This is the highest-value boundary in Android, and it is more a rule than a package:

Your API DTOs never reach the screen.

// Network layer — shaped by the backend, nullable everywhere, changes without warning.
data class UserDto(val id: String?, val display_name: String?, val avatar: String?)

// Domain — shaped by your product. Non-null, meaningful, yours.
data class User(val id: UserId, val name: String, val avatar: Uri?)

The reason isn't purity. It's that backend types are someone else's schema, generally nullable because the wire format allows it, and they change on a release cycle you don't control. Letting them reach composables means every backend nullability decision becomes a UI concern, and every rename is a multi-screen refactor.

This boundary survives every architecture argument I've had, on every team.

What consistently pays: a repository boundary

One place that decides cache versus network versus disk, exposing an interface stated in domain terms:

interface UserRepository {
    fun observe(id: UserId): Flow<User>
    suspend fun refresh(id: UserId)
}

It earns its keep because caching policy is genuinely cross-cutting — without a boundary it ends up duplicated across ViewModels — and because it makes tests fast. A fake repository is a few lines; a fake HTTP server is infrastructure.

What usually doesn't: a use case per repository method

class GetUserUseCase @Inject constructor(private val repo: UserRepository) {
    operator fun invoke(id: UserId) = repo.observe(id)
}

This is a redirect with a file of its own. It adds a class, a DI binding, a test file, and one more hop when reading the code — in exchange for nothing that repo.observe(id) didn't already provide.

Use cases become worth it when they hold real policy — combining sources, enforcing a rule, or orchestrating a sequence:

class RefreshFeedUseCase @Inject constructor(
    private val feed: FeedRepository,
    private val prefs: PreferencesRepository,
    private val clock: Clock,
) {
    suspend operator fun invoke(): Result<Unit> {
        if (!prefs.wifiOnly() || network.isUnmetered()) return feed.refresh()
        return Result.failure(WaitingForWifi)
    }
}

That logic has to live somewhere, and a ViewModel is the wrong home because it belongs to the product, not the screen. The distinction is whether the class contains a decision.

What usually doesn't: mappers between identical shapes

If UserDto, User, and UserUiModel carry the same five fields, you've bought three files, two mappers, and two chances to drop a field — to protect against a divergence that hasn't happened in two years.

Keep the DTO/domain split, because those genuinely diverge. Add a separate UI model when the screen needs something the domain doesn't have — a formatted date, a derived label, a pre-computed flag — not automatically.

What usually doesn't: an interface with one implementation forever

An interface is a bet that a second implementation is coming. Sometimes that's obviously true (a payment provider, a feature-flag backend). Often it isn't, and you've added indirection that makes "jump to implementation" a two-step operation for the entire life of the codebase.

Note that testability is a weaker argument than it used to be: you can fake a concrete class with an open modifier or a test double, and integration tests against a real implementation frequently catch more than a hand-written fake does.

The test to apply

Before adding a layer, finish this sentence:

"This boundary makes ______ cheaper to change."

  • "Swapping our HTTP client" — plausible, happens.
  • "Backend renaming a field" — happens constantly. Worth a boundary.
  • "If we ever move off Firebase" — a hypothetical, and you're paying today for an option you may never exercise.

A layer you can justify with a concrete change is architecture. A layer you justify with "it's the clean way" is cargo cult with a build cost.

The senior position

Architecture is a portfolio of bets about which changes are coming. Good senior judgement isn't knowing the diagram — it's knowing which bets your particular product is likely to win, and being willing to delete the ones it lost.

Keep reading