Composable Architecture: Cómo Estructurar Proyectos Grandes en Jetpack Compose

Composable Architecture: Cómo Estructurar Proyectos Grandes en Jetpack Compose

Guía exhaustiva sobre arquitectura de composables para aplicaciones grandes: modularización, separación de concerns, scaling sin caos. Estructura real para apps de 100+ screens con ejemplos prácticos y patrones probados.

Por Omar Flores

Composable Architecture: Cómo Estructurar Proyectos Grandes en Jetpack Compose

Escalando Composables Sin Caos: De Una App Pequeña a 100+ Screens


🎯 Introducción: El Problema Real

Te identificas con esto:

Semana 1:
  - Escribes 2-3 screens en Compose
  - Todo es simple, está en 1-2 archivos
  - La vida es bella

Semana 4:
  - Ahora tienes 8 screens
  - Los composables tienen 500+ líneas
  - Cambiar un pequeño padding afecta 3 screens
  - Nadie quiere tocar el código

Semana 12:
  - 30+ screens
  - 50+ composables
  - Nadie sabe dónde agregar nueva funcionalidad
  - Los PMs preguntan "¿Por qué tarda tanto?"
  - Los developers responden "porque el código es un desastre"

Semana 24:
  - 100+ screens
  - Refactorizar se convirtió en "reescribir todo"
  - El proyecto se siente como un monolito de la era XML
  - "Jetpack Compose no escala", dice alguien que no entiende arquitectura

Aquí está la verdad: Jetpack Compose es increíblemente escalable. Pero solo si estructuras tu código correctamente desde el inicio.

El problema no es Compose. El problema es que la mayoría de developers vienen de era XML/ViewBinding, donde no necesitaban pensar en modularización porque estaban limitados por el framework.

Con Compose, tienes libertad total. Y esa libertad es un arma de doble filo. Si no sabes qué hacer con ella, te destruye.

¿Por Qué Esta Guía Es Diferente?

La mayoría de guías de Compose te enseñan:

✅ "Aquí está un Composable simple"
✅ "Aquí está cómo usar State"
❌ "Aquí está cómo estructurar una app de 100+ screens sin que sea un desastre"

Esta guía existe para cerrar ese vacío.

Vamos a construir una arquitectura de composables que:

Escala sin dolor - Agregar features es tan fácil en la semana 100 como en la semana 1 ✅ Modularización clara - Cada composable sabe qué hace, nada más ✅ Separación de concerns - UI, State, Business Logic están separados ✅ Testeable - Cada composable puede ser testeado sin mocks complejos ✅ Reutilizable - Componentes que escribes hoy se usan en 10 places mañana ✅ Mantenible - Los nuevos developers entienden la estructura en 1 hora

Lo Que Construiremos

Vamos a usar una App de Gestión de Tareas como caso de estudio. No es una app trivial:

Screens:
  - Autenticación (Login, Signup)
  - Dashboard principal
  - Lista de tareas (con filtros, búsqueda, paginación)
  - Detalle de tarea
  - Crear/editar tarea
  - Ajustes de usuario
  - Sincronización offline
  - Notificaciones

Complejidad:
  - 50+ composables
  - 15+ ViewModels
  - State management con Flow/StateFlow
  - Navigation complexity
  - Deep linking
  - Shared state entre screens

🏗️ Parte 1: Los Principios Fundamentales

Principio 1: Composables Deben Ser Pequeños y Enfocados

Regla de oro: Si un composable hace más de una cosa, divídelo.

// ❌ MALO: Un composable hace TODO
@Composable
fun TaskListScreen(
    viewModel: TaskViewModel,
    onNavigateToDetail: (Int) -> Unit
) {
    // Obtener datos
    val tasks by viewModel.tasks.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val searchQuery by viewModel.searchQuery.collectAsState()

    // Renderizar header
    Column {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("Mis Tareas", style = MaterialTheme.typography.headlineSmall)
            IconButton(onClick = { /* ... */ }) {
                Icon(Icons.Default.Settings, contentDescription = null)
            }
        }

        // Search bar
        TextField(
            value = searchQuery,
            onValueChange = { viewModel.onSearchQueryChanged(it) },
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp),
            placeholder = { Text("Buscar tareas...") }
        )

        // Loading indicator
        if (isLoading) {
            CircularProgressIndicator(
                modifier = Modifier
                    .align(Alignment.CenterHorizontally)
                    .padding(32.dp)
            )
        }

        // List de tareas
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 16.dp)
        ) {
            items(tasks.size) { index ->
                val task = tasks[index]
                TaskItem(
                    task = task,
                    onClick = { onNavigateToDetail(task.id) }
                )
                Divider()
            }
        }
    }
}

El problema: Este composable hace 5 cosas:

  1. Gestiona estado (obtiene de ViewModel)
  2. Renderiza header
  3. Renderiza search bar
  4. Renderiza loading indicator
  5. Renderiza lista

Cuando cambia una cosa, todo el composable se rebuilda.

La solución: Dividir en pequeños composables enfocados

// ✅ BUENO: Composables pequeños y enfocados

