wandres.dev
ARQUITECTURA DE APPS · patrones y capas

MV contra MVVM: el patrón que SwiftUI ya trae

SwiftUI no es un framework de vistas al que se le añade un patrón por encima: es un sistema donde la vista ya es una función del estado y el runtime ya hace de observador. Esta lección reconstruye el debate entre el patrón MV y el MVVM heredado de UIKit, examina qué argumento tiene cada bando y sostiene que la pregunta correcta no es cuál gana, sino qué problema concreto resuelve cada capa que decides añadir.

⏱ 18 min

Ningún debate de arquitectura en el mundo Apple ha consumido tanta tinta como este, y pocos se han discutido peor. De un lado están quienes sostienen que cada pantalla necesita su clase de modelo de vista, porque así se hacía en UIKit y así se hace en los demás ecosistemas. Del otro, quienes replican que SwiftUI ya trae su propio patrón incorporado y que envolver el estado en una capa más es reproducir la solución de un problema que ya no existe. La discusión degenera en preferencias personales porque casi nunca se hace la pregunta previa, que es histórica antes que técnica: qué trabajo concreto hacía un modelo de vista en el mundo imperativo, y cuánto de ese trabajo sigue vivo ahora que el framework observa el estado por ti y recalcula la interfaz sin que nadie se lo pida.

🎯 Al terminar esta lección sabrás
  • Reconstruir qué problema resolvía el modelo de vista en UIKit y cuánto de ese problema persiste hoy.
  • Entender el patrón MV como consecuencia técnica del ciclo de vida de View y del sistema de dependencias de SwiftUI.
  • Distinguir la lógica de presentación auténtica de la mera reexposición de propiedades del modelo.
  • Formular el criterio de decisión en términos de coste y beneficio observables, no de ortodoxia importada.

Qué resolvía el modelo de vista en UIKit

En una arquitectura imperativa, la pantalla es un objeto de larga vida con estado interno mutable, y alguien tiene que sincronizar ese estado con lo que se ve. Ese alguien era el controlador, y el modelo de vista nació para quitarle trabajo: guardaba los datos ya formateados y ofrecía un mecanismo de notificación para que la capa de interfaz supiera cuándo volver a pintarse.

// Mundo imperativo: alguien debe empujar cada cambio hacia la pantalla
final class PerfilViewModel {
    var nombreFormateado: String = "" { didSet { onChange?() } }
    var onChange: (() -> Void)?
}

final class PerfilViewController: UIViewController {
    let vm = PerfilViewModel()
    override func viewDidLoad() {
        super.viewDidLoad()
        vm.onChange = { [weak self] in self?.render() }   // el puente manual
    }
}

Ese puente manual era el verdadero producto del patrón. El modelo de vista existía porque la interfaz no sabía recalcularse sola: había que decirle cuándo, y había que decírselo sin que la vista conociera la red ni la base de datos. Cuando el puente desaparece, la justificación original desaparece con él, y lo que quede en pie tendrá que justificarse por otros motivos.

El patrón MV: la vista ya es el observador

Una View de SwiftUI no es un objeto que vive y se actualiza: es un valor efímero que el runtime construye, compara y descarta. Su body es una función pura del estado, y el sistema de observación decide cuándo volver a evaluarla. Bajo esa premisa, el modelo con lógica y las vistas que lo leen bastan: eso es lo que la comunidad llama patrón MV.

@Observable
final class Biblioteca {
    var libros: [Libro] = []
    var pendientes: [Libro] { libros.filter { !$0.leido } }
    func marcarLeido(_ id: Libro.ID) {
        guard let i = libros.firstIndex(where: { $0.id == id }) else { return }
        libros[i].leido = true
    }
}

struct BibliotecaView: View {
    @Environment(Biblioteca.self) private var biblioteca
    var body: some View {
        List(biblioteca.pendientes) { libro in
            Text(libro.titulo)
        }
    }
}
🏛️

La vista es una función

El body se recalcula a partir del estado. Nadie tiene que empujar cambios hacia la pantalla.

🔭

La observación es del framework

@Observable registra qué propiedades leyó cada vista e invalida solo a quienes dependen de ellas.

🧩

El modelo es compartible

Un modelo de dominio sirve a varias pantallas. Un modelo de vista por pantalla fragmenta esa verdad.

🧪

La lógica ya es testeable

Si el cálculo vive en el modelo y no en el body, se prueba sin renderizar absolutamente nada.

El argumento a favor de conservar el modelo de vista

El bando contrario no defiende la ceremonia por la ceremonia. Su tesis es que el estado de presentación existe aunque el framework observe solo: el texto a medio escribir de un formulario, el error de validación, el indicador de carga o el destino de navegación no pertenecen al dominio, y meterlos en el modelo compartido lo contamina. Un modelo de vista es entonces un modelo más, uno cuyo dominio es la pantalla.

Pregunta Patrón MV Modelo de vista dedicado
Dónde vive el estado de dominio en un modelo observable compartido en el mismo sitio, sin cambios
Dónde vive el estado de pantalla en @State dentro de la vista en una clase propia, fuera del body
Coste de una pantalla trivial ninguno un archivo y un tipo por cada vista
Ganancia en pantallas complejas poca: el body crece y se enreda alta: la lógica se prueba aislada
Riesgo característico mezclar dominio y presentación escribir capas que solo reenvían datos
// Estado de pantalla que no pertenece al dominio
@Observable
final class AltaViewModel {
    var email = ""
    var repetir = ""
    var aceptaTerminos = false
    private(set) var enviando = false

