Saltar al contenido
K
Jetpack y arquitecturaPaso a paso30 min de lectura · 6 min de máquina Intermedio

Casos de uso: cuándo sí y cuándo sobran

Al terminar: Decidir cuándo un caso de uso aporta y cuándo sobra, y conectarlo con dos repositorios y el ViewModel.

Al terminar esta lección, la regla «cómo se escribe una crónica» ya no vive dentro del CronicaViewModel: vive en un caso de uso, PublicarCronica, que junta el repositorio de partidos con el de crónicas. La pantalla de la crónica se verá y se comportará igual que antes; lo que cambia es dónde está la regla y que ahora se puede probar sin pantalla ni red. Además tendrás un criterio claro para decidir cuándo un caso de uso aporta y cuándo es pura ceremonia.

Importa porque «¿hace falta un caso de uso aquí?» es una de las preguntas de arquitectura que más se repiten, y la respuesta correcta casi nunca es «siempre» ni «nunca». Temas: caso de uso con dos repositorios; la dirección de las dependencias; cuándo sobra. No hay nada que instalar.

La regla de una crónica no es de la pantalla

Mira lo que hace hoy la función publicar() del CronicaViewModel:

así estaba: el ViewModel hacía demasiado
            val resultado = intentar {
                val partido = partidos.partido(partidoId) ?: error("No existe el partido $partidoId")
                val titulo = partido.resumen()
                val goles = partido.eventos.filterIsInstance<Gol>().sortedBy { it.minuto }
                val texto = if (goles.isEmpty()) "Sin goles" else goles.joinToString("\n") { "${it.minuto}' ${it.jugador.nombre} (${it.equipo.nombre})" }
                CronicaPublicada(cronicas.publicar(titulo, texto).getOrThrow(), titulo, texto)
            }

Hay tres decisiones mezcladas:

  1. De negocio: el título de la crónica es el resumen del partido, y el texto es una línea por gol, en orden de minuto, o «Sin goles».
  2. De coordinación: primero hay que leer el partido (de Room) y después publicar (en la red). Son dos repositorios.
  3. De pantalla: cuándo se enseña «Publicando…», la crónica publicada o el error.

Solo la tercera es del ViewModel. Las dos primeras son del dominio. Si las dejas en el ViewModel, para probar la regla del texto necesitas montar un ViewModel, y si mañana otra pantalla (compartir desde la lista, por ejemplo) quiere publicar una crónica, copiará la regla.

Cuándo sí y cuándo sobra

La lección de Casos de uso: RegistrarGol dejó una frase: «si solo reenviara, sobraría». Aquí está el criterio completo:

Merece un caso de usoSobra
Hay una regla de negocio que decidirSolo reenvía la llamada al repositorio
Combina dos o más repositoriosUsa un solo repositorio y no hay regla
Se reutiliza desde varios ViewModelLo usa una pantalla y es trivial
Quieres probar la regla sin pantallaNo hay regla que aislar: probar el ViewModel ya prueba todo

PublicarCronica cumple tres de la izquierda (regla, dos repositorios y prueba sin pantalla); la de reutilizarse desde varios ViewModel no, porque hoy lo usa uno solo. Compáralo con la pantalla de árbitros de las dos lecciones anteriores, que llama al repositorio directamente. ¿Merecería un caso de uso ObtenerArbitros?

contraejemplo: un caso de uso que sobra
class ObtenerArbitros(private val repositorio: ArbitrosRepository) {
    operator fun invoke() = repositorio.observarArbitros()      // solo reenvía: ni regla, ni segundo repositorio
}

No: solo cambia el nombre de la llamada. Añade un fichero, un paso más que seguir y ningún beneficio. Si algún día los árbitros ganan una regla («solo los de la ciudad del partido»), ahí aparecería el caso de uso, sin prisa.

La dirección de las dependencias

Pantalla ──► ViewModel ──► Caso de uso ──► Repositorios ──► Room / red

Cada flecha apunta hacia la derecha (de quien usa a quien es usado) y nunca al revés: un repositorio no conoce a un caso de uso, ni un caso de uso a un ViewModel. Gracias a eso, cada pieza se prueba con la de su derecha sustituida por un fake.

Un ejemplo con una biblioteca

El mismo molde con datos ajenos a Marcador. AvisarDevolucion junta el repositorio de libros con el de avisos a socios: primero pregunta por el libro y después envía el aviso.