@Composable
fun TaskListScreen(
    viewModel: TaskViewModel,
    onNavigateToDetail: (Int) -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()

    // Delegamos a composables especializados
    Column(modifier = Modifier.fillMaxSize()) {
        TaskListHeader(
            onSettingsClick = { /* navigate */ }
        )

        TaskSearchBar(
            searchQuery = uiState.searchQuery,
            onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) }
        )

        when {
            uiState.isLoading -> TaskListLoadingIndicator()
            uiState.tasks.isEmpty() -> TaskListEmptyState()
            else -> TaskListContent(
                tasks = uiState.tasks,
                onTaskClick = onNavigateToDetail
            )
        }
    }
}

@Composable
private fun TaskListHeader(onSettingsClick: () -> Unit) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("Mis Tareas", style = MaterialTheme.typography.headlineSmall)
        IconButton(onClick = onSettingsClick) {
            Icon(Icons.Default.Settings, contentDescription = null)
        }
    }
}

@Composable
private fun TaskSearchBar(
    searchQuery: String,
    onSearchQueryChanged: (String) -> Unit
) {
    TextField(
        value = searchQuery,
        onValueChange = onSearchQueryChanged,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        placeholder = { Text("Buscar tareas...") }
    )
}

@Composable
private fun TaskListLoadingIndicator() {
    CircularProgressIndicator(
        modifier = Modifier
            .fillMaxSize()
            .wrapContentSize(Alignment.Center)
    )
}

@Composable
private fun TaskListEmptyState() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .wrapContentSize(Alignment.Center),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Icon(
            imageVector = Icons.Default.TaskAlt,
            contentDescription = null,
            modifier = Modifier.size(64.dp),
            tint = MaterialTheme.colorScheme.outline
        )
        Spacer(modifier = Modifier.height(16.dp))
        Text(
            "No hay tareas",
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.outline
        )
    }
}

@Composable
private fun TaskListContent(
    tasks: List<Task>,
    onTaskClick: (Int) -> Unit
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 16.dp)
    ) {
        items(
            count = tasks.size,
            key = { tasks[it].id }
        ) { index ->
            TaskItem(
                task = tasks[index],
                onClick = { onTaskClick(tasks[index].id) }
            )
            if (index < tasks.size - 1) {
                Divider()
            }
        }
    }
}

Beneficios:

  • TaskListHeader solo se rebuilda si el callback cambia (casi nunca)
  • TaskSearchBar solo se rebuilda si searchQuery cambia
  • TaskListContent solo se rebuilda si tasks cambia
  • Fácil de testear cada parte independientemente

Principio 2: Separar Estado (State) de Presentación (UI)

Regla de oro: El composable no debe saber cómo obtener estado, solo cómo presentarlo.

// ❌ MALO: Composable obtiene estado directamente de ViewModel
@Composable
fun TaskItem(viewModel: TaskViewModel, taskId: Int) {
    val task by viewModel.getTask(taskId).collectAsState()
    val isCompleted by viewModel.isTaskCompleted(taskId).collectAsState()

    Row {
        // UI
    }
}

// ✅ BUENO: Composable recibe estado como parámetro
data class TaskItemState(
    val task: Task,
    val isCompleted: Boolean,
    val isLoading: Boolean = false
)

@Composable
fun TaskItem(
    state: TaskItemState,
    onCompletedChange: (Boolean) -> Unit,
    onClick: () -> Unit
)  {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(enabled = !state.isLoading, onClick = onClick)
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        if (state.isLoading) {
            CircularProgressIndicator(modifier = Modifier.size(24.dp))
        } else {
            Checkbox(
                checked = state.isCompleted,
                onCheckedChange = onCompletedChange
            )

            Column(modifier = Modifier.weight(1f)) {
                Text(
                    state.task.title,
                    style = MaterialTheme.typography.bodyLarge,
                    textDecoration = if (state.isCompleted) TextDecoration.LineThrough else TextDecoration.None
                )
                Text(
                    state.task.description,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.outline
                )
            }
        }
    }
}

¿Por qué esto importa?

Testabilidad:
  ❌ MALO: Necesitas mockear ViewModel, Flow, StateFlow, repositorio...
  ✅ BUENO: Solo pasas un data class, listo.

Reusabilidad:
  ❌ MALO: TaskItem solo funciona con ese ViewModel específico
  ✅ BUENO: TaskItem funciona en cualquier contexto donde tengas TaskItemState

Performance:
  ❌ MALO: Si ViewModel.getTask() o isTaskCompleted() cambian, se rebuilda
  ✅ BUENO: Solo se rebuilda si TaskItemState cambió

Principio 3: Composables Tienen Tres Capas

Cada pantalla debe tener exactamente tres capas de composables:

// CAPA 1: Screen (Conecta ViewModel con UI)
@Composable
fun TaskListScreen(
    viewModel: TaskListViewModel,
    onNavigateToDetail: (Int) -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()

    TaskListContent(
        state = uiState,
        onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
        onTaskClick = { viewModel.onTaskClick(it); onNavigateToDetail(it) }
    )
}

