Clean Architecture — una arquitectura en capas propuesta por Robert Martin (Uncle Bob) en 2012, que divide una aplicación en capas independientes: Domain (Entities, Use Cases), Data (Repositories, DataSources) y Presentation (ViewModels, Views). El principio principal es la Dependency Rule: las dependencias apuntan hacia adentro, las capas externas dependen de las internas, pero no al revés. Clean Architecture se utiliza en el desarrollo móvil para proyectos con alta complejidad de lógica de negocio. Más detalles en el libro The Clean Architecture.
Puntos Clave
Clean Architecture — un patrón arquitectónico formulado por Robert Martin (Uncle Bob) en 2012. La idea central es dividir una aplicación en capas con una regla estricta de dependencias: el código dentro de una capa no sabe nada del código exterior. Las capas externas (UI, frameworks, BD) son detalles de implementación. Las capas internas (lógica de negocio, reglas empresariales) son la esencia de la aplicación.
Capas de Clean Architecture en el desarrollo móvil: 1) Domain — Entities (objetos de negocio) y Use Cases (escenarios de uso); 2) Data — RepositoryImpl (implementaciones de repositorios), DataSources (red, BD, caché); 3) Presentation — ViewModels, Views (Compose/SwiftUI). Domain es la capa más interna sin dependencias. Data depende de Domain (implementa interfaces de repositorio). Presentation depende de Domain (llama a Use Cases, se suscribe a resultados).
| Capa | Contiene | Dependencias |
|---|---|---|
| Domain | Entities, Use Cases, Repository Interfaces | Ninguna (Kotlin/Swift puro) |
| Data | RepositoryImpl, DataSources (API, BD, Caché) | Domain, Retrofit, Room, Ktor |
| Presentation | ViewModels, Views, Composables | Domain, Jetpack, SwiftUI |
Dependency Rule — la única regla estricta de Clean Architecture. El código fuente solo puede referenciar una capa dentro de sí misma o una capa inferior (más cercana al centro). Presentation importa Domain. Domain NO importa Data ni Presentation. Esto se logra a través del Principio de Inversión de Dependencias: Domain define la interfaz Repository, Data la implementa. Presentation depende de la abstracción UseCase, no de un repositorio específico.
Domain — la capa más estable de la aplicación. Entities son objetos de negocio independientes de frameworks: User, Product, Order. Use Cases son clases con un único método invoke (u operator fun invoke en Kotlin), que implementan un escenario: GetUserUseCase, PlaceOrderUseCase, CalculateTotalUseCase. Repository Interfaces son abstracciones de acceso a datos definidas en Domain, implementadas en Data. Domain no contiene Android SDK, iOS UIKit, Retrofit, Room — solo Kotlin o Swift puro.
// Entity — objeto de negocio (Domain)
struct User: Equatable {
let id: Int
let name: String
let email: String
}
// Repository Interface — abstracción de datos (Domain)
protocol UserRepository {
func getUser(id: Int) async throws -> User
func getUsers() async throws -> [User]
}
// Use Case — un escenario (Domain)
final class GetUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: Int) async throws -> User {
return try await repository.getUser(id: id)
}
}
Use Case — «una clase con un método» no es un dogma sino una recomendación práctica. Cuando un Use Case se vuelve más complejo (validación + registro + llamada al repositorio), sus métodos se agrupan por significado: UserUseCase.getUser, UserUseCase.searchUsers, UserUseCase.deleteUser. Lo principal es que un Use Case no debe saber de dónde vienen los datos (red, BD, caché) ni quién los muestra (Compose, SwiftUI). En IT Sectr asignamos un Use Case para cada operación que tenga una regla de negocio, validación o combinación de datos de dos fuentes.
Pureza del Domain se logra mediante el mapeo DTO en los límites de las capas. La capa Data recibe modelos JSON (DTO), los mapea a Entities de Domain. Presentation recibe Entities de Domain, los mapea a ViewModels (DisplayItem). Una Entity de Domain nunca contiene anotaciones de Retrofit, Room o Codable — esto garantiza que la capa no necesitará cambios al cambiar de BD de Room a Realm o al reemplazar Retrofit por Ktor.
Data Layer — implementación de las interfaces definidas en Domain. Contiene RepositoryImpl (clases que implementan UserRepository) y DataSources (RemoteDataSource — API, LocalDataSource — BD, CacheDataSource — SharedPreferences/NSUserDefaults). La capa Data depende de Domain (importa interfaces de repositorio y Entities) y de frameworks (Retrofit, Room, Ktor, CoreData). RepositoryImpl oculta la fuente de datos a Domain — el Use Case no sabe si los datos vinieron de la red o del caché.
// DTO — modelo para red (Data)
data class UserDto(
@SerializedName("id") val id: Int,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
@SerializedName("email") val email: String
)
// RepositoryImpl — implementación (Data)
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
override suspend fun getUser(id: Int): User {
// Intentando obtener de caché
localDataSource.getUser(id)?.let { return it.toDomain() }
// Si no — cargar desde red
val dto = remoteDataSource.fetchUser(id)
val user = dto.toDomain()
localDataSource.saveUser(user)
return user
}
override suspend fun getUsers(): List<User> {
return remoteDataSource.fetchAllUsers().map { it.toDomain() }
}
}
// Mapper — conversión DTO ↔ Domain
fun UserDto.toDomain() = User(
id = id,
name = "$firstName $lastName",
email = email
)
Estrategia de Caché en Data Layer: RepositoryImpl primero verifica el almacenamiento local; si no hay datos, carga desde la red y guarda localmente. Si la red no está disponible, devuelve datos obsoletos con una marca isStale. El Use Case en Domain no sabe de la estrategia — recibe User mediante Repository.getUser(id). Cambiar la estrategia (por ejemplo, invalidación de caché cada 15 minutos) no afecta a Domain ni a Presentation.
Modularidad en Android — Kotlin Multiplatform permite extraer Domain en un módulo KMP separado sin dependencias de Android SDK. Data es un módulo separado con dependencia de Domain. Presentation es un módulo de Android con dependencia de Domain. Dependencias de Gradle: domain (Kotlin puro), data (domain + Retrofit + Room), app (domain + presentation + Hilt). Tal modularidad es esencial para proyectos grandes — CI compila Domain por separado, los tests unitarios de Domain no requieren un emulador de Android.
Presentation Layer — la capa más externa de Clean Architecture. Contiene ViewModels (Android) / ObservableObject (iOS) y Views (Compose/SwiftUI). El ViewModel llama a un Use Case, recibe el resultado y lo transforma en estado de UI. La View se suscribe al Estado y lo renderiza. Presentation depende de Domain — importa Use Cases y Entities. Presentation no importa la capa Data — los datos llegan a través del Use Case, que internamente usa un Repository.
ViewModel en Clean Architecture no contiene lógica de negocio — llama al Use Case. Si un Use Case devuelve User, el ViewModel lo transforma en UserDisplayItem (name, emailFormatted, avatarUrl) — un modelo puramente de presentación. El Use Case no sabe de DisplayItem — devuelve una Entity. Esta separación permite probar el Use Case sin la UI y el ViewModel sin el UseCase (mediante mocks). En IT Sectr seguimos estrictamente: Use Case — lógica de negocio, ViewModel — solo presentación, View — solo visualización.
// Use Case (Domain) — lógica de negocio pura
class GetUserUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(id: Int): User {
return repository.getUser(id)
}
}
// ViewModel (Presentation) — solo presentación
class UserViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow<UserScreenState>(UserScreenState.Loading)
val state: StateFlow<UserScreenState> = _state.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_state.value = UserScreenState.Loading
val user = getUserUseCase(id)
val displayItem = UserDisplayItem(
name = user.name,
email = user.email,
initials = user.name.split(" ").joinToString("") { it.first().toString() }
)
_state.value = UserScreenState.Success(displayItem)
}
}
}
data class UserDisplayItem(
val name: String,
val email: String,
val initials: String
)
sealed interface UserScreenState {
data object Loading : UserScreenState
data class Success(val displayItem: UserDisplayItem) : UserScreenState
data class Error(val message: String) : UserScreenState
}
Navegación en la capa Presentation también es parte del anillo exterior. Clean Architecture no prescribe un mecanismo de navegación — puede ser NavController (Compose), NavigationStack (SwiftUI), Coordinator (UIKit) o Router (VIPER). Es importante que las decisiones de navegación las toma Presentation, pero la navegación no debe penetrar en el Use Case. El Use Case devuelve un resultado, el ViewModel decide a qué pantalla navegar. En Clean Architecture, la navegación es un detalle que puede reemplazarse sin cambiar el Domain.
Clean Architecture en Android se implementa mediante módulos de Gradle: domain (Kotlin puro), data (domain + Retrofit + Room), presentation (domain + Compose). Estructura de carpetas: domain/user/User.kt, GetUserUseCase.kt, UserRepository.kt; data/remote/UserRemoteDataSource.kt, local/UserDao.kt, repository/UserRepositoryImpl.kt; presentation/ui/user/UserViewModel.kt, UserScreen.kt. DI (Hilt) conecta las capas: UserRepositoryImpl se vincula a la interfaz UserRepository en el módulo domain.
Clean Architecture en iOS usa SPM o grupos de Xcode sin módulos separados (debido a limitaciones de Xcode). Domain — una carpeta con archivos que no importan UIKit ni SwiftUI. Data — una carpeta con APIClient, CoreDataStack, RepositoryImpl. Presentation — una carpeta con ViewModels y SwiftUI Views. DI mediante constructor o ensamblaje en la App. La llamada principal es async/await a través de UseCase.execute() con verificación de MainActor para actualizaciones de UI.
// Data Layer: Remote DataSource (iOS)
final class UserRemoteDataSource {
private let apiClient: APIClient
func fetchUser(id: Int) async throws -> UserDTO {
return try await apiClient.get("/users/\(id)")
}
}
// Repository Implementation (Data)
final class UserRepositoryImpl: UserRepository {
private let remote: UserRemoteDataSource
private let local: UserLocalDataSource
func getUser(id: Int) async throws -> User {
if let cached = try await local.getUser(id) {
return cached
}
let dto = try await remote.fetchUser(id)
let user = dto.toDomain()
try await local.saveUser(user)
return user
}
}
// Presentation: ViewModel + SwiftUI View
@MainActor
final class UserViewModel: ObservableObject {
@Published private(set) var state: UserScreenState = .loading
private let getUserUseCase: GetUserUseCase
func loadUser(id: Int) {
Task {
state = .loading
if let user = try? await getUserUseCase.execute(id: id) {
state = .success(user)
} else {
state = .error("Failed to load")
}
}
}
}
Clean Architecture en proyectos de IT Sectr — nuestro estándar para proyectos a partir de 30 días. Hemos estado usando una arquitectura de tres capas con Kotlin Multiplatform para Android/iOS desde 2022. Domain — un módulo KMP compartido, Data — módulos de plataforma (Retrofit en Android, URLSession en iOS), Presentation — UI nativa. Esto proporciona un 60–80 % de código de lógica de negocio compartido entre iOS y Android, reduciendo el tiempo de desarrollo en un 30–40 % en comparación con dos implementaciones separadas.
Preguntas Frecuentes
Mínimo tres: Domain, Data, Presentation. Para proyectos grandes se añaden Framework (dependencias de Android SDK/iOS UIKit) y Device (GPS, cámara, sensores). El número de capas no es una regla estricta sino una cuestión de conveniencia. Lo principal es seguir la Dependency Rule: las dependencias apuntan hacia adentro, hacia Domain. Puede comenzar con tres y agregar capas a medida que el proyecto crece.
Sí — en un 30–50 % en comparación con MVVM debido a la extracción de interfaces de repositorio, Use Cases y mappers. Esto es excesivo para una aplicación CRUD simple. Clean Architecture se justifica para proyectos con lógica de negocio compleja donde la capacidad de prueba y el aislamiento de capas son más importantes que la velocidad de desarrollo. Para MVP o prototipos, use MVVM — Clean Architecture ralentizará el inicio.
Sí, es una práctica común. Los Use Cases permanecen en Domain, mientras que Presentation usa el ciclo MVI (Intent → Reducer → State). La capa Data sigue siendo la misma, Domain sigue siendo el mismo. MVI en Presentation proporciona un estado de pantalla predecible, Clean Architecture proporciona aislamiento de la lógica de negocio. Esta combinación se utiliza en proyectos grandes con docenas de desarrolladores.
Un Use Case es necesario cuando la operación implica una regla de negocio: validación, combinación de datos de dos fuentes, cálculo, registro, verificación de permisos de acceso. Una simple solicitud getUser(id) sin lógica adicional puede llamar al Repository directamente desde el ViewModel. Sin embargo, para la consistencia arquitectónica, muchos equipos crean un Use Case para cada método público del Repository — esto agrega un 5–10 % de código pero simplifica la lectura.
Domain: Tests unitarios de Use Cases con Repository mock — Kotlin/Swift puro sin Android SDK. Data: Tests de integración de RepositoryImpl con DataSource mock/fake. Presentation: Tests de ViewModel con UseCase mock. Gracias a la Dependency Rule, cada capa se prueba de forma aislada. En IT Sectr, la cobertura de Domain alcanza el 95 %, Data — 70–80 %, Presentation — 60–70 %.
Resumen
Desarrollaremos una aplicación móvil llave en mano
IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.
Lea también