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, la regla «cómo se escribe la crónica de un partido» vivirá en un caso de uso propio, PublicarCronica, que junta dos repositorios (el de partidos y el de crónicas). El CronicaViewModel quedará mucho más corto: enseña la crónica y avisa al caso de uso. La app se verá igual que antes. Y sabrás decidir, con una tabla, cuándo un caso de uso compensa y cuándo sobra: con los árbitros, por ejemplo, no habrá ninguno.
Importa porque un caso de uso mal puesto es el defecto más común de las apps «con arquitectura»: capas que solo reenvían llamadas y no hacen nada. Temas: cuándo sí y cuándo no, la dirección de las dependencias, un caso de uso con dos repositorios y cómo cambia el ViewModel.
Qué es un caso de uso, y por qué no basta con el ViewModel
Un caso de uso es una acción con sentido para el negocio de la app («publicar la crónica», «registrar un gol»), con sus reglas, escrita en un sitio que no es ni la pantalla, ni el ViewModel, ni el repositorio.
Un caso de uso compensa cuando hay algo que decidir. Cuatro señales:
| Compensa (sí) | Sobra (no) |
|---|---|
| Hay una regla de negocio: cómo se calcula, qué se valida | Solo reenvía la llamada al repositorio |
| Junta dos o más repositorios | Usa uno solo, sin regla |
| Se reutiliza en varias pantallas | Lo usa una pantalla y es trivial |
| Quieres probar la regla sin pantalla | No hay regla que probar: con probar el ViewModel basta |
Y una regla de oro sobre quién conoce a quién: las dependencias van en un solo sentido.
Vista → ViewModel → caso de uso → repositorios → datosEl ViewModel conoce al caso de uso; el caso de uso conoce a los repositorios; nunca al revés. Un repositorio que llamara a un ViewModel obligaría a probar la pantalla para probar los datos.
Un ejemplo con otros datos: una pizzería
Pedir una pizza junta dos cosas: la carta (¿existe ese plato y cuánto cuesta?) y la cocina (recibe la comanda). La regla («una comanda dice mesa, cantidad, plato y total») es negocio, así que se escribe una vez en un caso de uso.
// Los dos repositorios, como contratos (protocolos): el caso de uso no sabe cómo funcionan por dentro.
protocol Carta { func precio(de plato: String) -> Int? } // nil: ese plato no existe
protocol Cocina { func recibir(_ comanda: String) -> Int } // devuelve el número de pedido
struct PrepararPedido {
let carta: any Carta
let cocina: any Cocina
enum Fallo: Error, Equatable { // el fallo esperable, como valor
case noHayEsePlato(String)
}
// callAsFunction: se llama como una función, `prepararPedido(mesa: 4, …)`.
func callAsFunction(mesa: Int, plato: String, cantidad: Int) -> Result<Int, Fallo> {
// Regla 1: el plato tiene que estar en la carta.
guard let precio = carta.precio(de: plato) else { return .failure(.noHayEsePlato(plato)) }
// Regla 2: cómo se escribe la comanda.
let total = precio * cantidad
let numero = cocina.recibir("Mesa \(mesa): \(cantidad) × \(plato) (\(total) €)")
return .success(numero)
}
}
// Dos imitaciones para probar, sin cocina de verdad.
struct CartaDeMentira: Carta {
func precio(de plato: String) -> Int? { plato == "margarita" ? 9 : nil }
}
struct CocinaQueImprime: Cocina {
func recibir(_ comanda: String) -> Int {
print("comanda:", comanda)
return 7
}
}
let prepararPedido = PrepararPedido(carta: CartaDeMentira(), cocina: CocinaQueImprime())
// Convierte el Result en una línea de texto.
func contar(_ resultado: Result<Int, PrepararPedido.Fallo>) {
switch resultado {
case let .success(numero): print("pedido n.º \(numero)")
case let .failure(.noHayEsePlato(plato)): print("no hay \(plato)") // un patrón dentro de otro: fallo, y dentro, ese caso con su plato
}
}
contar(prepararPedido(mesa: 4, plato: "margarita", cantidad: 2))
contar(prepararPedido(mesa: 4, plato: "sushi", cantidad: 1))comanda: Mesa 4: 2 × margarita (18 €)
pedido n.º 7
no hay sushiEn precio(de plato: String), de es la etiqueta que se escribe al llamar (precio(de: plato)) y plato es el nombre que se usa dentro de la función.
Compensa por las cuatro señales: hay regla (la comanda y el fallo), junta dos colaboradores y se puede probar sin pantalla ni cocina real.
Y uno que sobra. Si solo hubiera que preguntar un precio, el «caso de uso» no aportaría nada:
// Solo reenvía la llamada a la carta. No hay regla, no hay segundo colaborador.
// La pantalla (o su ViewModel) puede preguntar a `Carta` directamente.
struct ObtenerPrecio {
let carta: any Carta
func callAsFunction(plato: String) -> Int? { carta.precio(de: plato) }
}Escribirlo es ceremonia: un fichero más, una prueba más y un salto más para llegar al dato, sin ganar nada. Eso es lo que no hay que hacer.
Reto · Ejercicio: avisar de una devolución
En tu Playground (reutiliza el mismo y borra lo anterior), escribe el caso de uso de una biblioteca. Piezas:
Libro, con unidy un título.- Un protocolo
Biblioteca, con una función que recibe el id de un libro y devuelve el libro o nada. - Un protocolo
Avisador, con una función que envía un texto y devuelve el número del aviso. AvisarDevolucion, el caso de uso: recibe unaBibliotecay unAvisador, tiene un fallo comparable,noExisteElLibro, con el id; y se llama como una función con el id del libro y el nombre del socio, y devuelve unResultcon el número del aviso o el fallo. Si el libro existe, envía el aviso «Lucía, devuelve «Dune» antes del viernes» (con el nombre del socio y el título del libro). Si no existe, no envía nada.- Una biblioteca de mentira que solo conoce el libro 1, «Dune», y un avisador que imprime «enviado: » seguido del texto y devuelve 101.
Llámalo con el libro 1 y con el 9, y escribe el resultado con un switch: «aviso n.º » con el número, o «fallo: no existe el libro » con el id. Lo que debe salir:
enviado: Lucía, devuelve «Dune» antes del viernes
aviso n.º 101
fallo: no existe el libro 9Ver solución
struct Libro {
let id: Int
let titulo: String
}
protocol Biblioteca {
func libro(id: Int) -> Libro?
}
protocol Avisador {
func enviar(_ texto: String) -> Int
}
struct AvisarDevolucion {
let biblioteca: any Biblioteca
let avisador: any Avisador
enum Fallo: Error, Equatable {
case noExisteElLibro(Int)
}
func callAsFunction(libroId: Int, socio: String) -> Result<Int, Fallo> {
guard let libro = biblioteca.libro(id: libroId) else {
return .failure(.noExisteElLibro(libroId)) // sin libro, no se envía nada
}
let numero = avisador.enviar("\(socio), devuelve «\(libro.titulo)» antes del viernes")
return .success(numero)
}
}
struct BibliotecaDeMentira: Biblioteca {
func libro(id: Int) -> Libro? { id == 1 ? Libro(id: 1, titulo: "Dune") : nil }
}
struct AvisadorQueImprime: Avisador {
func enviar(_ texto: String) -> Int {
print("enviado: \(texto)")
return 101
}
}
let avisarDevolucion = AvisarDevolucion(biblioteca: BibliotecaDeMentira(), avisador: AvisadorQueImprime())
func contar(_ resultado: Result<Int, AvisarDevolucion.Fallo>) {
switch resultado {
case let .success(numero): print("aviso n.º \(numero)")
case let .failure(.noExisteElLibro(id)): print("fallo: no existe el libro \(id)")
}
}
contar(avisarDevolucion(libroId: 1, socio: "Lucía"))
contar(avisarDevolucion(libroId: 9, socio: "Lucía"))enviado: Lucía, devuelve «Dune» antes del viernes
aviso n.º 101
fallo: no existe el libro 9Con el libro 9 no sale ningún «enviado»: la regla corta antes de tocar el avisador. Y fíjate en que las pruebas de esta regla no necesitan pantalla, ni red, ni biblioteca real.
Paso 1 · El caso de uso: PublicarCronica.swift
Objetivo: un fichero nuevo con la regla de la crónica y su publicación. Todavía no lo usa nadie.
El caso de uso es una regla de la crónica y no sabe de red: va en Cronica/Domain. Clic derecho sobre la carpeta Cronica de MarcadorApp (no la de MarcadorAppTests) → New Folder → Domain; después, clic derecho sobre esa Domain → New File from Template… → Swift File → nombre PublicarCronica (solo el target MarcadorApp). Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/Domain && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/Domain/PublicarCronica.swift.
Qué debe hacer, mirando lo que hoy hace CronicaViewModel y que se va a mudar:
- Es un
structcon dos repositorios:partidos(unany PartidosRepositorio) ycronicas(unany CronicasRepositorio). - Tiene un fallo comparable,
Fallo, que además enseña un texto conLocalizedError:noExisteElPartido(con el id; «No existe el partido 7»),red(con unErrorDeRed; su mismo texto) yotro(con un texto; ese texto). redactar(partidoId:)devuelve laCronicadel partido, o nada si no existe. Es la regla: el título es«local marcador visitante»(por ejemplo «Rayo FC 2-1 Toros») y el texto, una línea por gol con el formato «12' Ana (Rayo FC)» (minuto, jugador y equipo), o «Sin goles» si no hubo.callAsFunction(partidoId:)es asíncrona y devuelveResult<Int, Fallo>: si no hay crónica, el fallonoExisteElPartido; si la hay, la publica con el repositorio de crónicas y devuelve el número que le dio el servidor. Si lo que sale es unErrorDeRed, lo envuelve enred; cualquier otro error, enotro.
¿Por qué otro? Un Result<Int, Fallo> solo admite fallos de tipo Fallo: no cabe ningún otro error. Pero el repositorio de crónicas puede lanzar cualquiera (por ejemplo, una respuesta del servidor con otra forma que no se puede leer). Todo error que llegue tiene que convertirse en uno de los casos de Fallo, y otro es el sitio donde meter lo que no esperabas.
Piezas que ya conoces: if case let .gol(minuto, jugador, equipo) = evento para sacar los datos de un gol (Enums: el partido de eventos), partido.marcador(), joined(separator:) y catch let error as ErrorDeRed, que solo entra en ese catch si el error es de ese tipo (Errores: throws, try y do/catch). El resto de errores caen en el catch de abajo.
Intenta escribirlo tú; Cmd+B debe compilar.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Torneo
// CASO DE USO QUE SÍ COMPENSA (gemelo de `PublicarCronica.kt`).
//
// Junta DOS repositorios (los partidos y las crónicas) y guarda una REGLA de negocio: cómo se
// escribe la crónica de un partido. El ViewModel solo dice «publica la del partido 3».
//
// Dirección de las dependencias: ViewModel → caso de uso → repositorios. Nunca al revés.
// (Con los árbitros NO hay caso de uso: un `ObtenerArbitros` solo reenviaría la llamada al repositorio.)
struct PublicarCronica {
let partidos: any PartidosRepositorio
let cronicas: any CronicasRepositorio
// Los fallos esperables, en un `Result` tipado, como en `RegistrarGol`.
enum Fallo: Error, Equatable, LocalizedError {
case noExisteElPartido(Int)
case red(ErrorDeRed)
case otro(String) // cualquier otro fallo (por ejemplo, una respuesta con otra forma)
var errorDescription: String? {
switch self {
case let .noExisteElPartido(id): "No existe el partido \(id)"
case let .red(error): error.errorDescription
case let .otro(texto): texto
}
}
}
// LA REGLA: el título es el resultado («Rayo FC 2-1 Toros») y el texto, una línea por gol
// («12' Ana (Rayo FC)»), o «Sin goles». La pantalla la usa para enseñarla antes de publicar.
func redactar(partidoId: Int) -> Cronica? {
guard let partido = partidos.partido(id: partidoId) else { return nil }
var lineas: [String] = []
for evento in partido.eventos {
if case let .gol(minuto, jugador, equipo) = evento {
lineas.append("\(minuto)' \(jugador.nombre) (\(equipo.nombre))")
}
}
return Cronica(
titulo: "\(partido.local.nombre) \(partido.marcador()) \(partido.visitante.nombre)",
texto: lineas.isEmpty ? "Sin goles" : lineas.joined(separator: "\n")
)
}
// `callAsFunction`: el caso de uso se llama como una función, `publicarCronica(partidoId: 1)`.
func callAsFunction(partidoId: Int) async -> Result<Int, Fallo> {
guard let cronica = redactar(partidoId: partidoId) else {
return .failure(.noExisteElPartido(partidoId))
}
do {
let id = try await cronicas.publicar(titulo: cronica.titulo, texto: cronica.texto)
return .success(id)
} catch let error as ErrorDeRed {
return .failure(.red(error))
} catch {
return .failure(.otro(error.localizedDescription))
}
}
}Hay dos funciones y no una porque la pantalla necesita la crónica antes de publicar (para enseñarte el título y los goles) y el caso de uso la necesita al publicar. La regla se escribe una sola vez, en redactar, y las dos la usan.
Paso 2 · El ViewModel, mucho más corto
Objetivo: que CronicaViewModel reciba el caso de uso en vez de dos repositorios, y pierda la regla.
Antes, el ViewModel recibía partidos y cronicas, tenía una función privada redactar con la regla y publicaba él. Ahora recibe un PublicarCronica, le pide la crónica al arrancar y le dice «publica» al pulsar el botón. Todo lo demás (el estado de la pantalla y su vista) no cambia.
Intenta reescribir CronicaViewModel.swift: el init recibe partidoId y publicarCronica y los guarda (publicarCronica es un PublicarCronica tal cual: no tiene protocolo, es un struct); si redactar devuelve una crónica, el estado empieza con ella; si no, con el mensaje «No existe el partido» y el id. La función publicar() sigue igual en lo que enseña (publicando, el mensaje de éxito «Crónica publicada con el n.º» y el número, y el registro), pero la llamada es al caso de uso: await publicarCronica(partidoId:) devuelve un Result y se abre con un switch; el texto del fallo es fallo.localizedDescription.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Observation
// LO QUE VE LA PANTALLA DE LA CRÓNICA.
struct CronicaUiState: Equatable {
var cronica: Cronica? // nil: el partido no existe
var publicando = false
var mensaje: String? // «Crónica publicada con el n.º 101», o el error
}
// Ahora el ViewModel solo hace lo suyo: enseñar la crónica y avisar al caso de uso.
// Cómo se escribe y con qué repositorios se publica lo sabe `PublicarCronica`.
@MainActor
@Observable
final class CronicaViewModel {
private(set) var uiState: CronicaUiState
@ObservationIgnored private let partidoId: Int
@ObservationIgnored private let publicarCronica: PublicarCronica
init(partidoId: Int, publicarCronica: PublicarCronica) {
self.partidoId = partidoId
self.publicarCronica = publicarCronica
if let cronica = publicarCronica.redactar(partidoId: partidoId) {
uiState = CronicaUiState(cronica: cronica)
} else {
uiState = CronicaUiState(mensaje: "No existe el partido \(partidoId)")
}
}
// El botón «Publicar».
func publicar() async {
guard uiState.cronica != nil else { return }
uiState.publicando = true
uiState.mensaje = nil
switch await publicarCronica(partidoId: partidoId) {
case let .success(id):
uiState.mensaje = "Crónica publicada con el n.º \(id)"
case let .failure(fallo):
uiState.mensaje = fallo.localizedDescription
}
uiState.publicando = false
Registro.anotar("crónica: \(uiState.mensaje ?? "")")
}
}Fíjate en lo que se fue: el ViewModel ya no importa Torneo, ya no sabe qué es un gol ni cómo se escribe una línea de crónica, y ya no conoce el repositorio de crónicas. Si mañana la crónica lleva el árbitro, se cambia PublicarCronica y el ViewModel ni se entera.
La lista de partidos construye el caso de uso donde se abre la crónica (sigue siendo «a escondidas»; la lección siguiente lo corrige). Solo cambia el destino .cronica de ListaDePartidosView.swift:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
case let .cronica(id):
// También a escondidas: el servicio y el repositorio de crónicas nacen aquí.
CronicaView(viewModel: CronicaViewModel(
partidoId: id,
publicarCronica: PublicarCronica(
partidos: repositorio,
cronicas: RepositorioDeCronicasEnRed(servicio: ServicioJSONPlaceholder())
)
))El resto del fichero no cambia. Compila con Cmd+B: Build Succeeded. Ejecuta la app: «Crónica» dentro de un partido enseña lo mismo que antes, y «Publicar» sigue dando «Crónica publicada con el n.º 101».
Los árbitros no pasan por ningún caso de uso, y es a propósito. Mira ArbitrosViewModel: llama directamente a repositorio.refrescar(), un repositorio, sin regla. Un ObtenerArbitros sería el ObtenerPrecio de la pizzería.
Paso 3 · Probar la regla sin pantalla: PublicarCronicaTests.swift
Objetivo: dos pruebas del caso de uso con dos falsos, sin pantalla y sin red.
Crea PublicarCronicaTests.swift en MarcadorAppTests/Cronica/Domain (clic derecho sobre Cronica, dentro de MarcadorAppTests → New Folder → Domain; en Domain, New File from Template… → Swift File, solo el target MarcadorAppTests; con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Cronica/Domain && touch ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Cronica/Domain/PublicarCronicaTests.swift).
Lo que prueba cada una:
- Publica el título y una línea por gol del partido 1. Con el
RepositorioFalsode Tests en iOS: falsos y espías y unServicioFalsodetrás de unRepositorioDeCronicasEnRed, llamar al caso de uso con el partido 1 devuelve.success(101). El partido 1 de ese falso es «Rayo FC 2-1 Toros», con tres goles: 12' Ana, 55' Iván y 80' Ana (el de la app es el 3-2 de siempre: son datos distintos a propósito, para que la prueba no dependa de los datos de ejemplo). Lo que viajó al servidor tiene como título «Rayo FC 2-1 Toros» y como cuerpo «12' Ana (Rayo FC)», «55' Iván (Toros)» y «80' Ana (Rayo FC)» en tres líneas. - Un partido que no existe no llega al servidor. Con el partido 7, el resultado es
.failure(.noExisteElPartido(7)), el texto del fallo es «No existe el partido 7» y el servicio falso no recibió nada.
Intenta escribirlas tú. El ServicioFalso anota en publicadas lo que le envían (con title y body).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Testing
import Torneo
@testable import MarcadorApp
// El caso de uso con DOS falsos: el repositorio de partidos y el servicio. Sin pantalla y sin red.
@MainActor
struct PublicarCronicaTests {
@Test func publicaElTituloYUnaLineaPorGolDelPartidoUno() async {
let servicio = ServicioFalso()
let publicar = PublicarCronica(partidos: RepositorioFalso(), cronicas: RepositorioDeCronicasEnRed(servicio: servicio))
#expect(await publicar(partidoId: 1) == .success(101))
// El partido 1 del falso es Rayo FC 2-1 Toros: tres goles (la tarjeta y el cambio no cuentan).
#expect(servicio.publicadas.first?.title == "Rayo FC 2-1 Toros")
#expect(servicio.publicadas.first?.body == "12' Ana (Rayo FC)\n55' Iván (Toros)\n80' Ana (Rayo FC)")
}
@Test func unPartidoQueNoExisteNoLlegaAlServidor() async {
let servicio = ServicioFalso()
let publicar = PublicarCronica(partidos: RepositorioFalso(), cronicas: RepositorioDeCronicasEnRed(servicio: servicio))
let resultado = await publicar(partidoId: 7)
#expect(resultado == .failure(.noExisteElPartido(7)))
// El texto que verá la pantalla.
#expect(PublicarCronica.Fallo.noExisteElPartido(7).localizedDescription == "No existe el partido 7")
#expect(servicio.publicadas.isEmpty)
}
}Dos cosas del fichero. #expect(await publicar(partidoId: 1) == .success(101)) compara un Result entero con ==, y funciona porque el Fallo es Equatable. Y import Foundation arriba no es decoración:
Las pruebas del caso de uso son las que más rinden: comprueban la regla de verdad, y no tocan pantalla, ni red, ni disco.
Ejecuta con Cmd+U.
Punto de control
- Existe
PublicarCronica.swift, yCronicaViewModelya no recibe dos repositorios, ni tieneredactar, ni importaTorneo. - Cmd+B termina en
Build Succeededy Cmd+U da 50 pruebas en verde (con tus ejercicios de pruebas, 53; sin ellos, 50): 34 unitarias (30 de Swift Testing y 4 de XCTest) y 16 de interfaz. Son dos más que antes: las dePublicarCronicaTests. Los ejercicios con pruebas propias, como la taquilla, suman tres más: no es un error. - La app se ve igual: «Crónica» enseña el título y los goles, y «Publicar» da «Crónica publicada con el n.º 101».
- Sabes decir por qué los árbitros no tienen caso de uso y la crónica sí.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.
PublicarCronicacompensa como caso de uso. ¿Cuál es la razón principal?2.¿Por qué
ArbitrosViewModelllama directamente al repositorio y no a unObtenerArbitros?3.¿En qué sentido van las dependencias entre las capas?
4.
PublicarCronica.Fallotiene un casootro(String). ¿Para qué sirve?5.¿Qué ganó
CronicaViewModelal mudar la regla aPublicarCronica?6.Las pruebas de
PublicarCronicano necesitan pantalla ni red. ¿Por qué?