// CAPA 2: Content (Lógica de layout, manejo de estados)
@Composable
private fun TaskListContent(
    state: TaskListUiState,
    onSearchQueryChanged: (String) -> Unit,
    onTaskClick: (Int) -> Unit
) {
    Column(modifier = Modifier.fillMaxSize()) {
        TaskListHeader()
        TaskSearchBar(
            searchQuery = state.searchQuery,
            onSearchQueryChanged = onSearchQueryChanged
        )

        when {
            state.isLoading -> TaskListLoadingIndicator()
            state.tasks.isEmpty() -> TaskListEmptyState()
            else -> TaskListContent(
                tasks = state.tasks,
                onTaskClick = onTaskClick
            )
        }
    }
}

// CAPA 3: Components (UI pura, reusable)
@Composable
fun TaskItem(
    state: TaskItemState,
    onCompletedChange: (Boolean) -> Unit,
    onClick: () -> Unit
) {
    Row {
        // UI pura, sin lógica
    }
}

Las tres capas:

┌─────────────────────────────────────┐
│ Screen Layer                        │
│ - Conecta ViewModel a UI            │
│ - Obtiene estado de ViewModel       │
│ - Maneja navigation                 │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│ Content Layer                       │
│ - Lógica de layout                  │
│ - Manejo de conditional rendering   │
│ - Composición de componentes        │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│ Component Layer                     │
│ - UI pura y reusable               │
│ - Sin estado, solo parámetros      │
│ - Fáciles de testear               │
└─────────────────────────────────────┘

📁 Parte 2: Modularización de Composables

Estructura de Carpetas

La estructura que funciona para 100+ screens:

app/
├── src/
│   └── main/
│       └── kotlin/
│           └── com/example/taskapp/
│               ├── MainActivity.kt
│               ├── navigation/
│               │   ├── NavGraph.kt
│               │   ├── Route.kt
│               │   └── Destinations.kt
│               ├── feature/
│               │   ├── taskList/
│               │   │   ├── TaskListScreen.kt
│               │   │   ├── TaskListViewModel.kt
│               │   │   ├── TaskListUiState.kt
│               │   │   └── components/
│               │   │       ├── TaskItem.kt
│               │   │       ├── TaskListHeader.kt
│               │   │       ├── TaskSearchBar.kt
│               │   │       ├── TaskListEmptyState.kt
│               │   │       └── TaskListLoadingIndicator.kt
│               │   ├── taskDetail/
│               │   │   ├── TaskDetailScreen.kt
│               │   │   ├── TaskDetailViewModel.kt
│               │   │   ├── TaskDetailUiState.kt
│               │   │   └── components/
│               │   │       ├── TaskDetailHeader.kt
│               │   │       ├── TaskContent.kt
│               │   │       ├── TaskActions.kt
│               │   │       └── TaskComments.kt
│               │   ├── auth/
│               │   │   ├── loginScreen/
│               │   │   ├── signupScreen/
│               │   │   └── shared/
│               │   ├── settings/
│               │   └── /* más features */
│               ├── ui/
│               │   ├── components/
│               │   │   ├── AppBar.kt
│               │   │   ├── AppButton.kt
│               │   │   ├── AppCard.kt
│               │   │   ├── AppDivider.kt
│               │   │   └── /* componentes compartidos */
│               │   ├── theme/
│               │   │   ├── Color.kt
│               │   │   ├── Type.kt
│               │   │   ├── Shape.kt
│               │   │   └── Theme.kt
│               │   └── modifiers/
│               │       ├── PaddingModifier.kt
│               │       ├── ElevationModifier.kt
│               │       └── /* modifiers custom */
│               ├── domain/
│               │   ├── model/
│               │   ├── repository/
│               │   └── usecase/
│               ├── data/
│               │   ├── repository/
│               │   ├── datasource/
│               │   └── model/
│               ├── di/
│               │   └── AppModule.kt
│               └── utils/

¿Por qué esta estructura?

feature/taskList/:
  - Todo lo relacionado con task list en un lugar
  - Fácil de encontrar
  - Fácil de eliminar completa si necesitas

