¿Qué hace Arquitectura Limpia para Android?
Patrones de Arquitectura Limpia para proyectos Android y KMP. Cubre límites de módulos, inversión de dependencias, patrones UseCase/Repository y diseño de la capa de datos con Room, SQLDelight y Ktor.
Cuándo activarla
- Al estructurar módulos de un proyecto Android o KMP
- Al implementar UseCases, Repositories o DataSources
- Al diseñar el flujo de datos entre capas (domain, data, presentation)
- Al configurar la inyección de dependencias con Koin o Hilt
- Al trabajar con Room, SQLDelight o Ktor en una arquitectura por capas
Estructura de módulos
Diseño recomendado
project/
├── app/ # Punto de entrada Android, cableado de DI, clase Application
├── core/ # Utilidades compartidas, clases base, tipos de error
├── domain/ # UseCases, modelos de dominio, interfaces de repositorio (Kotlin puro)
├── data/ # Implementaciones de repositorio, DataSources, BD, red
├── presentation/ # Pantallas, ViewModels, modelos de UI, navegación
├── design-system/ # Componentes Compose reutilizables, tema, tipografía
└── feature/ # Módulos de funcionalidad (opcional, para proyectos grandes)
├── auth/
├── settings/
└── profile/
Reglas de dependencia
app → presentation, domain, data, core
presentation → domain, design-system, core
data → domain, core
domain → core (o sin dependencias)
core → (nada)
Crítico: domain NUNCA debe depender de data, presentation ni de ningún framework. Solo contiene Kotlin puro.
Capa de dominio
Patrón UseCase
Cada UseCase representa una operación de negocio. Usa operator fun invoke para invocaciones limpias:
class GetItemsByCategoryUseCase(
private val repository: ItemRepository
) {
suspend operator fun invoke(category: String): Result<List<Item>> {
return repository.getItemsByCategory(category)
}
}
// UseCase basado en Flow para flujos reactivos
class ObserveUserProgressUseCase(
private val repository: UserRepository
) {
operator fun invoke(userId: String): Flow<UserProgress> {
return repository.observeProgress(userId)
}
}
Modelos de dominio
Los modelos de dominio son clases de datos Kotlin simples, sin anotaciones de frameworks:
data class Item(
val id: String,
val title: String,
val description: String,
val tags: List<String>,
val status: Status,
val category: String
)
enum class Status { DRAFT, ACTIVE, ARCHIVED }
Interfaces de repositorio
Definidas en domain, implementadas en data:
interface ItemRepository {
suspend fun getItemsByCategory(category: String): Result<List<Item>>
suspend fun saveItem(item: Item): Result<Unit>
fun observeItems(): Flow<List<Item>>
}
Capa de datos
Implementación del repositorio
Coordina entre fuentes de datos locales y remotas:
class ItemRepositoryImpl(
private val localDataSource: ItemLocalDataSource,
private val remoteDataSource: ItemRemoteDataSource
) : ItemRepository {
override suspend fun getItemsByCategory(category: String): Result<List<Item>> {
return runCatching {
val remote = remoteDataSource.fetchItems(category)
localDataSource.insertItems(remote.map { it.toEntity() })
localDataSource.getItemsByCategory(category).map { it.toDomain() }
}
}
override suspend fun saveItem(item: Item): Result<Unit> {
return runCatching {
localDataSource.insertItems(listOf(item.toEntity()))
}
}
override fun observeItems(): Flow<List<Item>> {
return localDataSource.observeAll().map { entities ->
entities.map { it.toDomain() }
}
}
}
Patrón Mapper
Mantén los mappers como funciones de extensión junto a los modelos de datos:
// En la capa de datos
fun ItemEntity.toDomain() = Item(
id = id,
title = title,
description = description,
tags = tags.split("|"),
status = Status.valueOf(status),
category = category
)
fun ItemDto.toEntity() = ItemEntity(
id = id,
title = title,
description = description,
tags = tags.joinToString("|"),
status = status,
category = category
)
Room Database (Android)
@Entity(tableName = "items")
data class ItemEntity(
@PrimaryKey val id: String,
val title: String,
val description: String,
val tags: String,
val status: String,
val category: String
)
@Dao
interface ItemDao {
@Query("SELECT * FROM items WHERE category = :category")
suspend fun getByCategory(category: String): List<ItemEntity>
@Upsert
suspend fun upsert(items: List<ItemEntity>)
@Query("SELECT * FROM items")
fun observeAll(): Flow<List<ItemEntity>>
}
SQLDelight (KMP)
-- Item.sq
CREATE TABLE ItemEntity (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
tags TEXT NOT NULL,
status TEXT NOT NULL,
category TEXT NOT NULL
);
getByCategory:
SELECT * FROM ItemEntity WHERE category = ?;
upsert:
INSERT OR REPLACE INTO ItemEntity (id, title, description, tags, status, category)
VALUES (?, ?, ?, ?, ?, ?);
observeAll:
SELECT * FROM ItemEntity;
Cliente de red Ktor (KMP)
class ItemRemoteDataSource(private val client: HttpClient) {
suspend fun fetchItems(category: String): List<ItemDto> {
return client.get("api/items") {
parameter("category", category)
}.body()
}
}
// Configuración de HttpClient con negociación de contenido
val httpClient = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(Logging) { level = LogLevel.HEADERS }
defaultRequest { url("https://api.example.com/") }
}
Inyección de dependencias
Koin (compatible con KMP)
// Módulo de dominio
val domainModule = module {
factory { GetItemsByCategoryUseCase(get()) }
factory { ObserveUserProgressUseCase(get()) }
}
// Módulo de datos
val dataModule = module {
single<ItemRepository> { ItemRepositoryImpl(get(), get()) }
single { ItemLocalDataSource(get()) }
single { ItemRemoteDataSource(get()) }
}
// Módulo de presentación
val presentationModule = module {
viewModelOf(::ItemListViewModel)
viewModelOf(::DashboardViewModel)
}
Hilt (solo Android)
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
@HiltViewModel
class ItemListViewModel @Inject constructor(
private val getItems: GetItemsByCategoryUseCase
) : ViewModel()
Manejo de errores
Patrón Result/Try
Usa Result<T> o un tipo sellado personalizado para propagar errores:
sealed interface Try<out T> {
data class Success<T>(val value: T) : Try<T>
data class Failure(val error: AppError) : Try<Nothing>
}
sealed interface AppError {
data class Network(val message: String) : AppError
data class Database(val message: String) : AppError
data object Unauthorized : AppError
}
// En el ViewModel — mapear al estado de UI
viewModelScope.launch {
when (val result = getItems(category)) {
is Try.Success -> _state.update { it.copy(items = result.value, isLoading = false) }
is Try.Failure -> _state.update { it.copy(error = result.error.toMessage(), isLoading = false) }
}
}
Plugins de convención (Gradle)
Para proyectos KMP, usa plugins de convención para reducir la duplicación de archivos de configuración:
// build-logic/src/main/kotlin/kmp-library.gradle.kts
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
kotlin {
androidTarget()
iosX64(); iosArm64(); iosSimulatorArm64()
sourceSets {
commonMain.dependencies { /* dependencias compartidas */ }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
Aplica en los módulos:
// domain/build.gradle.kts
plugins { id("kmp-library") }
Antipatrones a evitar
- Importar clases del framework de Android en
domain— mantenlo como Kotlin puro - Exponer entidades de base de datos o DTOs a la capa de UI — siempre mapea a modelos de dominio
- Poner lógica de negocio en los ViewModels — extráela a UseCases
- Usar
GlobalScopeo corrutinas no estructuradas — usaviewModelScopeo concurrencia estructurada - Implementaciones de repositorio demasiado grandes — divide en DataSources enfocados
- Dependencias circulares entre módulos — si A depende de B, B no debe depender de A
Referencias
Consulta la habilidad: compose-multiplatform-patterns para patrones de UI.
Consulta la habilidad: kotlin-coroutines-flows para patrones asíncronos.