MV es el patrón que Apple muestra oficialmente desde la introducción de Observation (WWDC23). La vista observa modelos @Observable directamente mediante @State y @Environment, sin una capa ViewModel intermedia. La lógica de negocio vive en el modelo y en *services* inyectados; la vista solo describe la UI y reacciona al estado.
La idea clave es que las vistas SwiftUI ya son una proyección declarativa del estado — son structs ligeros y desechables. Añadir un ViewModel por cada vista duplica esa función y pelea contra el diseño del framework. Cuando una vista crece, se parte en sub-vistas, no se le cuelga un ViewModel.
Es el default recomendado para apps SwiftUI nuevas: mínimo boilerplate, máxima afinidad con el framework, y muy fácil de dirigir con una IA porque hay menos capas que coordinar.
- Pide primero los modelos `@Observable` y los structs de datos (el dominio).
- Luego los protocolos de los services (el contrato), antes que su implementación.
- Después la implementación concreta de los services.
- Al final las vistas, que consumen el modelo vía `@Environment`/`@State`.
- Que NO haya aparecido una clase `…ViewModel` innecesaria por cada vista.
- Que los services se inyecten por protocolo, no se instancien dentro del modelo.
- Que la lógica de negocio esté en el modelo/service, no en el `body` de la vista.
- Que se use `@Observable` (iOS 17+), no el viejo `ObservableObject`/`@Published`, salvo que el target lo exija.
- La IA añade un ViewModel por costumbre aunque no aporte nada — pídele que lo quite y use el modelo directo.
- Mete llamadas de red dentro del `body` de la vista en vez de en el modelo.
- Instancia services con `init()` concreto dentro del modelo, rompiendo la testeabilidad.
// Modelo observable: hace el trabajo, sin ViewModel
@Observable
final class LibraryStore {
private(set) var books: [Book] = []
var isLoading = false
private let api: BooksAPI
init(api: BooksAPI) { self.api = api }
func load() async {
isLoading = true
defer { isLoading = false }
books = (try? await api.fetchBooks()) ?? []
}
}
// La vista usa el modelo directamente
struct LibraryView: View {
@Environment(LibraryStore.self) private var store
var body: some View {
List(store.books) { BookRow(book: $0) }
.overlay { if store.isLoading { ProgressView() } }
.task { await store.load() }
}
} - Mínimo boilerplate; menos archivos que mantener.
- Es el enfoque oficial de Apple para SwiftUI.
- Muy fácil de dirigir con IA: pocas capas, contratos claros.
- Rendimiento: la observación granular de `@Observable` re-renderiza solo lo necesario.
- Menos prescriptivo: sin disciplina, la lógica puede filtrarse a las vistas.
- La testeabilidad depende de inyectar bien los services (no es automática).
- A gran escala conviene complementarlo con Clean o TCA.
MVVM separa la vista de su estado de presentación mediante un *ViewModel*: una clase que expone los datos ya listos para mostrar y los comandos que la vista invoca. Fue el estándar de facto en la era UIKit + Combine y sigue siendo sólido ahí.
En SwiftUI el panorama cambió. Como las vistas ya son una proyección declarativa del estado, un ViewModel por vista suele duplicar lo que el framework hace solo, y se considera sobreingeniería para apps simples. Por eso aquí no es el default de SwiftUI.
Dónde sí aporta en SwiftUI: para puentear APIs no-SwiftUI (UIKit, Combine, delegados), para estado de presentación compartido entre varias vistas, para modelos de referencia de larga vida con comportamiento, o por consistencia de equipo. Es un debate abierto: hay devs que la defienden por la separación y la testeabilidad que impone.
- Pide primero el Model (datos y reglas puras).
- Luego el ViewModel: propiedades de presentación + comandos, con dependencias por protocolo.
- Al final la vista, que solo observa el ViewModel y dispara comandos.
- El ViewModel NO importa SwiftUI ni conoce tipos de vista.
- Una sola responsabilidad por ViewModel; si crece, dividir.
- Dependencias inyectadas por protocolo (testeable).
- ¿De verdad hace falta el ViewModel aquí, o es sobreingeniería? Si la vista es simple, considera MV.
- La IA mete tipos de UIKit/SwiftUI dentro del ViewModel, rompiendo la separación.
- Crea un ViewModel anémico que solo reenvía al modelo (señal de que sobra).
- Duplica en el ViewModel estado que SwiftUI ya maneja con `@State`.
// ViewModel: media entre la vista y el modelo
@Observable
final class LibraryViewModel {
private(set) var books: [Book] = []
var isLoading = false
private let api: BooksAPI
init(api: BooksAPI) { self.api = api }
func load() async {
isLoading = true; defer { isLoading = false }
books = (try? await api.fetchBooks()) ?? []
}
}
struct LibraryView: View {
@State private var vm: LibraryViewModel
init(api: BooksAPI) { _vm = State(initialValue: LibraryViewModel(api: api)) }
var body: some View {
List(vm.books) { BookRow(book: $0) }
.task { await vm.load() }
}
} - Separación clara de presentación y lógica.
- ViewModels muy testeables de forma aislada.
- Familiar para equipos que vienen de UIKit.
- Boilerplate: un ViewModel por vista suma archivos.
- En SwiftUI suele duplicar lo que el framework ya hace.
- Riesgo de ViewModels anémicos o "god objects".
Clean Architecture organiza el código en capas con una regla de dependencia: las capas externas dependen de las internas, nunca al revés. El Domain (entidades + casos de uso) es el núcleo y no conoce ningún framework; Data (repositorios + data sources) y Presentation (vistas + modelos) dependen de él a través de protocolos.
No compite con MV o MVVM: se monta encima. La capa de presentación puede ser MV (un modelo @Observable) o MVVM, y debajo consume casos de uso del dominio. Esto da una testeabilidad y una escalabilidad excelentes a costa de más archivos y ceremonia.
Vale la pena en apps grandes, equipos múltiples, o dominios complejos que cambian de fuente de datos. Para una app pequeña es claramente excesivo.
- Pide primero el Domain: entidades y protocolos de casos de uso y repositorios. Es el contrato.
- Luego la capa Data: implementaciones de repositorios y data sources.
- Después la Presentation: modelos MV/MVVM que consumen los casos de uso.
- Al final las vistas y el wiring de inyección de dependencias.
- La regla de dependencia se respeta: Domain no importa nada de Data ni Presentation.
- Los DTO de red/BD no se filtran al Domain (hay mapeo a entidades).
- Cada caso de uso tiene una sola responsabilidad.
- ¿La app justifica esta ceremonia, o es excesiva para su tamaño?
- La IA hace que el Domain importe el framework de red (viola la regla de dependencia).
- Salta la capa de casos de uso y llama al repositorio desde la presentación.
- Usa el mismo modelo de datos en todas las capas, sin mapeo.
// Domain: caso de uso independiente de frameworks
protocol FetchBooksUseCase { func callAsFunction() async throws -> [Book] }
struct FetchBooks: FetchBooksUseCase {
let repo: BooksRepository
func callAsFunction() async throws -> [Book] { try await repo.all() }
}
// Data: implementa el repositorio del dominio
final class BooksRepositoryImpl: BooksRepository {
let remote: BooksRemoteDataSource
func all() async throws -> [Book] { try await remote.fetch().map(Book.init) }
}
// Presentation: MV o MVVM consumiendo el caso de uso
@Observable final class LibraryModel {
private(set) var books: [Book] = []
let fetchBooks: FetchBooksUseCase
func load() async { books = (try? await fetchBooks()) ?? [] }
} - Testeabilidad y escalabilidad excelentes.
- El dominio es independiente de frameworks y fuentes de datos.
- Límites claros para equipos grandes.
- Mucho boilerplate y archivos.
- Curva de aprendizaje alta.
- Excesiva para apps pequeñas.
TCA (de Point-Free) modela cada feature como un State, un conjunto de Action, y un Reducer puro que, dada una acción, muta el estado y devuelve Effects (el trabajo asíncrono). El Store conecta todo con la vista. Las dependencias se inyectan vía @Dependency.
Su gran ventaja es la trazabilidad y testeabilidad: como el reducer es puro y los efectos están aislados, puedes escribir tests exhaustivos que verifican cada cambio de estado paso a paso. Compone features grandes a partir de pequeñas.
El costo es una curva de aprendizaje alta y dependencia de una librería externa. Brilla en apps con mucho estado compartido y side-effects entrelazados; para una app simple es demasiado.
- Pide primero el `State` (Equatable) con todas las propiedades de la feature.
- Luego el enum `Action` con todos los casos (intenciones del usuario + resultados de efectos).
- Después el `Reducer` (body), caso por caso; pídelo incrementalmente, no todo de golpe.
- Define las `@Dependency` (clients) por separado, con su valor de test.
- Al final la vista que observa el Store.
- El reducer es puro: los side-effects solo ocurren dentro de `Effect`/`.run`.
- Toda dependencia entra por `@Dependency`, con `liveValue` y `testValue`.
- El `State` es `Equatable` y las `Action` cubren tanto intención como resultados.
- Las features se componen (scope) en vez de un único reducer gigante.
- La IA mete llamadas de red directamente en el reducer en lugar de un Effect.
- Mezcla versiones viejas de TCA (sin `@Reducer`/`@ObservableState`) con la API nueva.
- Crea dependencias con `init()` concreto en vez de `@Dependency`, rompiendo los tests.
import ComposableArchitecture
@Reducer
struct Library {
@ObservableState
struct State: Equatable { var books: [Book] = []; var isLoading = false }
enum Action { case onAppear, booksLoaded([Book]) }
@Dependency(\.booksClient) var booksClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
return .run { send in await send(.booksLoaded(try await booksClient.fetch())) }
case let .booksLoaded(books):
state.isLoading = false; state.books = books
return .none
}
}
}
} - Flujo unidireccional muy trazable.
- Testeabilidad excepcional (reducers puros).
- Composición de features a escala.
- Curva de aprendizaje pronunciada.
- Dependencia de una librería externa.
- Boilerplate notable para features simples.