What does Clean Architecture look like in a real Android app?
Concentric-circle diagrams are easy to draw; the interview question is whether you can point to the actual files, interfaces, and dependency direction that make the architecture real.
LEARN BY DOINGPut your intuition to work3 steps
What does Clean Architecture look like in a real Android app?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What "Clean Architecture" means once you leave the diagram
Uncle Bob's Clean Architecture diagram — concentric circles, dependencies pointing inward — is easy to draw and vague to implement. On Android it settles into a fairly standard three-layer shape: presentation (UI + state holders), domain (business rules, framework-agnostic), and data (where the bytes actually come from). The one rule that matters more than the names of the layers is the Dependency Rule: source code dependencies can only point inward. Presentation depends on domain. Data depends on domain. Domain depends on nothing — not on Android, not on Retrofit, not on Room, not even on the specific data source it's fetching from.
#Domain: the layer with no Android imports
The domain layer holds three kinds of things: entities (plain data classes representing business concepts — not Room @Entity classes, not network DTOs), repository interfaces (contracts stated as "what the app needs," not "how it's fetched"), and use cases (also called interactors — single-purpose classes that orchestrate one specific action). If you can import android.* or import androidx.* anywhere in this layer, something is wrong. This is what makes the domain layer a plain Kotlin/Java module you can unit test in milliseconds with no Android dependencies, no emulator, no Robolectric.
#Data: implements the contracts domain defined
The data layer implements the repository interfaces domain declared, and internally wires together one or more data sources — a UserRemoteDataSource hitting Retrofit, a UserLocalDataSource wrapping a Room DAO, maybe a cache-first strategy between them. Crucially, mapping happens here: a network DTO (UserDto) or a database entity (UserEntity) gets converted into the domain's plain User before it ever leaves this layer. Domain never sees a DTO. This is the detail interviewers listen for — if your answer has DTOs leaking into the ViewModel, you haven't actually separated the layers, you've just renamed folders.
#Presentation: the only layer allowed to know about Android UI
Presentation holds the ViewModel (or Presenter), the Compose UI or XML/View code, and any UI-only models (UserUiModel if the UI needs, say, a pre-formatted date string the domain User doesn't have). The ViewModel calls one or more use cases, maps the domain result into UI state, and exposes it as StateFlow. It should not call a repository directly — that's the boundary use cases exist to enforce, and it's also what keeps business logic out of the ViewModel: if "can this user post a comment" requires checking three fields and a feature flag, that rule belongs in a CanPostCommentUseCase, not inlined in the ViewModel where no other screen can reuse it.
#The dependency-inversion trick
The subtle part of the Dependency Rule is that the runtime call still flows presentation → domain → data, but the compile-time dependency of the interface is inverted: domain defines interface UserRepository, and data provides class UserRepositoryImpl : UserRepository. Data depends on (implements) domain's interface — domain does not depend on data's implementation. That inversion is what lets you swap a real UserRepositoryImpl for a FakeUserRepository in tests without domain ever knowing the difference, and it's the actual mechanism behind "dependencies point inward," not just a diagram convention.
#Wiring it together
In practice, dependency injection (Hilt is the current default) is what actually connects UserRepositoryImpl to the UserRepository interface at runtime — you @Binds the implementation to the interface in a Hilt module, and the ViewModel just requests UserRepository (or, more precisely, requests the use case, which requests the repository) without ever knowing which concrete class it got.
// domain module — no Android imports anywhere in this filedata class User(val id: String, val name: String, val email: String)interface UserRepository {suspend fun getUser(id: String): User}class GetUserProfileUseCase(private val repository: UserRepository) {suspend operator fun invoke(userId: String): Result<User> =runCatching { repository.getUser(userId) }}// data module — implements the domain contractclass UserRepositoryImpl(private val remote: UserRemoteDataSource,private val local: UserLocalDataSource) : UserRepository {override suspend fun getUser(id: String): User {local.getCachedUser(id)?.let { return it.toDomain() }val dto = remote.fetchUser(id)local.cacheUser(dto.toEntity())return dto.toDomain()}}private fun UserDto.toDomain() = User(id = id, name = fullName, email = email)private fun UserEntity.toDomain() = User(id = id, name = name, email = email)