feature/*/components/:
  - Componentes específicos de esa feature
  - Si necesitas reutilizar, muévelo a ui/components/
  - Evita duplicación

ui/:
  - Componentes compartidos por todas las features
  - Design system centralizado
  - Cambios de theme en un lugar

Feature Module Organization

Cada feature (taskList, taskDetail, auth, etc.) debe tener esta estructura:

feature/taskList/
├── TaskListScreen.kt          ← Screen layer
├── TaskListViewModel.kt        ← State management
├── TaskListUiState.kt          ← UI state contract
├── TaskListIntent.kt           ← User intents (opcional)
├── TaskListEvent.kt            ← UI events (opcional)
└── components/                 ← Content + Component layers
    ├── TaskItem.kt
    ├── TaskListHeader.kt
    ├── TaskSearchBar.kt
    ├── TaskListEmptyState.kt
    └── TaskListLoadingIndicator.kt

TaskListUiState.kt:

// ✅ BUENO: Un estado único para toda la feature
data class TaskListUiState(
    val tasks: List<Task> = emptyList(),
    val searchQuery: String = "",
    val isLoading: Boolean = false,
    val error: String? = null,
    val selectedTask: Int? = null,
    val sortBy: SortOption = SortOption.RECENT,
    val filterBy: FilterOption = FilterOption.ALL
)

enum class SortOption {
    RECENT, PRIORITY, ALPHABETICAL
}

enum class FilterOption {
    ALL, COMPLETED, PENDING
}

TaskListViewModel.kt:

@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val getTasksUseCase: GetTasksUseCase,
    private val completeTaskUseCase: CompleteTaskUseCase,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _uiState = MutableStateFlow(TaskListUiState())
    val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()

    private val _navigationEvent = Channel<TaskListNavigationEvent>()
    val navigationEvent: Flow<TaskListNavigationEvent> = _navigationEvent.receiveAsFlow()

    init {
        loadTasks()
    }

    fun onSearchQueryChanged(query: String) {
        _uiState.update { it.copy(searchQuery = query) }
        filterTasks()
    }

    fun onTaskClick(taskId: Int) {
        viewModelScope.launch {
            _navigationEvent.send(TaskListNavigationEvent.NavigateToDetail(taskId))
        }
    }

    fun onCompleteTask(taskId: Int) {
        viewModelScope.launch {
            try {
                completeTaskUseCase(taskId)
                loadTasks()
            } catch (e: Exception) {
                _uiState.update { it.copy(error = e.message) }
            }
        }
    }

    private fun loadTasks() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val tasks = getTasksUseCase()
                _uiState.update {
                    it.copy(
                        tasks = tasks,
                        isLoading = false
                    )
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(
                        error = e.message,
                        isLoading = false
                    )
                }
            }
        }
    }

    private fun filterTasks() {
        viewModelScope.launch {
            val filtered = getTasksUseCase()
                .filter { task ->
                    task.title.contains(_uiState.value.searchQuery, ignoreCase = true)
                }
            _uiState.update { it.copy(tasks = filtered) }
        }
    }
}

sealed class TaskListNavigationEvent {
    data class NavigateToDetail(val taskId: Int) : TaskListNavigationEvent()
}

TaskListScreen.kt:

@Composable
fun TaskListScreen(
    viewModel: TaskListViewModel = hiltViewModel(),
    onNavigateToDetail: (Int) -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()

    LaunchedEffect(Unit) {
        viewModel.navigationEvent.collect { event ->
            when (event) {
                is TaskListNavigationEvent.NavigateToDetail -> {
                    onNavigateToDetail(event.taskId)
                }
            }
        }
    }

    TaskListContent(
        state = uiState,
        onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
        onTaskClick = { viewModel.onTaskClick(it) },
        onCompleteTask = { viewModel.onCompleteTask(it) }
    )
}

@Composable
private fun TaskListContent(
    state: TaskListUiState,
    onSearchQueryChanged: (String) -> Unit,
    onTaskClick: (Int) -> Unit,
    onCompleteTask: (Int) -> Unit
) {
    Column(modifier = Modifier.fillMaxSize()) {
        TaskListHeader()

        TaskSearchBar(
            searchQuery = state.searchQuery,
            onSearchQueryChanged = onSearchQueryChanged
        )

        when {
            state.isLoading -> TaskListLoadingIndicator()
            state.error != null -> TaskListErrorState(error = state.error)
            state.tasks.isEmpty() -> TaskListEmptyState()
            else -> TaskListItems(
                tasks = state.tasks,
                onTaskClick = onTaskClick,
                onCompleteTask = onCompleteTask
            )
        }
    }
}

@Composable
private fun TaskListItems(
    tasks: List<Task>,
    onTaskClick: (Int) -> Unit,
    onCompleteTask: (Int) -> Unit
) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = 16.dp)
    ) {
        items(
            count = tasks.size,
            key = { tasks[it].id }
        ) { index ->
            TaskItem(
                state = TaskItemState(
                    task = tasks[index],
                    isCompleted = tasks[index].isCompleted
                ),
                onCompletedChange = { onCompleteTask(tasks[index].id) },
                onClick = { onTaskClick(tasks[index].id) }
            )
            if (index < tasks.size - 1) {
                Divider()
            }
        }
    }
}

🔌 Parte 3: Separación de Concerns en UI

Niveles de Reusabilidad

Cada composable debe tener un nivel de reusabilidad claro:

// NIVEL 1: Design System Components
// Pueden usarse en CUALQUIER aplicación, completamente agnóstico
@Composable
fun AppButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    loading: Boolean = false
) {
    Button(
        onClick = onClick,
        modifier = modifier
            .height(48.dp)
            .fillMaxWidth(),
        enabled = enabled && !loading,
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.primary
        )
    ) {
        if (loading) {
            CircularProgressIndicator(
                modifier = Modifier.size(20.dp),
                color = MaterialTheme.colorScheme.onPrimary
            )
        } else {
            Text(text)
        }
    }
}

// NIVEL 2: Feature-Specific Components
// Solo se usan dentro de una feature específica
@Composable
fun TaskItem(
    state: TaskItemState,
    onCompletedChange: (Boolean) -> Unit,
    onClick: () -> Unit
) {
    // Usa AppButton, AppCard, etc.
}

// NIVEL 3: Screen Composables
// Se usan para ensamblar un screen completo
@Composable
fun TaskListScreen(
    viewModel: TaskListViewModel = hiltViewModel(),
    onNavigateToDetail: (Int) -> Unit
) {
    // Ensambla TaskItem, AppButton, etc.
}

Estructura de carpetas por nivel:

ui/
├── components/
│   ├── AppButton.kt         ← NIVEL 1: Design System
│   ├── AppCard.kt
│   ├── AppTextField.kt
│   ├── AppDialog.kt
│   └── /* más componentes base */