ejemplo mínimo: un caso de uso con dos repositorios (con comentarios)
import kotlinx.coroutines.*
import kotlin.coroutines.cancellation.CancellationException
 
data class Libro(val id: Int, val titulo: String)
 
interface LibrosRepository {                                   // repositorio 1: los libros
    suspend fun libro(id: Int): Libro?
}
 
interface AvisosRepository {                                   // repositorio 2: los avisos a los socios
    suspend fun enviar(texto: String): Int                     // devuelve el número del aviso
}
 
class LibrosDePrueba : LibrosRepository {
    private val libros = listOf(Libro(1, "Dune"), Libro(2, "Emma"))
    override suspend fun libro(id: Int): Libro? = libros.firstOrNull { it.id == id }
}
 
class AvisosDePrueba : AvisosRepository {
    override suspend fun enviar(texto: String): Int {
        println("enviado: $texto")
        return 101
    }
}
 
class AvisarDevolucion(                                        // caso de uso: junta DOS repositorios
    private val libros: LibrosRepository,
    private val avisos: AvisosRepository,
) {
    suspend operator fun invoke(socio: String, libroId: Int): Result<Int> = try {
        val libro = libros.libro(libroId) ?: error("No existe el libro $libroId")      // 1.º: pregunta al primero
        Result.success(avisos.enviar("$socio, devuelve «${libro.titulo}» antes del viernes"))  // 2.º: usa al segundo
    } catch (cancelacion: CancellationException) {
        throw cancelacion
    } catch (e: Exception) {
        Result.failure(e)                                      // un fallo es un valor, no una excepción que viaja
    }
}
 
fun main() = runBlocking {
    val avisar = AvisarDevolucion(LibrosDePrueba(), AvisosDePrueba())
    println(avisar("Lucía", 1))
    println(avisar("Lucía", 9))
}
enviado: Lucía, devuelve «Dune» antes del viernes
Success(101)
Failure(java.lang.IllegalStateException: No existe el libro 9)

Con el libro 9 no se envía nada: la regla corta antes de tocar el segundo repositorio. Y quien lo use (un ViewModel) solo ve un Result.

Reto · Ejercicio: un pedido que mira el almacén

Fuera de la app, en el Kotlin Playground (play.kotlinlang.org).

Una tienda tiene dos repositorios: el almacén (devuelve un producto por su número, o nada si no existe; cada producto tiene nombre y unidades en stock) y los pedidos (crea un pedido a partir de un texto, imprime «pedido: » seguido del texto y devuelve el número 101). Los productos de prueba son «Té verde» (5 unidades, número 1) y «Café» (0 unidades, número 2).

Escribe un caso de uso ComprarProducto que reciba los dos repositorios y se llame como una función con el número del producto. Si no existe, falla con «No existe el producto» y el número; si no hay unidades, falla con «Agotado: » y el nombre; si todo va bien, crea el pedido con el nombre del producto y devuelve el número del pedido. El resultado es un Result, y la cancelación no se atrapa.

En main, compra el producto 1, el 2 y el 9. La consola debe mostrar:

pedido: Té verde
Success(101)
Failure(java.lang.IllegalStateException: Agotado: Café)
Failure(java.lang.IllegalStateException: No existe el producto 9)
Ver solución
solución: ComprarProducto con dos repositorios
import kotlinx.coroutines.*
import kotlin.coroutines.cancellation.CancellationException
 
data class Producto(val id: Int, val nombre: String, val stock: Int)
 
interface AlmacenRepository {
    suspend fun producto(id: Int): Producto?
}
 
interface PedidosRepository {
    suspend fun crear(descripcion: String): Int
}
 
class AlmacenDePrueba : AlmacenRepository {
    private val productos = listOf(Producto(1, "Té verde", 5), Producto(2, "Café", 0))
    override suspend fun producto(id: Int): Producto? = productos.firstOrNull { it.id == id }
}
 
class PedidosDePrueba : PedidosRepository {
    override suspend fun crear(descripcion: String): Int {
        println("pedido: $descripcion")
        return 101
    }
}
 
