MV is the pattern Apple has shown officially since Observation shipped (WWDC23). The view observes @Observable models directly through @State and @Environment, with no intermediate ViewModel layer. Business logic lives in the model and in injected *services*; the view only describes the UI and reacts to state.
The key idea: SwiftUI views already are a declarative projection of state — lightweight, throwaway structs. Adding a ViewModel per view duplicates that role and fights the framework. When a view grows, you split it into subviews rather than bolting on a ViewModel.
It is the recommended default for new SwiftUI apps: minimal boilerplate, maximum framework affinity, and very easy to drive with an AI because there are fewer layers to coordinate.
- Ask for the `@Observable` models and data structs first (the domain).
- Then the service protocols (the contract), before their implementation.
- Then the concrete service implementations.
- Finally the views, which consume the model via `@Environment`/`@State`.
- No unnecessary `…ViewModel` class appeared per view.
- Services are injected by protocol, not instantiated inside the model.
- Business logic lives in the model/service, not in the view `body`.
- Uses `@Observable` (iOS 17+), not legacy `ObservableObject`/`@Published`, unless the target requires it.
- The AI adds a ViewModel out of habit even when it adds nothing — tell it to remove it and use the model directly.
- Puts network calls inside the view `body` instead of the model.
- Instantiates services with a concrete `init()` inside the model, breaking testability.
// 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() }
}
} - Minimal boilerplate; fewer files to maintain.
- It is Apple's official approach for SwiftUI.
- Very easy to drive with AI: few layers, clear contracts.
- Performance: `@Observable` granular tracking re-renders only what is needed.
- Less prescriptive: without discipline, logic can leak into views.
- Testability depends on injecting services well (not automatic).
- At large scale you complement it with Clean or TCA.
MVVM separates the view from its presentation state via a *ViewModel*: a class exposing display-ready data and the commands the view invokes. It was the de facto standard in the UIKit + Combine era and remains solid there.
In SwiftUI the picture changed. Because views are already a declarative projection of state, a ViewModel per view often duplicates what the framework does for free, and is considered over-engineering for simple apps. That is why it is not the SwiftUI default here.
Where it does earn its place in SwiftUI: to bridge non-SwiftUI APIs (UIKit, Combine, delegates), for presentation state shared across views, for long-lived reference models with behavior, or for team consistency. It is an open debate: some devs defend it for the separation and testability it enforces.
- Ask for the Model first (pure data and rules).
- Then the ViewModel: presentation properties + commands, with protocol dependencies.
- Finally the view, which only observes the ViewModel and fires commands.
- The ViewModel does NOT import SwiftUI or know view types.
- One responsibility per ViewModel; split if it grows.
- Dependencies injected by protocol (testable).
- Is the ViewModel truly needed here, or is it over-engineering? If the view is simple, consider MV.
- The AI puts UIKit/SwiftUI types inside the ViewModel, breaking separation.
- Creates an anemic ViewModel that just forwards to the model (a sign it is unnecessary).
- Duplicates in the ViewModel state SwiftUI already manages with `@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() }
}
} - Clear separation of presentation and logic.
- ViewModels are highly testable in isolation.
- Familiar to teams coming from UIKit.
- Boilerplate: a ViewModel per view adds files.
- In SwiftUI it often duplicates what the framework already does.
- Risk of anemic ViewModels or "god objects".
Clean Architecture organizes code into layers with a dependency rule: outer layers depend on inner ones, never the reverse. The Domain (entities + use cases) is the core and knows no framework; Data (repositories + data sources) and Presentation (views + models) depend on it through protocols.
It does not compete with MV or MVVM: it sits on top. The presentation layer can be MV (an @Observable model) or MVVM, and below it consumes domain use cases. This yields excellent testability and scalability at the cost of more files and ceremony.
It pays off in large apps, multiple teams, or complex domains that switch data sources. For a small app it is clearly overkill.
- Ask for the Domain first: entities and protocols for use cases and repositories. That is the contract.
- Then the Data layer: repository and data source implementations.
- Then Presentation: MV/MVVM models that consume the use cases.
- Finally the views and the dependency-injection wiring.
- The dependency rule holds: Domain imports nothing from Data or Presentation.
- Network/DB DTOs do not leak into the Domain (there is mapping to entities).
- Each use case has a single responsibility.
- Does the app justify this ceremony, or is it excessive for its size?
- The AI makes the Domain import the networking framework (violates the dependency rule).
- Skips the use-case layer and calls the repository from presentation.
- Uses the same data model across all layers, with no mapping.
// 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()) ?? [] }
} - Excellent testability and scalability.
- The domain is independent of frameworks and data sources.
- Clear boundaries for large teams.
- Lots of boilerplate and files.
- Steep learning curve.
- Overkill for small apps.
TCA (by Point-Free) models each feature as a State, a set of Actions, and a pure Reducer that, given an action, mutates state and returns Effects (the async work). The Store wires it to the view. Dependencies are injected via @Dependency.
Its big advantage is traceability and testability: because the reducer is pure and effects are isolated, you can write exhaustive tests verifying every state change step by step. It composes large features from small ones.
The cost is a steep learning curve and a dependency on an external library. It shines in apps with lots of shared state and intertwined side-effects; for a simple app it is too much.
- Ask for the `State` (Equatable) with all the feature properties first.
- Then the `Action` enum with every case (user intent + effect results).
- Then the `Reducer` (body), case by case; request it incrementally, not all at once.
- Define the `@Dependency` clients separately, with their test value.
- Finally the view observing the Store.
- The reducer is pure: side-effects only happen inside `Effect`/`.run`.
- Every dependency comes through `@Dependency`, with `liveValue` and `testValue`.
- The `State` is `Equatable` and `Action`s cover both intent and results.
- Features are composed (scoped) instead of one giant reducer.
- The AI puts network calls directly in the reducer instead of an Effect.
- Mixes old TCA versions (no `@Reducer`/`@ObservableState`) with the new API.
- Creates dependencies with a concrete `init()` instead of `@Dependency`, breaking 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
}
}
}
} - Highly traceable unidirectional flow.
- Exceptional testability (pure reducers).
- Feature composition at scale.
- Steep learning curve.
- Dependency on an external library.
- Noticeable boilerplate for simple features.