feature/
├── taskList/
│   ├── components/
│   │   ├── TaskItem.kt      ← NIVEL 2: Feature-Specific
│   │   ├── TaskListHeader.kt
│   │   └── TaskSearchBar.kt
│   └── TaskListScreen.kt    ← NIVEL 3: Screen

Patrón de Composables Base

El error más común: Copiar diseño en cada componente

// ❌ MALO: Duplicar padding, colores, etc.
@Composable
fun TaskItem(...) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("Task", style = MaterialTheme.typography.bodyLarge)
        Text("Description", color = MaterialTheme.colorScheme.outline)
    }
}

@Composable
fun UserItem(...) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("User", style = MaterialTheme.typography.bodyLarge)
        Text("Email", color = MaterialTheme.colorScheme.outline)
    }
}

// ✅ BUENO: Crear base component, reutilizar
@Composable
fun ListItem(
    title: String,
    subtitle: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier
            .fillMaxWidth()
            .clickable(onClick = onClick)
            .padding(16.dp)
    ) {
        Text(title, style = MaterialTheme.typography.bodyLarge)
        Text(subtitle, color = MaterialTheme.colorScheme.outline)
    }
}

@Composable
fun TaskItem(
    state: TaskItemState,
    onCompletedChange: (Boolean) -> Unit,
    onClick: () -> Unit
) {
    ListItem(
        title = state.task.title,
        subtitle = state.task.description,
        onClick = onClick
    )
}

@Composable
fun UserItem(
    state: UserItemState,
    onClick: () -> Unit
) {
    ListItem(
        title = state.user.name,
        subtitle = state.user.email,
        onClick = onClick
    )
}

El Patrón State Container

Para composables complejos que gestionar su propio estado local:

// ✅ BUENO: State container pattern
@Composable
fun TaskDetailScreen(
    viewModel: TaskDetailViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    val screenState by viewModel.screenState.collectAsState()

    // State container maneja todo
    val state = remember {
        TaskDetailScreenState(
            onNavigateBack = onNavigateBack
        )
    }

    TaskDetailContent(
        screenState = screenState,
        state = state
    )
}

class TaskDetailScreenState(
    val onNavigateBack: () -> Unit
) {
    var isDescriptionExpanded by mutableStateOf(false)
    var selectedTab by mutableStateOf(0)

    fun toggleDescription() {
        isDescriptionExpanded = !isDescriptionExpanded
    }

    fun selectTab(index: Int) {
        selectedTab = index
    }
}

@Composable
private fun TaskDetailContent(
    screenState: TaskDetailUiState,
    state: TaskDetailScreenState
) {
    Column {
        TaskDetailHeader(
            title = screenState.task.title,
            onBack = state.onNavigateBack
        )

        TaskDetailBody(
            screenState = screenState,
            isExpanded = state.isDescriptionExpanded,
            onToggle = { state.toggleDescription() }
        )

        TaskDetailTabs(
            selectedTab = state.selectedTab,
            onTabSelected = { state.selectTab(it) }
        )
    }
}

🎨 Parte 4: Shared UI Components & Design System

Crear un Design System Centralizado

// ui/theme/Color.kt
val primaryColor = Color(0xFF6200EE)
val secondaryColor = Color(0xFF03DAC6)
val tertiaryColor = Color(0xFF018786)

// ui/theme/Type.kt
val AppTypography = Typography(
    displayLarge = TextStyle(
        fontSize = 57.sp,
        fontWeight = FontWeight.Bold,
        lineHeight = 64.sp
    ),
    headlineSmall = TextStyle(
        fontSize = 24.sp,
        fontWeight = FontWeight.Bold,
        lineHeight = 32.sp
    ),
    bodyLarge = TextStyle(
        fontSize = 16.sp,
        fontWeight = FontWeight.Normal,
        lineHeight = 24.sp
    ),
    bodySmall = TextStyle(
        fontSize = 12.sp,
        fontWeight = FontWeight.Normal,
        lineHeight = 16.sp
    )
)

// ui/theme/Theme.kt
@Composable
fun TaskAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) {
        darkColorScheme(
            primary = primaryColor,
            secondary = secondaryColor,
            tertiary = tertiaryColor
        )
    } else {
        lightColorScheme(
            primary = primaryColor,
            secondary = secondaryColor,
            tertiary = tertiaryColor
        )
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        content = content
    )
}

Componentes Base del Design System

// ui/components/AppCard.kt
@Composable
fun AppCard(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Card(
        modifier = modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(12.dp)),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surface
        ),
        elevation = CardDefaults.cardElevation(
            defaultElevation = 4.dp
        )
    ) {
        content()
    }
}

// ui/components/AppTopBar.kt
@Composable
fun AppTopBar(
    title: String,
    onBackClick: (() -> Unit)? = null,
    actions: @Composable RowScope.() -> Unit = {}
) {
    TopAppBar(
        title = { Text(title) },
        navigationIcon = if (onBackClick != null) {
            {
                IconButton(onClick = onBackClick) {
                    Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
                }
            }
        } else null,
        actions = actions,
        colors = TopAppBarDefaults.topAppBarColors(
            containerColor = MaterialTheme.colorScheme.primary,
            titleContentColor = MaterialTheme.colorScheme.onPrimary
        )
    )
}

