← All posts

Clean Architecture in Android: what actually matters

April 29, 2026 5 min read

I spent the first month of a legacy migration arguing about folder structure. Heated Slack threads about whether UserMapper belonged in the data layer or its own mappers package. Meanwhile, the actual problem — a codebase where changing the API broke the UI in six unpredictable ways — was sitting untouched.

That’s the trap with Clean Architecture. It gives you a vocabulary, and teams spend their energy on the vocabulary instead of the problem it’s meant to solve.

The problem CA actually solves

Clean Architecture is not about layers. It is about change isolation.

The core symptom in legacy Android codebases is always the same: you can’t change anything without fear. A database schema change requires hunting through three Activities for hardcoded column names. Adding offline mode is impossible because networking, caching, and UI rendering are tangled into single classes.

The problem isn’t missing layers. It’s that changes propagate everywhere unpredictably.

CA’s three-layer model gives you one useful mental model: who should change when this requirement changes?

If a UI change forces you to touch a repository, or an API change leaks into a ViewModel, your boundaries are wrong. The layer names don’t matter. The isolation does.

The one rule that actually matters

Outer layers depend on inner layers. Never the reverse.

   presentation                        data
   Activity, Fragment,                 Retrofit, Room,
   ViewModel, Compose                  DTOs, mappers
          |                                  |
          | depends on                       | depends on
          +----------------+-----------------+
                           v
                        domain
        entities . use cases . repository interfaces
          pure Kotlin — no Android SDK, no Retrofit

   arrows only ever point inward; domain imports nothing

Your domain layer — use cases, entities, repository interfaces — should import nothing outside pure Kotlin. No Android SDK. No Retrofit. No Room annotations. If your domain tests run on a JVM without an emulator, you’ve achieved the most valuable part of the architecture.

The check is simple: open your domain module’s build.gradle. If you see implementation("androidx...") or a Retrofit dependency, business logic is coupled to a framework. That means expensive tests and pain during every major library migration.

Where teams go wrong

Mapping everything. The biggest complaint about CA is mapper boilerplate — three classes and three mappers for a username field. The honest answer: if your domain entity and UI model are structurally identical, merge them. Mapping earns its place when it protects a genuine boundary: keeping a backend field out of your UI state, or insulating domain objects from database schema changes. If it’s just ceremony, remove it.

Interfaces for everything. Not every repository needs a separate interface. Interfaces exist for two reasons: multiple implementations, or testing with fakes. If neither applies, the interface is noise. I’ve seen codebases with UserRepository, UserRepositoryImpl, and UserRepositoryInterface as three separate files. Nobody caught a bug those files prevented.

Clean architecture without tests. This is the most damaging mistake. CA makes code testable — it does not make it tested. The architecture gives you the seams: isolated use cases with injected dependencies. But someone still has to write the tests. I’ve seen perfectly structured Clean Architecture codebases with 2% coverage. The structure is necessary but not sufficient.

What a migration actually looks like

The worst thing you can do is stop all feature work and refactor bottom-up for three months. Teams burn out. Business teams lose trust. Refactors die halfway through.

What works: migrate by feature, not by layer.

Pick the next feature going into development. Before building it, carve out a clean module with proper boundaries. Leave the legacy code alone. Over six to twelve months, new code grows and legacy code shrinks at the edges. You never have a flag day where everything breaks at once.

When we took this approach on a large production app, test coverage moved from 0% to 80% over that period — not because the new structure wrote tests for us, but because we finally had units small enough to test in isolation. Feature teams could work on separate modules without stepping on each other’s commits. QA could run module-level regression without triggering a full app build.

That’s the real payoff. Not “better architecture” in the abstract. Parallel work. Faster feedback cycles. Fewer production surprises.

A concrete smell to check right now

Open any Activity or Fragment in your project. Count how many import statements reference packages outside the presentation layer.

  intact:  Fragment -> ViewModel -> UseCase -> Repository
                                                   |
                                            Retrofit / Room

  leaked:  Fragment ----------------------------> Retrofit
           ViewModel ---------------------------> Room DAO
           Retrofit callback -> business rules inline

Retrofit calls in a Fragment: data layer leaked into presentation. Room queries in a ViewModel: data layer in presentation. Business logic inside a network callback: domain logic in data.

You don’t need to fix all of this today. But each violation is a place where a future change costs more than it should. Clean Architecture is just a name for the ongoing practice of pushing those violations toward the edges.

When to not bother

A settings screen that reads one flag from SharedPreferences does not need a use case, a repository interface, and a domain entity. Build the simplest thing that isolates the change. Add layers when the complexity earns them.

Clean Architecture is a tool for managing complexity at scale. Apply it where there is actual complexity to manage.