🧭 ¿Qué arquitectura me conviene?
Responde unas preguntas rápidas. La recomendación favorece MV para SwiftUI nuevo y solo sugiere más estructura cuando el caso lo pide.
Recomendación
🤖 Cómo dirigir a la IA
Principios que aplican a cualquier arquitectura para que la IA implemente bien y tú puedas supervisar con confianza.
Pide capa por capa, no la app entera
Construye de adentro hacia afuera: primero el dominio/modelo, luego los contratos (protocolos), después las implementaciones y al final las vistas. Un solo prompt gigante produce código difícil de revisar y acoplado.
Da el contrato antes que la implementación
Pide primero los protocolos y firmas. Cuando el límite está claro, la implementación que genera la IA respeta la frontera y es intercambiable y testeable.
Una responsabilidad por archivo
Si un archivo empieza a mezclar red, lógica y UI, pídele a la IA que lo divida. Archivos enfocados son más fáciles de revisar para ti y de editar con fiabilidad para la IA.
Exige tests junto al código
Pide la implementación y sus tests en el mismo paso, con dependencias inyectadas por protocolo. Si la IA no puede testear algo sin mocks elaborados, suele ser señal de un mal límite.
Verifica que no se filtre lógica entre capas
Revisa que la vista no tenga lógica de negocio, que el modelo no importe tipos de UI, y que el dominio no conozca la red. Las filtraciones son el error más común que comete una IA.
Itera sin reescribir todo
Cuando algo falla, pide un cambio acotado a un archivo o función, no "arréglalo todo". Así conservas lo que ya revisaste y evitas regresiones silenciosas.
Revisa las dependencias inyectadas
Confirma que los servicios entran por init o por el mecanismo de inyección del patrón (`@Environment`, `@Dependency`), no instanciados con `init()` concreto dentro de la capa. Es lo que hace el código testeable.
Desconfía del default de la IA
Por entrenamiento, muchas IAs meten un ViewModel por costumbre. En SwiftUI nuevo, indícale explícitamente que uses MV (modelo @Observable directo) salvo que el caso justifique lo contrario.
📊 Comparar arquitecturas
Clic en una columna para ordenar. Activa los criterios que te importan para ver el match ponderado.
MV (Model-View)
El enfoque que Apple demuestra para SwiftUI: modelos @Observable directos en la vista.
Curva
Testeabilidad
Escalabilidad
Boilerplate

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.

Diagrama de capas
Toca una capa para ver su responsabilidad.
Reproduce el flujo de un evento a través de las capas.
Guía IA específica
Orden de construcción
  1. Pide primero los modelos `@Observable` y los structs de datos (el dominio).
  2. Luego los protocolos de los services (el contrato), antes que su implementación.
  3. Después la implementación concreta de los services.
  4. Al final las vistas, que consumen el modelo vía `@Environment`/`@State`.
Prompts de ejemplo
Modelo observable
Crea un modelo `@Observable` `LibraryStore` que exponga `books: [Book]` e `isLoading`, con un método `load()` async que use un protocolo `BooksAPI` inyectado por init. No uses ObservableObject ni @Published; usa el macro @Observable de iOS 17+.
Vista sin ViewModel
Crea `LibraryView` que lea `LibraryStore` desde `@Environment` y muestre la lista. No crees un ViewModel: la vista debe usar el modelo directamente y llamar `load()` en `.task`.
Checklist de supervisión
  • 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.
Errores comunes de la IA
  • 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.
Casos de uso
App SwiftUI nueva, equipo pequeño o solo dev.
Mínimo boilerplate y máxima afinidad con SwiftUI; es justo lo que Apple demuestra.
App de tamaño medio con estado mayormente local por pantalla.
`@State` + `@Environment` cubren el caso sin capas extra.
🟡
Estado compartido muy complejo con muchos side-effects entrelazados.
Funciona, pero a cierta escala TCA da más estructura y trazabilidad.
App con UIKit/Combine pesado que hay que puentear.
Ahí un ViewModel (MVVM) sí ayuda a aislar el puente.
Código representativo
// 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() }
    }
}
Pros
  • 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.
Contras
  • 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
View–ViewModel–Model. Estándar en UIKit; en SwiftUI úsala solo cuando aporta.
Curva
Testeabilidad
Escalabilidad
Boilerplate

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.