// ui/components/AppDialog.kt
@Composable
fun AppDialog(
    title: String,
    message: String,
    confirmText: String = "Confirmar",
    dismissText: String = "Cancelar",
    onConfirm: () -> Unit,
    onDismiss: () -> Unit,
    isShowing: Boolean = false
) {
    if (isShowing) {
        AlertDialog(
            onDismissRequest = onDismiss,
            title = { Text(title) },
            text = { Text(message) },
            confirmButton = {
                Button(onClick = onConfirm) {
                    Text(confirmText)
                }
            },
            dismissButton = {
                TextButton(onClick = onDismiss) {
                    Text(dismissText)
                }
            }
        )
    }
}

// ui/modifiers/PaddingModifiers.kt
object AppPadding {
    val small = 8.dp
    val medium = 16.dp
    val large = 24.dp
    val extraLarge = 32.dp
}

fun Modifier.screenPadding(): Modifier = this.padding(AppPadding.medium)
fun Modifier.itemPadding(): Modifier = this.padding(AppPadding.small)
fun Modifier.sectionPadding(): Modifier = this.padding(AppPadding.large)

🗂️ Parte 5: State Management Pattern Avanzado

StateFlow + ViewModel Pattern

// UiState unificado para toda la feature
data class TaskListUiState(
    val tasks: List<Task> = emptyList(),
    val searchQuery: String = "",
    val isLoading: Boolean = false,
    val error: String? = null,
    val selectedTask: Int? = null,
    val sortBy: SortOption = SortOption.RECENT,
    val filterBy: FilterOption = FilterOption.ALL,
    val isRefreshing: Boolean = false,
    val hasMoreItems: Boolean = true
)

// Events para navegación y side-effects
sealed class TaskListEvent {
    data class NavigateToDetail(val taskId: Int) : TaskListEvent()
    data class ShowError(val message: String) : TaskListEvent()
    object NavigateBack : TaskListEvent()
}

// ViewModel
@HiltViewModel
class TaskListViewModel @Inject constructor(
    private val getTasksUseCase: GetTasksUseCase,
    private val completeTaskUseCase: CompleteTaskUseCase,
    private val deleteTaskUseCase: DeleteTaskUseCase,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _uiState = MutableStateFlow(TaskListUiState())
    val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()

    private val _event = Channel<TaskListEvent>()
    val event: Flow<TaskListEvent> = _event.receiveAsFlow()

    init {
        loadTasks()
    }

    // Intent methods (what user can do)
    fun onSearchQueryChanged(query: String) {
        _uiState.update { it.copy(searchQuery = query) }
        filterTasks()
    }

    fun onSortOptionChanged(sortOption: SortOption) {
        _uiState.update { it.copy(sortBy = sortOption) }
        sortTasks()
    }

    fun onFilterOptionChanged(filterOption: FilterOption) {
        _uiState.update { it.copy(filterBy = filterOption) }
        filterTasks()
    }

    fun onTaskClick(taskId: Int) {
        viewModelScope.launch {
            _event.send(TaskListEvent.NavigateToDetail(taskId))
        }
    }

    fun onCompleteTask(taskId: Int) {
        viewModelScope.launch {
            try {
                completeTaskUseCase(taskId)
                loadTasks()
            } catch (e: Exception) {
                _event.send(TaskListEvent.ShowError(e.message ?: "Unknown error"))
            }
        }
    }

    fun onDeleteTask(taskId: Int) {
        viewModelScope.launch {
            try {
                deleteTaskUseCase(taskId)
                loadTasks()
            } catch (e: Exception) {
                _event.send(TaskListEvent.ShowError(e.message ?: "Unknown error"))
            }
        }
    }

    fun onRefresh() {
        _uiState.update { it.copy(isRefreshing = true) }
        loadTasks()
    }

    // Private helpers
    private fun loadTasks() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val tasks = getTasksUseCase()
                _uiState.update {
                    it.copy(
                        tasks = tasks,
                        isLoading = false,
                        isRefreshing = false,
                        error = null
                    )
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(
                        error = e.message,
                        isLoading = false,
                        isRefreshing = false
                    )
                }
            }
        }
    }

    private fun filterTasks() {
        val currentState = _uiState.value
        val filtered = currentState.tasks
            .filter { task ->
                task.title.contains(currentState.searchQuery, ignoreCase = true)
            }
            .filter { task ->
                when (currentState.filterBy) {
                    FilterOption.ALL -> true
                    FilterOption.COMPLETED -> task.isCompleted
                    FilterOption.PENDING -> !task.isCompleted
                }
            }

        _uiState.update { it.copy(tasks = filtered) }
    }

    private fun sortTasks() {
        val currentState = _uiState.value
        val sorted = currentState.tasks.sortedWith(
            when (currentState.sortBy) {
                SortOption.RECENT -> compareByDescending { it.createdAt }
                SortOption.PRIORITY -> compareByDescending { it.priority }
                SortOption.ALPHABETICAL -> compareBy { it.title }
            }
        )

        _uiState.update { it.copy(tasks = sorted) }
    }
}

Consuming Events en el Screen

