Clean Architecture Android — Cultiva Aprende

Arquitectura por capas para app de micro-cursos de IA · Android + KMP · Koin · Room · Ktor

dee20a8b · avanzado
Estructura de capas — módulos Gradle
:app
Entry point Android · Application class · DI wiring (startKoin)
CultivaAprende.kt AppModule.kt MainActivity.kt
↓ depende de
:presentation
Screens Compose · ViewModels · UI State · Navigation
CursosScreen CursosViewModel UiState
↓ depende de (solo interfaces)
:domain ★
Kotlin puro · UseCases · Modelos · Interfaces repositorios — CERO dependencias de framework
Curso.kt GetCursosUseCase MarcarLeccionUseCase CursoRepository
↓ implementa interfaces de domain
:data
Room · Ktor · Mappers · DataSources local + remoto
CursoRepositoryImpl CursoEntity CursoDto CursoDao CursoRemoteDataSource
↓ comparte utilidades
:core
Extensiones · AppError · tipos base · nada de negocio
AppError Try<T> Extensions.kt
Regla crítica: :domain NO puede importar nada de :data, :presentation ni Android framework. Solo Kotlin puro. Si un import tiene android.* en domain → arquitectura rota.
Capa Domain — Kotlin puro, sin framework
Curso.kt
GetCursosUseCase.kt
CursoRepository.kt
domain/src/commonMain/kotlin/ia/cultiva/domain/model/Curso.kt
// ✅ Pure Kotlin — zero framework imports
package ia.cultiva.domain.model

data class Curso(
    val id: String,
    val titulo: String,
    val descripcion: String,
    val categoria: Categoria,
    val dificultad: Dificultad,
    val duracionMin: Int,
    val lecciones: List<Leccion> = emptyList()
)

data class Leccion(
    val id: String,
    val cursoId: String,
    val titulo: String,
    val ordenIndex: Int,
    val completada: Boolean = false
)

data class Progreso(
    val userId: String,
    val cursoId: String,
    val leccionesCompletadas: Int,
    val total: Int
) {
    val porcentaje: Float
        get() = if (total == 0) 0f else leccionesCompletadas.toFloat() / total

    val completado: Boolean
        get() = leccionesCompletadas >= total
}

enum class Categoria { MARKETING, AUTOMATIZACION, DATOS, AGENTES }
enum class Dificultad { BASICO, INTERMEDIO, AVANZADO }

// ─────────────────────────────────────────────────
// domain/usecase/GetCursosUseCase.kt
class GetCursosUseCase(
    private val repository: CursoRepository
) {
    suspend operator fun invoke(categoria: Categoria? = null): Result<List<Curso>> =
        repository.getCursos(categoria)
}

class MarcarLeccionCompletadaUseCase(
    private val repository: CursoRepository
) {
    suspend operator fun invoke(leccionId: String, userId: String): Result<Progreso> =
        repository.marcarLeccionCompletada(leccionId, userId)
}

class ObservarProgresoUseCase(
    private val repository: CursoRepository
) {
    operator fun invoke(userId: String): Flow<List<Progreso>> =
        repository.observarProgreso(userId)
}

// ─────────────────────────────────────────────────
// domain/repository/CursoRepository.kt
interface CursoRepository {
    suspend fun getCursos(categoria: Categoria? = null): Result<List<Curso>>
    suspend fun getCursoPorId(id: String): Result<Curso>
    suspend fun marcarLeccionCompletada(leccionId: String, userId: String): Result<Progreso>
    fun observarProgreso(userId: String): Flow<List<Progreso>>
}
Capa Data — Room + Ktor + Mappers
CursoRepositoryImpl.kt
Room Entities + DAO
Ktor DataSource
data/src/main/kotlin/ia/cultiva/data/repository/CursoRepositoryImpl.kt
class CursoRepositoryImpl(
    private val localDs: CursoLocalDataSource,
    private val remoteDs: CursoRemoteDataSource
) : CursoRepository {

    override suspend fun getCursos(categoria: Categoria?): Result<List<Curso>> =
        runCatching {
            // 1. Fetch remoto y cachear en Room
            val dtos = remoteDs.fetchCursos(categoria?.name)
            localDs.upsertCursos(dtos.map { it.toEntity() })
            // 2. Leer de local (fuente de verdad)
            localDs.getCursos(categoria?.name).map { it.toDomain() }
        }

    override suspend fun getCursoPorId(id: String): Result<Curso> =
        runCatching { localDs.getCursoPorId(id).toDomain() }

    override suspend fun marcarLeccionCompletada(
        leccionId: String, userId: String
    ): Result<Progreso> = runCatching {
        localDs.marcarCompletada(leccionId)
        val progreso = localDs.getProgreso(userId, /*cursoId derivado*/ leccionId.substringBefore("-"))
        progreso.toDomain()
    }

    override fun observarProgreso(userId: String): Flow<List<Progreso>> =
        localDs.observarProgresoFlow(userId).map { list -> list.map { it.toDomain() } }
}

// ─── Room Entities ─────────────────────────────────
@Entity(tableName = "cursos")
data class CursoEntity(
    @PrimaryKey val id: String,
    val titulo: String,
    val descripcion: String,
    val categoria: String,
    val dificultad: String,
    val duracionMin: Int
)