class ComprarProducto(
    private val almacen: AlmacenRepository,
    private val pedidos: PedidosRepository,
) {
    suspend operator fun invoke(id: Int): Result<Int> = try {
        val producto = almacen.producto(id) ?: error("No existe el producto $id")
        check(producto.stock > 0) { "Agotado: ${producto.nombre}" }   // si la condición es falsa, lanza IllegalStateException con ese mensaje
        Result.success(pedidos.crear(producto.nombre))
    } catch (cancelacion: CancellationException) {
        throw cancelacion
    } catch (e: Exception) {
        Result.failure(e)
    }
}
 
fun main() = runBlocking {
    val comprar = ComprarProducto(AlmacenDePrueba(), PedidosDePrueba())
    println(comprar(1))
    println(comprar(2))
    println(comprar(9))
}

Las dos reglas (que exista y que haya unidades) cortan antes de crear el pedido, por eso con el café y con el 9 no se imprime «pedido:». check(condición) { mensaje } ya lo conoces de Excepciones: try, catch y require: si la condición es falsa, lanza una excepción con ese mensaje, y el catch de abajo la convierte en Result.failure. En el proyecto, intentar hace por ti el try con los dos catch.

Paso · PublicarCronica y un ViewModel más simple

Objetivo. Crear el caso de uso, hacer que el CronicaViewModel solo traduzca su resultado a estado de pantalla, y probar la regla con dos tests. Debes ver la pantalla de la crónica exactamente como antes.

app → kotlin+java
com.sebastiannarvaez.marcador
feature
cronica
domain
PublicarCronica.ktse crea
ui
CronicaViewModel.ktse modifica
com.sebastiannarvaez.marcador (test)
core
Falsos.ktse modifica
feature
cronica
domaincarpeta nueva
PublicarCronicaTest.ktse crea
Un fichero nuevo en el código, uno en los tests y dos que se modifican, en la vista Android.

Crea los ficheros. Con el selector en Android, despliega app → kotlin+java → com.sebastiannarvaez.marcador → feature → cronica. Clic derecho sobre domain → New → Kotlin Class/File → File → PublicarCronica: es una regla de la crónica, así que vive en el domain de su feature (lee los partidos a través de PartidosRepository, sin tocar la feature partidos). El test copia esa carpeta: en el paquete com.sebastiannarvaez.marcador (test), clic derecho → New → Package → añade .feature.cronica.domain, y dentro New → Kotlin Class/File → File → PublicarCronicaTest.

Piezas nuevas de este paso:

  • Result.getOrThrow() (la viste en Result: errores como valores): abre el «sobre»; devuelve el valor si el Result es un éxito y lanza el fallo si no. Aquí sirve para que, si publicar falla en la red, el intentar de fuera lo recoja y el fallo salga tal cual en el Result del caso de uso.
  • Un caso de uso con dos repositorios en el constructor: las dos dependencias llegan por Hilt, como una. Y el caso de uso mismo lleva @Inject constructor y no lleva @Singleton: no guarda nada entre llamadas, así que no hace falta una sola copia (en Inyección en todas las capas verás la tabla de alcances).

Ya vistas: operator fun invoke, intentar, error y ?: (Casos de uso: RegistrarGol); filterIsInstance (Lambdas con listas: filter, map y reglas), sortedBy (Colecciones) y joinToString (Guardar y leer JSON con Json); Result.fold, que convierte un Result en otra cosa con una lambda para el éxito y otra para el fallo (Result: errores como valores y Red en Marcador: la lista de árbitros); Pair (una pareja de dos valores) y a to b, que la crea (Colecciones); fakes y runTest (Tests de un ViewModel: fakes, setMain y Turbine).

Intenta escribirlo tú, en este orden:

  1. En feature/cronica/domain, PublicarCronica (con @Inject constructor): recibe los dos repositorios, se llama con el número del partido y devuelve Result<CronicaPublicada>. Falla con «No existe el partido» y el número si no está; el título es el resumen del partido; el texto, una línea por gol ordenada por minuto con el formato 15' Ana (Rayo FC) (o «Sin goles»); y publica con el repositorio de crónicas.
  2. El CronicaViewModel recibe PublicarCronica y el número del partido (@Assisted, como antes) en lugar de los dos repositorios, y publicar() solo llama al caso de uso y traduce el resultado.
  3. En Hilt no tienes que tocar nada: PublicarCronica se construye sola con @Inject constructor, y los dos repositorios que pide ya los entregan RepositoriosModule y DatosModule.
  4. Tests: FakePartidosRepository, PartidoDeLista y partidoConGoles() ya los tienes de Tests del ViewModel de Marcador. En Falsos.kt, un repositorio de crónicas falso que apunte lo publicado y responda 101; en PublicarCronicaTest, dos tests: el título y una línea por gol con partidoConGoles() (Rayo FC 2-1 Toros), y un partido que no existe falla y no publica nada.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/feature/cronica/domain/PublicarCronica.kt