@Composable
fun TaskListScreen(
    viewModel: TaskListViewModel = hiltViewModel(),
    onNavigateToDetail: (Int) -> Unit,
    onNavigateBack: () -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()

    // Consumir events
    LaunchedEffect(Unit) {
        viewModel.event.collect { event ->
            when (event) {
                is TaskListEvent.NavigateToDetail -> onNavigateToDetail(event.taskId)
                is TaskListEvent.ShowError -> {
                    // Mostrar snackbar
                    println("Error: ${event.message}")
                }
                TaskListEvent.NavigateBack -> onNavigateBack()
            }
        }
    }

    TaskListContent(
        state = uiState,
        onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
        onSortOptionChanged = { viewModel.onSortOptionChanged(it) },
        onFilterOptionChanged = { viewModel.onFilterOptionChanged(it) },
        onTaskClick = { viewModel.onTaskClick(it) },
        onCompleteTask = { viewModel.onCompleteTask(it) },
        onDeleteTask = { viewModel.onDeleteTask(it) },
        onRefresh = { viewModel.onRefresh() }
    )
}

🔄 Parte 6: Scaling de 10 a 100+ Screens

Feature-Based Modularization

Cuando alcanzas 30+ screens, necesitas dividir por features:

app/
├── feature/
│   ├── auth/
│   │   ├── login/
│   │   ├── signup/
│   │   ├── reset_password/
│   │   └── shared/
│   ├── task/
│   │   ├── list/
│   │   ├── detail/
│   │   ├── create/
│   │   ├── edit/
│   │   └── shared/
│   │       ├── models/
│   │       ├── repositories/
│   │       └── usecases/
│   ├── user/
│   │   ├── profile/
│   │   ├── settings/
│   │   └── shared/
│   └── /* más features */
// navigation/NavGraph.kt
@Composable
fun AppNavGraph(
    navController: NavHostController,
    startDestination: String = Screen.TaskList.route
) {
    NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        // Auth feature
        authNavGraph(navController)

        // Task feature
        taskNavGraph(navController)

        // User feature
        userNavGraph(navController)
    }
}