Diagrama de capas
Toca una capa para ver su responsabilidad.
Reproduce el flujo de un evento a través de las capas.
Guía IA específica
Orden de construcción
  1. Pide primero el Model (datos y reglas puras).
  2. Luego el ViewModel: propiedades de presentación + comandos, con dependencias por protocolo.
  3. Al final la vista, que solo observa el ViewModel y dispara comandos.
Prompts de ejemplo
ViewModel testeable
Crea `LibraryViewModel` como `@Observable` con `books`, `isLoading` y `load()` async, recibiendo un protocolo `BooksAPI` por init. El ViewModel no debe importar SwiftUI ni referenciar tipos de vista.
Frontera limpia
Revisa que la vista no tenga lógica: solo lee propiedades del ViewModel y llama sus comandos. Mueve cualquier transformación de datos al ViewModel.
Checklist de supervisión
  • 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.
Errores comunes de la IA
  • 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`.
Casos de uso
App UIKit, o SwiftUI que convive con mucho UIKit/Combine.
El ViewModel aísla el puente y la lógica de presentación.
🟡
Estado de presentación compartido entre varias vistas.
Un ViewModel compartido puede centralizarlo, aunque MV con un modelo en @Environment también sirve.
App SwiftUI nueva y simple.
Es sobreingeniería: SwiftUI ya proyecta el estado. Usa MV.
Código representativo
// 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() }
    }
}
Pros
  • Separación clara de presentación y lógica.
  • ViewModels muy testeables de forma aislada.
  • Familiar para equipos que vienen de UIKit.
Contras
  • 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
Capas concéntricas: Presentation → Domain → Data. Para apps grandes que deben escalar.
Curva
Testeabilidad
Escalabilidad
Boilerplate

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.

Diagrama de capas
Toca una capa para ver su responsabilidad.
Reproduce el flujo de un evento a través de las capas.
Guía IA específica
Orden de construcción
  1. Pide primero el Domain: entidades y protocolos de casos de uso y repositorios. Es el contrato.
  2. Luego la capa Data: implementaciones de repositorios y data sources.
  3. Después la Presentation: modelos MV/MVVM que consumen los casos de uso.
  4. Al final las vistas y el wiring de inyección de dependencias.
Prompts de ejemplo
Domain primero
Define la capa Domain: entidad `Book`, protocolo `BooksRepository` con `all() async throws -> [Book]`, y un caso de uso `FetchBooksUseCase`. El Domain no debe importar SwiftUI, Foundation networking ni ningún framework de datos.
Data implementa el contrato
Implementa `BooksRepositoryImpl` en la capa Data que cumpla `BooksRepository` usando un `BooksRemoteDataSource`. Mapea los DTO de red a la entidad `Book` del dominio; no filtres tipos de red hacia el Domain.
Checklist de supervisión
  • 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?
Errores comunes de la IA
  • 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.
Casos de uso
App grande, varios equipos, dominio complejo.
La separación estricta escala y aísla cambios de fuente de datos.
🟡
App media con lógica de negocio importante y mucho testeo.
Aporta testeabilidad, pero evalúa si todo el aparato se justifica.
App pequeña o MVP.
Demasiada ceremonia; MV entrega lo mismo con una fracción del código.
Código representativo
// 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()) ?? [] }
}
Pros
  • Testeabilidad y escalabilidad excelentes.
  • El dominio es independiente de frameworks y fuentes de datos.
  • Límites claros para equipos grandes.
Contras
  • Mucho boilerplate y archivos.
  • Curva de aprendizaje alta.
  • Excesiva para apps pequeñas.
TCA
The Composable Architecture: estado, acciones y efectos unidireccionales y testeables.
Curva
Testeabilidad
Escalabilidad
Boilerplate

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.

Diagrama de capas
Toca una capa para ver su responsabilidad.
Reproduce el flujo de un evento a través de las capas.
Guía IA específica
Orden de construcción
  1. Pide primero el `State` (Equatable) con todas las propiedades de la feature.
  2. Luego el enum `Action` con todos los casos (intenciones del usuario + resultados de efectos).
  3. Después el `Reducer` (body), caso por caso; pídelo incrementalmente, no todo de golpe.
  4. Define las `@Dependency` (clients) por separado, con su valor de test.
  5. Al final la vista que observa el Store.
Prompts de ejemplo
State + Action
Usando TCA (Point-Free), define el `@ObservableState struct State: Equatable` y el `enum Action` para una feature `Library` con lista de libros y estado de carga. Aún no escribas el reducer.
Reducer incremental
Ahora implementa el `body` del `@Reducer Library` manejando `.onAppear` (lanza un effect que carga libros vía `@Dependency(\.booksClient)`) y `.booksLoaded`. Mantén el reducer puro: nada de side-effects fuera de `.run`.
Checklist de supervisión
  • 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.
Errores comunes de la IA
  • 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.
Casos de uso
App con mucho estado compartido y side-effects entrelazados.
El flujo unidireccional y los effects aislados dan trazabilidad total.
Equipo que necesita tests exhaustivos del estado.
El reducer puro permite tests paso a paso del cambio de estado.
App SwiftUI simple o equipo sin experiencia en TCA.
La curva y el boilerplate no se justifican; usa MV.
Código representativo
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
            }
        }
    }
}
Pros
  • Flujo unidireccional muy trazable.
  • Testeabilidad excepcional (reducers puros).
  • Composición de features a escala.
Contras
  • Curva de aprendizaje pronunciada.
  • Dependencia de una librería externa.
  • Boilerplate notable para features simples.
Otras arquitecturas
Patrones que existen en el ecosistema iOS/macOS pero que no recomendamos por defecto para SwiftUI nuevo. Aquí está la información para quien quiera profundizar.
MVVM-C (Coordinators)
Qué es: MVVM más una capa Coordinator que centraliza la navegación y el flujo entre pantallas.
Cuándo: Popular en apps UIKit grandes para sacar la navegación de los view controllers.
Por qué no es del set principal: En SwiftUI puro la navegación se resuelve con `NavigationStack` y un `NavigationPath` enlazado a la vista (a menudo compartido vía `@Environment`), así que una capa Coordinator completa suele ser redundante. Por eso queda fuera del set principal.
MVC
Qué es: Model-View-Controller: el patrón clásico de UIKit, con el view controller como mediador.
Cuándo: Base de UIKit durante años; aún presente en código heredado.
Por qué no es del set principal: En la práctica de iOS degeneró en "Massive View Controller". SwiftUI lo reemplaza con su modelo declarativo; no es una recomendación para proyectos nuevos.
VIPER
Qué es: View, Interactor, Presenter, Entity, Router: separación muy granular de responsabilidades.
Cuándo: Apps UIKit muy grandes que buscaban modularidad estricta y testeo.
Por qué no es del set principal: Genera mucho boilerplate y encaja mal con el modelo declarativo de SwiftUI. Clean Architecture cubre el mismo objetivo de forma más moderna.
VIP (Clean Swift)
Qué es: Variante de Clean para UIKit con un ciclo cerrado unidireccional View → Interactor → Presenter → View.
Cuándo: Equipos UIKit que querían Clean con un ciclo de datos estricto por pantalla.
Por qué no es del set principal: Muy ligada a UIKit y al ciclo del view controller; en SwiftUI, TCA cubre el flujo unidireccional de forma más idiomática.
MVP
Qué es: Model-View-Presenter: el Presenter contiene la lógica de presentación y actualiza una vista pasiva.
Cuándo: Común en UIKit (y Android) como alternativa a MVC con vistas pasivas.
Por qué no es del set principal: Asume una vista pasiva que el Presenter empuja; SwiftUI invierte eso con su binding declarativo, haciéndolo redundante.
Redux / unidireccional
Qué es: Un store global con estado inmutable, acciones y reducers; flujo de datos en una sola dirección.
Cuándo: Inspiró a TCA; usado por equipos que vienen del mundo web (Redux).
Por qué no es del set principal: En el ecosistema Apple, TCA es la encarnación madura de estas ideas con herramientas y testeo de primera. Lo tratamos vía TCA.
Hexagonal (Ports & Adapters)
Qué es: El núcleo de la app define "puertos" (protocolos) y los detalles externos son "adaptadores" intercambiables.
Cuándo: Sistemas que necesitan aislar el dominio de la infraestructura de forma estricta.
Por qué no es del set principal: Comparte la idea central con Clean Architecture (dominio independiente vía protocolos); para iOS lo representamos con Clean.