El problema con los templates
Buscás "Android project structure" y encontrás el mismo diagrama en todas partes: carpetas data/, domain/, presentation/. Lo copiás, lo pegás, y tres meses después tenés un proyecto que técnicamente tiene Clean Architecture pero en la práctica es un desastre porque nadie entendió las reglas.
El problema no es la estructura — es que los tutoriales muestran el qué sin explicar el por qué. Y sin entender el por qué, la primera vez que enfrentás una decisión que el template no cubre, tomás el camino equivocado.
Este artículo va al revés: primero las preguntas, después la estructura.
La primera decisión: ¿por capa o por feature?
Hay dos formas de organizar los paquetes de una app y la tensión entre ellas es real.
Por capa — el enfoque clásico
ar.pensa.miapp/
├── data/
│ ├── local/ ← Room, DataStore
│ ├── remote/ ← Retrofit, DTOs
│ └── repository/ ← implementaciones
├── domain/
│ ├── model/ ← modelos de negocio
│ ├── repository/ ← interfaces
│ └── usecase/ ← use cases
└── presentation/
├── productos/ ← VM + Fragment de productos
├── checkout/ ← VM + Fragment de checkout
└── perfil/ ← VM + Fragment de perfil
Las capas son claramente visibles. Los límites técnicos están bien definidos. Pero cuando trabajás en una feature nueva — digamos, el carrito de compras — abrís archivos en cuatro carpetas distintas simultáneamente: data/remote/CarritoDto, data/repository/CarritoRepositoryImpl, domain/model/Carrito, domain/usecase/AgregarAlCarritoUseCase, presentation/carrito/CarritoViewModel.
Por feature — el enfoque moderno
ar.pensa.miapp/
├── feature/
│ ├── productos/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ ├── checkout/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── perfil/
│ ├── data/
│ ├── domain/
│ └── presentation/
└── core/
├── network/ ← OkHttp, Retrofit base
├── database/ ← Room base
└── ui/ ← componentes compartidos
Todo lo relacionado con una feature está junto. Cuando trabajás en el carrito, todo está en feature/checkout/. Más fácil de navegar. Más fácil de extraer a un módulo separado en el futuro.
La respuesta honestaPara apps pequeñas o medianas con un solo desarrollador, la diferencia es mínima. Por capas es más simple de empezar. Por features escala mejor y es más fácil de convertir en multimodulo después. Si el proyecto va a crecer, empezá por features desde el principio.
La estructura que uso como punto de partida
Esta es la estructura concreta que recomiendo para un proyecto nuevo de tamaño mediano. No es dogma — es un punto de partida razonado:
ar.pensa.miapp/
├── app/
│ ├── MiApp.kt ← Application class (@HiltAndroidApp)
│ └── MainActivity.kt ← single Activity container
│
├── core/
│ ├── network/
│ │ ├── NetworkModule.kt ← Hilt: OkHttp, Retrofit
│ │ ├── ApiResult.kt ← sealed class para resultados de red
│ │ └── NetworkMonitor.kt ← observar conectividad
│ ├── database/
│ │ ├── AppDatabase.kt
│ │ └── DatabaseModule.kt
│ ├── ui/
│ │ ├── components/ ← Composables o Views reutilizables
│ │ └── theme/ ← MaterialTheme, colores, tipografía
│ └── common/
│ ├── extensions/ ← extension functions compartidas
│ └── utils/ ← DateUtils, etc.
│
└── feature/
├── productos/
│ ├── data/
│ │ ├── remote/
│ │ │ ├── ProductoApi.kt
│ │ │ └── ProductoDto.kt
│ │ ├── local/
│ │ │ ├── ProductoDao.kt
│ │ │ └── ProductoEntity.kt
│ │ ├── mapper/
│ │ │ └── ProductoMapper.kt
│ │ └── repository/
│ │ └── ProductoRepositoryImpl.kt
│ ├── domain/
│ │ ├── model/
│ │ │ └── Producto.kt
│ │ ├── repository/
│ │ │ └── ProductoRepository.kt ← interfaz
│ │ └── usecase/
│ │ ├── GetProductosUseCase.kt
│ │ └── BuscarProductosUseCase.kt
│ ├── presentation/
│ │ ├── lista/
│ │ │ ├── ProductosViewModel.kt
│ │ │ ├── ProductosFragment.kt ← o ProductosScreen.kt en Compose
│ │ │ └── ProductosUiState.kt
│ │ └── detalle/
│ │ ├── DetalleViewModel.kt
│ │ └── DetalleFragment.kt
│ └── di/
│ └── ProductosModule.kt ← Hilt module para esta feature
└── auth/
├── data/ ...
├── domain/ ...
└── presentation/ ...
Las capas en detalle — qué va dónde
core/ — lo compartido entre features
Todo lo que múltiples features necesitan y que no pertenece a ninguna en particular. La regla: si solo una feature lo usa, va en esa feature. Si dos o más features lo necesitan, va en core/.
Lo que típicamente vive en core: configuración de red (OkHttp, Retrofit base), base de datos (AppDatabase), tema visual, extension functions de uso general, NetworkMonitor, AnalyticsTracker.
domain/ — sin Android, sin librerías
Esta es la capa más importante y la más ignorada. La regla que la define: ningún import de Android en el domain. Sin Context, sin LiveData, sin Flow de AndroidX, sin Room, sin Retrofit. Solo Kotlin puro.
// ✓ domain/model/Producto.kt — Kotlin puro
data class Producto(
val id: Int,
val nombre: String,
val precio: Double,
val disponible: Boolean
) {
val precioConIva: Double get() = precio * 1.21
}
// ✓ domain/repository/ProductoRepository.kt — interfaz pura
// Usa Flow de kotlinx.coroutines (no de AndroidX)
interface ProductoRepository {
fun getProductos(): Flow<List<Producto>>
suspend fun getProducto(id: Int): Producto?
suspend fun guardar(producto: Producto)
}
// ✓ domain/usecase/GetProductosUseCase.kt
class GetProductosUseCase @Inject constructor(
private val repository: ProductoRepository
) {
operator fun invoke(): Flow<List<Producto>> =
repository.getProductos()
.map { lista -> lista.filter { it.disponible } }
.map { lista -> lista.sortedBy { it.nombre } }
}
data/ — implementaciones concretas
Acá viven Room, Retrofit, DataStore. Solo saben de su fuente de datos — no saben nada de la presentación ni de la lógica de negocio.
// data/mapper/ProductoMapper.kt
// El mapper es la frontera entre capas
fun ProductoEntity.toDomain() = Producto(
id = id, nombre = nombre,
precio = precio, disponible = stock > 0
)
fun ProductoDto.toDomain() = Producto(
id = id, nombre = productName, // nombre diferente en la API
precio = unitPrice, disponible = stockCount > 0
)
fun Producto.toEntity() = ProductoEntity(
id = id, nombre = nombre,
precio = precio, stock = if (disponible) 1 else 0
)
presentation/ — solo UI y ViewModels
Los ViewModels solo coordinan — reciben acciones de la UI, llaman use cases o el repositorio, y exponen un UiState. Los Fragments/Activities/Screens solo observan el UiState y mandan acciones.
Convenciones de nombres que evitan confusión
Los nombres importan más de lo que parece. Un proyecto donde todos nombran las cosas igual es mucho más fácil de navegar que uno donde cada archivo tiene su propia convención.
# Modelos — sufijo por capa para evitar ambigüedad
Producto ← modelo de dominio (sin sufijo)
ProductoEntity ← entidad de Room
ProductoDto ← DTO de Retrofit (Data Transfer Object)
ProductoUiModel ← si necesitás transformar para la UI (raro con data classes)
# Repositorios
ProductoRepository ← interfaz (en domain/)
ProductoRepositoryImpl ← implementación (en data/)
# Use Cases — nombre de acción
GetProductosUseCase
BuscarProductosUseCase
AgregarAlCarritoUseCase
EliminarProductoUseCase
# ViewModels
ProductosViewModel ← para la lista
DetalleViewModel ← para el detalle
# UI State — junto al ViewModel que lo produce
ProductosUiState
DetalleUiState
# Fragments / Screens
ProductosFragment (View System)
ProductosScreen (Compose)
# Módulos Hilt
ProductosModule ← en feature/productos/di/
NetworkModule ← en core/network/
El sufijo Impl es importanteTener ProductoRepository (interfaz) y ProductoRepositoryImpl (implementación) en el mismo proyecto puede parecer redundante cuando solo hay una implementación. Pero fuerza la separación mental y hace trivial agregar una segunda implementación (fake para testing, in-memory para debug).
¿Cuándo modularizar en múltiples módulos de Gradle?
Esta es la pregunta que más confusión genera. Modularizar (dividir en módulos de Gradle separados) no es lo mismo que organizar en paquetes. Los paquetes son gratis — los módulos de Gradle tienen un costo real.
Costos de modularizar:
- Build más complejo y más lento de configurar inicialmente
- Más archivos
build.gradlepara mantener - Las dependencias entre módulos se hacen explícitas (es también una ventaja, pero tiene fricción)
- Hilt necesita configuración específica para módulos separados
Beneficios reales de modularizar:
- Build incremental: solo se recompila el módulo que cambió
- Límites de visibilidad forzados por el compilador
- Posibilidad de Dynamic Feature Modules para AAB
- Reutilización entre apps (ej: un módulo
:core:networkcompartido)
# La regla práctica:
# App pequeña (1-3 devs, < 50 pantallas): NO modularices
# App mediana (4-10 devs, 50-100 pantallas): modularizá core/
# App grande (> 10 devs, > 100 pantallas): modularizá por feature
# Señales de que es hora de modularizar:
# → El build tarda más de 2 minutos en clean build
# → Cambios en una feature recompilan toda la app
# → Hay reglas de visibilidad que se violan constantemente
# → Necesitás Dynamic Feature Modules
La estructura multimodulo cuando llegue el momento
miapp/ ← root project
├── app/ ← módulo :app (solo glue code)
│ └── build.gradle
├── core/
│ ├── network/ ← módulo :core:network
│ ├── database/ ← módulo :core:database
│ ├── ui/ ← módulo :core:ui
│ └── testing/ ← módulo :core:testing (fakes compartidos)
├── feature/
│ ├── productos/ ← módulo :feature:productos
│ ├── checkout/ ← módulo :feature:checkout
│ └── auth/ ← módulo :feature:auth
├── build.gradle ← configuración del root project
├── settings.gradle ← declara todos los módulos
└── gradle/
└── libs.versions.toml ← Version Catalog (versiones centralizadas)
// settings.gradle — declarar todos los módulos
include(":app")
include(":core:network")
include(":core:database")
include(":core:ui")
include(":feature:productos")
include(":feature:checkout")
// :app/build.gradle — solo depende de las features
dependencies {
implementation(project(":feature:productos"))
implementation(project(":feature:checkout"))
implementation(project(":feature:auth"))
}
// :feature:productos/build.gradle — depende de core, no de otras features
dependencies {
implementation(project(":core:network"))
implementation(project(":core:database"))
// NO: implementation(project(":feature:checkout")) ← features no dependen entre sí
}
// Si dos features necesitan comunicarse, usan el :core o el :app como mediador
Las features no se importan entre síEsta es la regla más importante de la modularización. Si :feature:checkout necesita datos de :feature:productos, esos datos deben pasar por el :app (via Navigation arguments) o por un módulo :core:shared. Si dos features se importan mutuamente, tenés un ciclo de dependencias y el build falla.
Los errores más comunes al estructurar
1. ViewModel en la capa de datos
// ❌ MAL — el ViewModel accede directamente al DAO
class ProductosViewModel : ViewModel() {
private val dao = AppDatabase.getInstance(app).productoDao()
// El ViewModel conoce Room — imposible de testear sin Room
}
// ✓ BIEN — el ViewModel solo conoce la interfaz
class ProductosViewModel @Inject constructor(
private val useCase: GetProductosUseCase // o el Repository directamente
) : ViewModel()
2. Lógica de negocio en el Fragment
// ❌ MAL — el Fragment decide qué mostrar
override fun onViewCreated(...) {
viewModel.productos.collect { lista ->
val disponibles = lista.filter { it.stock > 0 } // lógica de negocio en UI
val ordenados = disponibles.sortedBy { it.precio }
adapter.submitList(ordenados)
}
}
// ✓ BIEN — el Fragment solo renderiza lo que le llega
override fun onViewCreated(...) {
viewModel.uiState.collect { state ->
adapter.submitList(state.productos) // ya vienen filtrados y ordenados
}
}
3. Mezclar modelos entre capas
// ❌ MAL — la Entity de Room llega a la UI
class ProductosViewModel : ViewModel() {
val productos: StateFlow<List<ProductoEntity>> // Room en la capa de presentación
}
// ✓ BIEN — el mapper convierte antes de llegar al ViewModel
class ProductoRepositoryImpl : ProductoRepository {
override fun getProductos(): Flow<List<Producto>> =
dao.getAll().map { entities -> entities.map { it.toDomain() } }
// ↑ convierte acá
}
4. Over-engineering en proyectos pequeños
Use Cases que solo llaman al repositorio sin agregar lógica no aportan valor — solo agregan indirección.
// ❌ Sobre-engineerizado para una app simple
class GetProductosUseCase @Inject constructor(
private val repository: ProductoRepository
) {
operator fun invoke() = repository.getProductos()
// No agrega nada. El ViewModel podría llamar al repo directamente.
}
// ✓ Use Case con lógica real
class GetProductosDisponiblesOrdenadosUseCase @Inject constructor(
private val repository: ProductoRepository
) {
operator fun invoke(categoria: String?): Flow<List<Producto>> =
repository.getProductos()
.map { lista ->
lista
.filter { it.disponible }
.let { if (categoria != null) it.filter { p -> p.categoria == categoria } else it }
.sortedBy { it.nombre }
}
// Combina filtrado + filtro por categoría + ordenamiento. Vale la abstracción.
}
Checklist antes de escribir la primera línea
# ── DECISIONES DE ARQUITECTURA ──────────────────────────────
# ✓ ¿Por feature o por capa? (recomendado: por feature si > 5 features)
# ✓ ¿Un módulo Gradle o múltiples? (recomendado: uno hasta 50+ pantallas)
# ✓ ¿Use Cases o directo al Repository? (solo si hay lógica real que encapsular)
# ✓ ¿Compose o View System? (nuevo proyecto: Compose)
# ✓ ¿Hilt o Koin? (equipo profesional: Hilt)
# ── CONFIGURACIÓN INICIAL ────────────────────────────────────
# ✓ Version Catalog configurado (gradle/libs.versions.toml)
# ✓ .gitignore con local.properties, *.keystore, google-services.json
# ✓ buildSrc o convention plugins para lógica de build compartida
# ✓ ktlint o detekt configurado desde el principio (no desde el día 30)
# ── PAQUETE BASE Y NAMING ────────────────────────────────────
# ✓ Package name definido con la convención inversa (ar.pensa.miapp)
# ✓ Convención de nombres acordada con el equipo
# ✓ Estructura de carpetas creada aunque estén vacías
# ── DEPENDENCIAS BASE ────────────────────────────────────────
# ✓ Hilt configurado y funcionando con una inyección de prueba
# ✓ Navigation Component con el primer NavGraph
# ✓ Room con la primera Entity de prueba (mejor descubrir problemas ahora)
# ✓ Retrofit configurado apuntando al ambiente correcto (dev/staging/prod)
# ── TESTING DESDE EL DÍA 1 ──────────────────────────────────
# ✓ Estructura de test replicando la estructura de main/
# ✓ Un FakeRepository de ejemplo para ver el patrón
# ✓ Un test del ViewModel de ejemplo corriendo verde
Configurá ktlint desde el día 1Agregar un linter cuando el proyecto tiene 50 archivos significa arreglar 200 warnings de una vez. Desde el inicio, cada commit ya cumple el estilo acordado. El costo de configurarlo al inicio es 30 minutos. El costo de hacerlo después es una tarde entera.