package com.sebastiannarvaez.marcador.feature.cronica.domain
 
import com.sebastiannarvaez.marcador.core.domain.resumen
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.intentar
import javax.inject.Inject
 
// CASO DE USO CON DOS REPOSITORIOS. Aqui SI vale la pena:
//   - hay una REGLA de negocio: como se escribe una cronica (titulo = el resumen del
//     partido; texto = una linea por gol, en orden, o «Sin goles»);
//   - JUNTA dos repositorios: los partidos (Room) y las cronicas (la red);
//   - la regla se prueba sin pantalla (PublicarCronicaTest).
// Direccion: ViewModel -> caso de uso -> repositorios. Nunca al reves.
//
// Contraejemplo: la pantalla de arbitros llama a ArbitrosRepository DIRECTO. Un
// `ObtenerArbitros` que solo reenviara `observarArbitros()` seria pura ceremonia.
class PublicarCronica @Inject constructor(
    private val partidos: PartidosRepository,
    private val cronicas: CronicasRepository,
) {
    suspend operator fun invoke(partidoId: Int): Result<CronicaPublicada> = intentar {
        val partido = partidos.partido(partidoId) ?: error("No existe el partido $partidoId")
        val titulo = partido.resumen()
        val goles = partido.eventos.filterIsInstance<Gol>().sortedBy { it.minuto }
        val texto = if (goles.isEmpty()) "Sin goles" else goles.joinToString("\n") { "${it.minuto}' ${it.jugador.nombre} (${it.equipo.nombre})" }
        // getOrThrow: si la red fallo, el FalloDeRed sale tal cual en el Result de fuera.
        CronicaPublicada(cronicas.publicar(titulo, texto).getOrThrow(), titulo, texto)
    }
}
app/src/main/java/com/sebastiannarvaez/marcador/feature/cronica/ui/CronicaViewModel.kt
package com.sebastiannarvaez.marcador.feature.cronica.ui
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.core.ui.mensajeParaElUsuario
import com.sebastiannarvaez.marcador.feature.cronica.domain.PublicarCronica
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
 
// Publica la cronica de un partido. La regla («como se escribe una cronica») vive en el
// caso de uso; el ViewModel solo traduce su resultado a estado de pantalla.
// INYECCION ASISTIDA, como en PartidoViewModel: Hilt pone lo que sabe construir; el id
// del partido lo pone la pantalla (sale de la CronicaKey). @Assisted marca el que llega de fuera.
@HiltViewModel(assistedFactory = CronicaViewModel.Fabrica::class)
class CronicaViewModel @AssistedInject constructor(
    private val publicarCronica: PublicarCronica,
    @Assisted private val partidoId: Int,
) : ViewModel() {
 
    // La fabrica que Hilt ESCRIBE: solo se declara la funcion con lo que falta.
    @AssistedFactory
    interface Fabrica {
        fun crear(partidoId: Int): CronicaViewModel
    }
 
    private val _uiState = MutableStateFlow(CronicaUiState(partidoId))
    val uiState: StateFlow<CronicaUiState> = _uiState.asStateFlow()
 
    fun publicar() {
        if (_uiState.value.publicando) return
        _uiState.update { it.copy(publicando = true, error = null) }
        viewModelScope.launch {
            val resultado = publicarCronica(partidoId)
            _uiState.update { estado ->
                resultado.fold(
                    onSuccess = { estado.copy(publicando = false, publicada = it) },
                    onFailure = { estado.copy(publicando = false, error = it.mensajeParaElUsuario()) },
                )
            }
        }
    }
}

En Falsos.kt, el import (con los demás) y la clase nueva al final:

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
import com.sebastiannarvaez.marcador.feature.cronica.domain.CronicasRepository
app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
// El repositorio de cronicas, de mentira: apunta lo publicado y responde con el 101.
class FakeCronicasRepository(var fallo: Exception? = null) : CronicasRepository {
    // Pair<String, String> = una pareja de dos textos (título, texto); `titulo to texto` la crea.
    val publicadas = mutableListOf<Pair<String, String>>()
 