    var emailValido: Bool { email.contains("@") && email.contains(".") }
    var coinciden: Bool { !repetir.isEmpty && email == repetir }
    var puedeEnviar: Bool { emailValido && coinciden && aceptaTerminos && !enviando }
}

Ninguna de esas cuatro propiedades tiene sentido fuera de esta pantalla, y ninguna debería vivir en el modelo de la aplicación. Meterlas ahí sería el error simétrico del que se acusa al bando contrario: contaminar el dominio con el vocabulario de una interfaz concreta.

La honestidad exige reconocer que ambos bandos tienen razón en su terreno. Una pantalla que muestra una lista y navega no gana nada con una capa extra. Un formulario de alta con siete campos, validación cruzada, envío asíncrono y estados de error diferenciados sí gana, y quien lo niega termina con un body de doscientas líneas que ninguna prueba puede tocar.

💡
El síntoma que decide, no la doctrina

Hay una prueba mecánica que resuelve el noventa por ciento de los casos. Abre tu candidato a modelo de vista y tapa mentalmente las propiedades que solo leen del modelo y lo devuelven tal cual. Si al taparlas no queda casi nada, esa clase no es una capa: es un reenvío con nombre propio. Si en cambio queda un conjunto de reglas de validación, transformación o coordinación, la capa se ha ganado su sitio.

flowchart TD
E[Cambia el estado observable] --> R[El runtime invalida las vistas dependientes]
R --> B[Se reevalua el body como funcion pura]
B --> D[SwiftUI difunde el arbol y actualiza la pantalla]
V[Modelo de vista clasico] -.-> N[Notificar a mano cuando repintar]
N -.-> X[Trabajo que el runtime ya hace por ti]
style D fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111b

Cómo se decide sin doctrina

El error de método consiste en elegir el patrón antes de conocer la pantalla. La decisión es local: se toma vista a vista, se revisa cuando la vista crece y no compromete a las demás. Un proyecto sano puede tener treinta pantallas en MV puro y cuatro con modelo de vista, y esa mezcla no es incoherencia sino proporción. Lo incoherente es la regla uniforme aplicada sin mirar, en cualquiera de las dos direcciones.

Hay además un matiz que casi siempre disuelve la discusión antes de que empiece: el debate confunde dos ejes distintos. Uno es dónde vive el estado de presentación, que es lo que MV y MVVM discuten de verdad. El otro es si el dominio está separado del transporte, que es una cuestión independiente y bastante más importante. Una app puede ser MV puro y estar impecablemente estratificada, y otra puede tener un modelo de vista por pantalla y llamar a la red desde dentro de todos ellos. La segunda tiene un problema grave que ningún patrón de presentación resuelve, y sin embargo la discusión pública dedica cien veces más energía al primer eje que al segundo.

📝
Vocabulario: MV no es una sigla oficial de Apple

Apple nunca ha publicado un patrón llamado MV. El nombre lo acuñó la comunidad para describir lo que la documentación y las sesiones de la conferencia muestran de facto en sus ejemplos: modelos observables y vistas que los leen, sin capa intermedia. Conviene saberlo para no discutir como si hubiera una autoridad que zanje el asunto.

Un patrón es la sombra de una restricción, y las restricciones caducan

Todo patrón de arquitectura es la respuesta cristalizada a una limitación técnica de su época, y sobrevive con frecuencia a la limitación que lo engendró. El modelo de vista clásico nació para resolver dos carencias muy concretas del mundo imperativo: que la interfaz no sabía recalcularse sola y que el controlador acumulaba responsabilidades por gravedad. SwiftUI eliminó la primera de raíz al hacer del body una función pura del estado y al poner la observación dentro del framework, y atacó la segunda al convertir la vista en un valor barato que se compone en lugar de heredarse. Quien mantiene el patrón intacto después de eso no está siendo prudente: está conservando la forma de una solución cuya causa ya no está en la sala. Pero el movimiento inverso comete un error simétrico y peor argumentado, porque de que el framework observe por ti no se sigue que el estado de presentación haya dejado de existir. La validación cruzada de un formulario, la coordinación de tres fuentes asíncronas o la máquina de estados de un asistente por pasos son lógica genuina, y esa lógica tiene que vivir en algún sitio con nombre. La conclusión no es un patrón sino un método: identifica qué restricción justifica cada capa que escribes, comprueba si esa restricción sigue vigente en tu framework y en tu pantalla, y borra sin nostalgia lo que solo sobrevive por costumbre. Una arquitectura no se elige, se deriva; y se deriva de problemas presentes, no de la memoria muscular de un ecosistema que ya dejaste atrás.

⚔️ Audita el reenvío de tu proyecto
  1. Elige tres pantallas de una app tuya y clasifica cada propiedad de su capa intermedia en dos cubos: reenvío puro o lógica genuina.
  2. Para las que sean reenvío puro, elimina la capa y conecta la vista al modelo observable. Mide cuántas líneas desaparecen.
  3. Para la pantalla más compleja de las tres, haz lo contrario: extrae la validación y la coordinación a un tipo propio y escribe una prueba que no renderice nada.
  4. Escribe en dos frases la restricción concreta que justifica cada capa que hayas decidido conservar. Si no puedes nombrarla, la capa sobra.
  5. Repite el ejercicio dentro de un mes sobre la misma pantalla y comprueba si la respuesta cambió al crecer la app.