Saltar al contenido
K
Arquitectura en iOSPaso a paso35 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, 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 validaSolo reenvía la llamada al repositorio
Junta dos o más repositoriosUsa uno solo, sin regla
Se reutiliza en varias pantallasLo usa una pantalla y es trivial
Quieres probar la regla sin pantallaNo 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  →  datos

El 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.

ejemplo mínimo: un caso de uso con dos colaboradores (con comentarios)
// 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 sushi

En 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:

ejemplo mínimo: un caso de uso que sobra (con comentarios)
// 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 un id y 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 una Biblioteca y un Avisador, 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 un Result con 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 9
Ver solución
solución: avisar de una devolució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 9

Con 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.

MarcadorApp (proyecto)
MarcadorApp
Cronica
Domain
PublicarCronica.swiftse crea
En el Project navigator: un fichero nuevo.

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 struct con dos repositorios: partidos (un any PartidosRepositorio) y cronicas (un any CronicasRepositorio).
  • Tiene un fallo comparable, Fallo, que además enseña un texto con LocalizedError: noExisteElPartido (con el id; «No existe el partido 7»), red (con un ErrorDeRed; su mismo texto) y otro (con un texto; ese texto).
  • redactar(partidoId:) devuelve la Cronica del 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 devuelve Result<Int, Fallo>: si no hay crónica, el fallo noExisteElPartido; 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 un ErrorDeRed, lo envuelve en red; cualquier otro error, en otro.

¿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
MarcadorApp/Cronica/Domain/PublicarCronica.swift
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.

MarcadorApp (proyecto)
MarcadorApp
Cronica
UI
CronicaViewModel.swiftse modifica
Partidos
UI
ListaDePartidosView.swiftse modifica
En el Project navigator: dos ficheros cambian.

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
MarcadorApp/Cronica/UI/CronicaViewModel.swift
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
MarcadorApp/Partidos/UI/ListaDePartidosView.swift
                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 RepositorioFalso de Tests en iOS: falsos y espías y un ServicioFalso detrás de un RepositorioDeCronicasEnRed, 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
MarcadorAppTests/Cronica/Domain/PublicarCronicaTests.swift
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, y CronicaViewModel ya no recibe dos repositorios, ni tiene redactar, ni importa Torneo.
  • Cmd+B termina en Build Succeeded y 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 de PublicarCronicaTests. 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. 1.PublicarCronica compensa como caso de uso. ¿Cuál es la razón principal?

  2. 2.¿Por qué ArbitrosViewModel llama directamente al repositorio y no a un ObtenerArbitros?

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

  4. 4.PublicarCronica.Fallo tiene un caso otro(String). ¿Para qué sirve?

  5. 5.¿Qué ganó CronicaViewModel al mudar la regla a PublicarCronica?

  6. 6.Las pruebas de PublicarCronica no necesitan pantalla ni red. ¿Por qué?

Faltan 6 de 6