    override suspend fun publicar(titulo: String, texto: String): Result<Int> {
        fallo?.let { return Result.failure(it) }
        publicadas += titulo to texto
        return Result.success(101)
    }
}
app/src/test/java/com/sebastiannarvaez/marcador/feature/cronica/domain/PublicarCronicaTest.kt
package com.sebastiannarvaez.marcador.feature.cronica.domain
 
import com.sebastiannarvaez.marcador.core.FakeCronicasRepository
import com.sebastiannarvaez.marcador.core.FakePartidosRepository
import com.sebastiannarvaez.marcador.core.partidoConGoles
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
 
// La regla de la cronica, probada SIN pantalla y SIN red: dos repositorios falsos.
class PublicarCronicaTest {
 
    @Test
    fun escribeElTituloYUnaLineaPorGol() = runTest {
        // partidoConGoles (Falsos.kt): Rayo FC 2-1 Toros, goles al 15, 30 y 60.
        val cronicas = FakeCronicasRepository()
        val publicar = PublicarCronica(FakePartidosRepository(listOf(PartidoDeLista(1, partidoConGoles()))), cronicas)
 
        val resultado = publicar(1)    // publicar(1) llama al caso de uso, como una función
 
        val texto = "15' Ana (Rayo FC)\n30' Iván (Toros)\n60' Ana (Rayo FC)"
        assertEquals(Result.success(CronicaPublicada(101, "Rayo FC 2-1 Toros", texto)), resultado)
        assertEquals(listOf("Rayo FC 2-1 Toros" to texto), cronicas.publicadas)
    }
 
    @Test
    fun unPartidoQueNoExisteFallaYNoPublicaNada() = runTest {
        val cronicas = FakeCronicasRepository()
        // Dos pasos en una línea: PublicarCronica(…) crea el caso de uso y el (7) lo llama.
        val resultado = PublicarCronica(FakePartidosRepository(), cronicas)(7)
 
        assertEquals("No existe el partido 7", resultado.exceptionOrNull()?.message)
        assertTrue(cronicas.publicadas.isEmpty())
    }
}

Fíjate en cuánto ha adelgazado publicar() en el ViewModel: antes hacía el trabajo y ahora delega y traduce. Y el propio caso de uso es la pieza que ya conoce los dos repositorios.

Probarlo. Ejecuta la app (Run, variante gratisDebug), entra en un partido, pulsa «Crónica» y «Publicar»: debe salir «Crónica publicada con el n.º 101» y la tarjeta con el resumen y los goles, exactamente como en La pantalla de la crónica y sus pruebas. Y en la Terminal, ./gradlew :app:testGratisDebugUnitTest.

Punto de control

  • ./gradlew :app:testGratisDebugUnitTest termina en BUILD SUCCESSFUL y cuenta 19 tests con tus ejercicios y 16 sin ellos, y 0 fallos (los 2 tests nuevos son PublicarCronicaTest; el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine suma 3).
  • «Publicar» en la crónica de un partido sigue dando «Crónica publicada con el n.º 101» con la tarjeta del resumen y los goles; con el modo avión, «No hay conexión».
  • CronicaViewModel ya no importa PartidosRepository ni CronicasRepository: solo PublicarCronica.
  • ArbitrosViewModel sigue llamando al repositorio directamente, sin caso de uso, a propósito.

En Inyección en todas las capas ordenas los módulos de Hilt por capas y ves qué alcance tiene cada pieza: cuáles nacen una sola vez y cuáles, como este caso de uso, una nueva cada vez que se piden.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué PublicarCronica merece ser un caso de uso?

  2. 2.La pantalla de árbitros llama al repositorio directamente. ¿Qué pasaría con un caso de uso ObtenerArbitros que solo reenviara observarArbitros()?

  3. 3.¿En qué sentido van las dependencias entre capas?

  4. 4.Dentro de PublicarCronica, ¿para qué sirve getOrThrow() sobre el resultado de publicar?

  5. 5.Tras sacar la regla del CronicaViewModel, ¿qué le queda por hacer?

Faltan 5 de 5