// feature/task/navigation/TaskNavGraph.kt
fun NavGraphBuilder.taskNavGraph(navController: NavHostController) {
    navigation(
        startDestination = TaskScreen.List.route,
        route = TaskFeature.route
    ) {
        composable(TaskScreen.List.route) {
            TaskListScreen(
                onNavigateToDetail = { taskId ->
                    navController.navigate(TaskScreen.Detail.createRoute(taskId))
                }
            )
        }

        composable(
            route = TaskScreen.Detail.route,
            arguments = listOf(navArgument("taskId") { type = NavType.IntType })
        ) {
            TaskDetailScreen(
                onNavigateBack = { navController.popBackStack() }
            )
        }

        composable(TaskScreen.Create.route) {
            TaskCreateScreen(
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

// Navigation destinations
sealed class Screen(val route: String) {
    object TaskList : Screen("task/list")
    object TaskDetail : Screen("task/detail/{taskId}") {
        fun createRoute(taskId: Int) = "task/detail/$taskId"
    }
    object TaskCreate : Screen("task/create")
}

Shared State Between Features

// Cuando necesitas compartir estado entre features
@HiltViewModel
class AppStateViewModel : ViewModel() {
    private val _currentUser = MutableStateFlow<User?>(null)
    val currentUser: StateFlow<User?> = _currentUser.asStateFlow()

    private val _appTheme = MutableStateFlow<AppTheme>(AppTheme.Light)
    val appTheme: StateFlow<AppTheme> = _appTheme.asStateFlow()

    fun setCurrentUser(user: User) {
        _currentUser.value = user
    }

    fun setAppTheme(theme: AppTheme) {
        _appTheme.value = theme
    }
}

// En screens que necesitan acceder a este estado
@Composable
fun TaskListScreen(
    viewModel: TaskListViewModel = hiltViewModel(),
    appStateViewModel: AppStateViewModel = hiltViewModel(),
    onNavigateToDetail: (Int) -> Unit
) {
    val currentUser by appStateViewModel.currentUser.collectAsState()
    val appTheme by appStateViewModel.appTheme.collectAsState()

    TaskListContent(
        currentUser = currentUser,
        appTheme = appTheme,
        // ... rest of props
    )
}

🧪 Parte 7: Testing Composables

Testing Components Puros

// Composables sin estado son fáciles de testear
@Composable
fun TaskItem(
    state: TaskItemState,
    onCompletedChange: (Boolean) -> Unit,
    onClick: () -> Unit
) {
    // UI pura
}

// Test
class TaskItemTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun taskItem_displays_title() {
        composeTestRule.setContent {
            TaskItem(
                state = TaskItemState(
                    task = Task(id = 1, title = "Test Task"),
                    isCompleted = false
                ),
                onCompletedChange = {},
                onClick = {}
            )
        }

        composeTestRule.onNodeWithText("Test Task")
            .assertIsDisplayed()
    }

    @Test
    fun taskItem_calls_onClick() {
        var clicked = false
        composeTestRule.setContent {
            TaskItem(
                state = TaskItemState(
                    task = Task(id = 1, title = "Test Task"),
                    isCompleted = false
                ),
                onCompletedChange = {},
                onClick = { clicked = true }
            )
        }

        composeTestRule.onNodeWithText("Test Task")
            .performClick()

        assertTrue(clicked)
    }
}

Testing Screens con ViewModel

class TaskListScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    private val fakeViewModel = FakeTaskListViewModel()

    @Test
    fun taskListScreen_displays_tasks() {
        composeTestRule.setContent {
            TaskListScreen(
                viewModel = fakeViewModel,
                onNavigateToDetail = {}
            )
        }

        composeTestRule.onNodeWithText("Task 1")
            .assertIsDisplayed()

        composeTestRule.onNodeWithText("Task 2")
            .assertIsDisplayed()
    }
}

// Fake ViewModel para testing
class FakeTaskListViewModel : TaskListViewModel(
    getTasksUseCase = FakeGetTasksUseCase(),
    completeTaskUseCase = FakeCompleteTaskUseCase(),
    deleteTaskUseCase = FakeDeleteTaskUseCase(),
    savedStateHandle = SavedStateHandle()
) {
    init {
        // Override states for testing
    }
}

⚡ Parte 8: Performance & Optimization

Evitar Recomposiciones Innecesarias

// ❌ MALO: Recomposición completa cuando cambien muchas cosas
@Composable
fun TaskList(
    viewModel: TaskListViewModel
) {
    val uiState by viewModel.uiState.collectAsState()

    // TODO ESTO REBUILDA si cualquier parte de uiState cambió
    TaskListContent(
        tasks = uiState.tasks,
        isLoading = uiState.isLoading,
        error = uiState.error,
        // ... 10 más propiedades
    )
}

// ✅ BUENO: Rebuildar solo lo necesario
@Composable
fun TaskList(
    viewModel: TaskListViewModel
) {
    val tasks by viewModel.tasks.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val error by viewModel.error.collectAsState()

    // Cada uno rebuilda independientemente
    TaskListContent(
        tasks = tasks,
        isLoading = isLoading,
        error = error
    )
}

// O mejor aún: usar remember
@Composable
fun TaskList(
    viewModel: TaskListViewModel
) {
    val uiState by viewModel.uiState.collectAsState()

    // Memoizar derivadas para evitar recomposiciones
    val filteredTasks = remember(uiState.tasks, uiState.filter) {
        uiState.tasks.filter { /* ... */ }
    }

    TaskListContent(
        tasks = filteredTasks,
        isLoading = uiState.isLoading
    )
}

Memory Leaks Prevention

// ❌ MALO: LaunchedEffect sin cleanup
@Composable
fun TaskDetailScreen(viewModel: TaskDetailViewModel) {
    LaunchedEffect(Unit) {
        viewModel.observeTask().collect { task ->
            // Nunca cancela, memory leak
        }
    }
}

// ✅ BUENO: LaunchedEffect con proper scope
@Composable
fun TaskDetailScreen(viewModel: TaskDetailViewModel) {
    val task by viewModel.task.collectAsState()

    // Automatically cancelled cuando composable leave composition
    LaunchedEffect(task) {
        if (task != null) {
            // Safe, cancels when task changes or composable leaves
        }
    }
}

// ✅ BUENO: DisposableEffect para resources
@Composable
fun CameraPreview(cameraManager: CameraManager) {
    DisposableEffect(Unit) {
        val preview = cameraManager.startPreview()

        onDispose {
            cameraManager.stopPreview(preview)
        }
    }
}

📋 Checklist: Estructura de Proyecto Escalable

  • Modularización: Cada feature en su propia carpeta
  • Separación de Concerns: Composables pequeños (< 200 líneas)
  • Design System: Componentes base centralizados
  • State Management: Un UiState por feature, Events para navegación
  • Testing: Todos los composables puros son testeables
  • Navigation: NavGraph centralizado, Routes claros
  • Performance: Recomposiciones optimizadas, sin memory leaks
  • Documentation: Cada composable tiene @Composable + comentarios

🎓 Conclusión: La Arquitectura Correcta Es Invisible

Cuando tu arquitectura es correcta, no la ves. Los developers simplemente agregan features sin pensar en el sistema. El código es predecible, mantenible, testeable.

Los signs de buena arquitectura:

✅ Un nuevo developer entiende la estructura en 1 hora
✅ Agregar una feature toma el mismo tiempo en semana 1 que en semana 100
✅ Los cambios están localizados (cambias una cosa, no rompes 5)
✅ Testear es fácil (sin mocks complejos)
✅ Code reviews son sobre lógica, no sobre estructura
✅ Refactorizar es seguro (tests catchean problemas)

Los signs de arquitectura mala:

❌ Nadie entiende la estructura, preguntan "¿dónde va esto?"
❌ Agregar features se vuelve cada vez más lento
❌ Los cambios tienen efectos secundarios en random places
❌ Testear requiere mocks de mocks
❌ Code reviews gastan tiempo en "por qué pusiste esto aquí"
❌ Refactorizar es aterrador

La buena noticia: Jetpack Compose, si lo estructuras correctamente, escala tan bien como cualquier UI framework moderno.

La arquitectura correcta no es un detalle. Es el fundamento de un proyecto mantenible.

Tags

#jetpack-compose #architecture #modular-architecture #ui-architecture #kotlin #android #scaling #design-patterns