@Entity(tableName = "lecciones")
data class LeccionEntity(
    @PrimaryKey val id: String,
    val cursoId: String,
    val titulo: String,
    val ordenIndex: Int,
    val completada: Boolean = false
)

@Dao
interface CursoDao {
    @Query("SELECT * FROM cursos WHERE (:cat IS NULL OR categoria = :cat)")
    suspend fun getCursos(cat: String?): List<CursoEntity>

    @Upsert
    suspend fun upsertCursos(cursos: List<CursoEntity>)

    @Query("UPDATE lecciones SET completada = 1 WHERE id = :id")
    suspend fun marcarCompletada(id: String)

    @Query("SELECT * FROM lecciones WHERE cursoId = :cursoId")
    fun observarLecciones(cursoId: String): Flow<List<LeccionEntity>>
}

// ─── Mappers (en data layer, nunca en domain) ──────
fun CursoEntity.toDomain() = Curso(
    id = id, titulo = titulo, descripcion = descripcion,
    categoria = Categoria.valueOf(categoria),
    dificultad = Dificultad.valueOf(dificultad),
    duracionMin = duracionMin
)
fun CursoDto.toEntity() = CursoEntity(id, titulo, descripcion, categoria, dificultad, duracionMin)

// ─── Ktor Remote DataSource ────────────────────────
class CursoRemoteDataSource(private val client: HttpClient) {
    suspend fun fetchCursos(categoria: String?): List<CursoDto> =
        client.get("v1/cursos") {
            categoria?.let { parameter("categoria", it) }
        }.body()
}

val httpClient = HttpClient {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(Logging) { level = LogLevel.HEADERS }
    defaultRequest { url("https://api.cultivaaprende.com/") }
}
CursosViewModel.kt — Presentation
StateFlow · coroutines · sin lógica de negocio
@HiltViewModel
class CursosViewModel @Inject constructor(
    private val getCursos: GetCursosUseCase,
    private val observarProgreso: ObservarProgresoUseCase,
    private val marcarLeccion: MarcarLeccionCompletadaUseCase
) : ViewModel() {

    private val _state = MutableStateFlow(CursosUiState())
    val state = _state.asStateFlow()

    fun cargarCursos(categoria: Categoria? = null) {
        viewModelScope.launch {
            _state.update { it.copy(cargando = true) }
            getCursos(categoria)
                .onSuccess { cursos ->
                    _state.update { it.copy(cursos = cursos, cargando = false) }
                }
                .onFailure { err ->
                    _state.update { it.copy(error = err.message, cargando = false) }
                }
        }
    }

    fun marcarCompletada(leccionId: String) {
        viewModelScope.launch {
            marcarLeccion(leccionId, "user-001")
                .onSuccess { progreso ->
                    _state.update { it.copy(progreso = progreso) }
                }
        }
    }
}

data class CursosUiState(
    val cursos: List<Curso> = emptyList(),
    val progreso: Progreso? = null,
    val cargando: Boolean = false,
    val error: String? = null
)
Koin DI Modules — :app
Wiring de todas las capas en un solo punto
// domain/src/.../di/domainModule.kt
val domainModule = module {
    factory { GetCursosUseCase(get()) }
    factory { MarcarLeccionCompletadaUseCase(get()) }
    factory { ObservarProgresoUseCase(get()) }
}

// data/src/.../di/dataModule.kt
val dataModule = module {
    single<CursoRepository> {
        CursoRepositoryImpl(get(), get())
    }
    single { CursoLocalDataSource(get()) }
    single { CursoRemoteDataSource(get()) }
    single { httpClient }
    single {
        Room.databaseBuilder(
            get(), CultivaDatabase::class.java, "cultiva.db"
        ).build()
    }
}

// presentation/src/.../di/presentationModule.kt
val presentationModule = module {
    viewModelOf(::CursosViewModel)
    viewModelOf(::DashboardViewModel)
}

// app/src/.../CultivaAprende.kt
class CultivaAprende : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@CultivaAprende)
            modules(domainModule, dataModule, presentationModule)
        }
    }
}
Anti-patrones a evitar — Cultiva Aprende
🚫
Android en :domain
Nunca import android.* en domain. Si necesitas Context, pásalo desde data/app vía interfaz abstracta.
🚫
Entities en UI
Nunca exponer CursoEntity o CursoDto a Compose. Siempre mapear a Curso (domain model) antes de llegar a ViewModel.
🚫
Lógica en ViewModel
El ViewModel solo orquesta UseCases y actualiza UiState. Ningún cálculo de negocio: eso va en GetCursosUseCase.
🚫
GlobalScope
Siempre viewModelScope en ViewModels y coroutineScope estructurado en tests. GlobalScope causa leaks.
🚫
Repository gordo
Si CursoRepositoryImpl crece demasiado, extrae CursoLocalDataSource y CursoRemoteDataSource como clases separadas.
🚫
Dependencias circulares
Si :data importa :presentation o viceversa → diseño roto. Usa :core para tipos compartidos, nunca bidireccional.
Grafo de dependencias de módulos Gradle
:app
entry point
→ depende de
app → presentation, domain, data, core
presentation → domain, design-system, core
data → domain, core
domain → core (o ninguno)
core → (nada)
:presentation
Compose + VM
:data
Room + Ktor
:domain ★
Kotlin puro
:core
base types
:design-system
Compose UI