¿Cómo se ve Clean Architecture en una app Android real?
Los diagramas de círculos concéntricos son fáciles de dibujar; la pregunta de entrevista es si puedes señalar los archivos, interfaces y dirección de dependencias reales que hacen que la arquitectura exista de verdad.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo se ve Clean Architecture en una app Android real?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué significa "Clean Architecture" cuando sales del diagrama
El diagrama de Clean Architecture de Uncle Bob — círculos concéntricos, dependencias apuntando hacia adentro — es fácil de dibujar y vago de implementar. En Android suele asentarse en una forma bastante estándar de tres capas: presentación (UI + contenedores de estado), dominio (reglas de negocio, independientes del framework) y datos (de donde realmente vienen los bytes). La regla que importa más que los nombres de las capas es la Regla de Dependencia: las dependencias del código fuente solo pueden apuntar hacia adentro. Presentación depende de dominio. Datos depende de dominio. Dominio no depende de nada — ni de Android, ni de Retrofit, ni de Room, ni siquiera de la fuente de datos concreta de la que proviene la información.
#Dominio: la capa sin ningún import de Android
La capa de dominio contiene tres tipos de cosas: entidades (data classes planas que representan conceptos de negocio — no clases @Entity de Room, ni DTOs de red), interfaces de repositorio (contratos expresados como "qué necesita la app", no como "cómo se obtiene") y casos de uso (también llamados interactors — clases de un único propósito que orquestan una acción concreta). Si en algún punto de esta capa puedes escribir import android.* o import androidx.*, algo está mal. Esto es lo que convierte a la capa de dominio en un módulo de Kotlin/Java puro que se puede testear unitariamente en milisegundos, sin dependencias de Android, sin emulador, sin Robolectric.
#Datos: implementa los contratos que definió dominio
La capa de datos implementa las interfaces de repositorio que declaró dominio, y por dentro conecta una o varias fuentes de datos — un UserRemoteDataSource que llama a Retrofit, un UserLocalDataSource que envuelve un DAO de Room, quizás una estrategia cache-first entre ambos. Lo crucial es que el mapeo ocurre aquí: un DTO de red (UserDto) o una entidad de base de datos (UserEntity) se convierten al User plano de dominio antes de salir de esta capa. Dominio nunca ve un DTO. Este es el detalle que los entrevistadores escuchan con atención — si en tu respuesta los DTOs se filtran hasta el ViewModel, no has separado realmente las capas, solo has renombrado carpetas.
#Presentación: la única capa autorizada a conocer la UI de Android
Presentación contiene el ViewModel (o Presenter), la UI en Compose o en XML/View, y cualquier modelo exclusivo de UI (UserUiModel, si la interfaz necesita, por ejemplo, una fecha ya formateada que el User de dominio no tiene). El ViewModel llama a uno o varios casos de uso, convierte el resultado de dominio en estado de UI y lo expone como StateFlow. No debería llamar a un repositorio directamente — esa es justamente la frontera que existen los casos de uso para hacer cumplir, y es también lo que mantiene la lógica de negocio fuera del ViewModel: si "puede este usuario publicar un comentario" requiere comprobar tres campos y un feature flag, esa regla pertenece a un CanPostCommentUseCase, no metida a mano en el ViewModel, donde ninguna otra pantalla puede reutilizarla.
#El truco de la inversión de dependencias
La parte sutil de la Regla de Dependencia es que la llamada en tiempo de ejecución sigue fluyendo presentación → dominio → datos, pero la dependencia en tiempo de compilación de la interfaz está invertida: dominio define interface UserRepository, y datos provee class UserRepositoryImpl : UserRepository. Datos depende de (implementa) la interfaz de dominio — dominio no depende de la implementación de datos. Esa inversión es lo que te permite sustituir un UserRepositoryImpl real por un FakeUserRepository en los tests sin que dominio note la diferencia jamás, y es el mecanismo real detrás de "las dependencias apuntan hacia adentro", no solo una convención del diagrama.
#Conectándolo todo
En la práctica, la inyección de dependencias (Hilt es la opción por defecto actual) es lo que realmente conecta UserRepositoryImpl con la interfaz UserRepository en tiempo de ejecución: haces @Binds de la implementación a la interfaz en un módulo de Hilt, y el ViewModel simplemente solicita UserRepository (o, más precisamente, solicita el caso de uso, que a su vez solicita el repositorio) sin saber jamás qué clase concreta recibió.
// módulo de dominio — ningún import de Android en este archivodata 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) }}// módulo de datos — implementa el contrato del dominioclass 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)