🧭 Which architecture fits me?
Answer a few quick questions. The recommendation favors MV for new SwiftUI and only suggests more structure when the case calls for it.
Recommendation
🤖 How to direct the AI
Principles that apply to any architecture so the AI implements well and you can supervise with confidence.
Ask layer by layer, not the whole app
Build inside-out: domain/model first, then contracts (protocols), then implementations, and views last. One giant prompt yields coupled code that is hard to review.
Give the contract before the implementation
Ask for protocols and signatures first. With a clear boundary, the implementation the AI generates respects the seam and stays swappable and testable.
One responsibility per file
If a file starts mixing networking, logic, and UI, ask the AI to split it. Focused files are easier for you to review and for the AI to edit reliably.
Demand tests alongside the code
Ask for the implementation and its tests in the same step, with protocol-injected dependencies. If the AI cannot test something without elaborate mocks, that often signals a bad seam.
Check that logic does not leak across layers
Verify the view holds no business logic, the model imports no UI types, and the domain knows nothing about networking. Leaks are the most common mistake an AI makes.
Iterate without rewriting everything
When something is wrong, ask for a scoped change to one file or function, not "fix it all". You keep what you already reviewed and avoid silent regressions.
Review the injected dependencies
Confirm services enter via init or the pattern’s injection mechanism (`@Environment`, `@Dependency`), not instantiated with a concrete `init()` inside the layer. That is what makes code testable.
Distrust the AI default
By training, many AIs add a ViewModel out of habit. In new SwiftUI, tell it explicitly to use MV (a direct @Observable model) unless the case justifies otherwise.
📊 Compare architectures
Click a column to sort. Toggle the criteria that matter to see the weighted match.
MV (Model-View)
Apple's demonstrated approach for SwiftUI: @Observable models used directly by the view.
Learning curve
Testability
Scalability
Boilerplate

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.

Layer diagram
Tap a layer to see its responsibility.
Play an event's flow through the layers.
AI-specific guide
Build order
  1. Ask for the `@Observable` models and data structs first (the domain).
  2. Then the service protocols (the contract), before their implementation.
  3. Then the concrete service implementations.
  4. Finally the views, which consume the model via `@Environment`/`@State`.
Example prompts
Observable model
Create an `@Observable` model `LibraryStore` exposing `books: [Book]` and `isLoading`, with an async `load()` method using a `BooksAPI` protocol injected via init. Do not use ObservableObject or @Published; use the iOS 17+ @Observable macro.
View without ViewModel
Create `LibraryView` that reads `LibraryStore` from `@Environment` and shows the list. Do not create a ViewModel: the view must use the model directly and call `load()` in `.task`.
Supervision checklist
  • 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.
Common AI mistakes
  • 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.
Use cases
New SwiftUI app, small team or solo dev.
Minimal boilerplate and maximum SwiftUI affinity; exactly what Apple demonstrates.
Medium app with mostly screen-local state.
`@State` + `@Environment` cover it with no extra layers.
🟡
Very complex shared state with many intertwined side-effects.
Works, but at scale TCA gives more structure and traceability.
Heavy UIKit/Combine that must be bridged.
There a ViewModel (MVVM) does help isolate the bridge.
Representative code
// 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
  • 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.
Cons
  • 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
View–ViewModel–Model. A UIKit standard; in SwiftUI use it only when it earns its place.
Learning curve
Testability
Scalability
Boilerplate

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.

Layer diagram
Tap a layer to see its responsibility.
Play an event's flow through the layers.
AI-specific guide
Build order
  1. Ask for the Model first (pure data and rules).
  2. Then the ViewModel: presentation properties + commands, with protocol dependencies.
  3. Finally the view, which only observes the ViewModel and fires commands.
Example prompts
Testable ViewModel
Create `LibraryViewModel` as `@Observable` with `books`, `isLoading` and async `load()`, taking a `BooksAPI` protocol via init. The ViewModel must not import SwiftUI or reference view types.
Clean boundary
Verify the view has no logic: it only reads ViewModel properties and calls its commands. Move any data transformation into the ViewModel.
Supervision checklist
  • 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.
Common AI mistakes
  • 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`.
Use cases
UIKit app, or SwiftUI living alongside heavy UIKit/Combine.
The ViewModel isolates the bridge and presentation logic.
🟡
Presentation state shared across views.
A shared ViewModel can centralize it, though MV with a model in @Environment also works.
New, simple SwiftUI app.
Over-engineering: SwiftUI already projects state. Use MV.
Representative code
// 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
  • Clear separation of presentation and logic.
  • ViewModels are highly testable in isolation.
  • Familiar to teams coming from UIKit.
Cons
  • 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
Concentric layers: Presentation → Domain → Data. For large apps that must scale.
Learning curve
Testability
Scalability
Boilerplate

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.

Layer diagram
Tap a layer to see its responsibility.
Play an event's flow through the layers.
AI-specific guide
Build order
  1. Ask for the Domain first: entities and protocols for use cases and repositories. That is the contract.
  2. Then the Data layer: repository and data source implementations.
  3. Then Presentation: MV/MVVM models that consume the use cases.
  4. Finally the views and the dependency-injection wiring.
Example prompts
Domain first
Define the Domain layer: entity `Book`, protocol `BooksRepository` with `all() async throws -> [Book]`, and a `FetchBooksUseCase`. The Domain must not import SwiftUI, networking, or any data framework.
Data implements the contract
Implement `BooksRepositoryImpl` in the Data layer conforming to `BooksRepository` using a `BooksRemoteDataSource`. Map network DTOs to the domain `Book` entity; do not leak network types into the Domain.
Supervision checklist
  • 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?
Common AI mistakes
  • 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.
Use cases
Large app, several teams, complex domain.
Strict separation scales and isolates data-source changes.
🟡
Medium app with significant business logic and lots of testing.
Adds testability, but weigh whether the full apparatus is justified.
Small app or MVP.
Too much ceremony; MV delivers the same with a fraction of the code.
Representative code
// 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
  • Excellent testability and scalability.
  • The domain is independent of frameworks and data sources.
  • Clear boundaries for large teams.
Cons
  • Lots of boilerplate and files.
  • Steep learning curve.
  • Overkill for small apps.
TCA
The Composable Architecture: unidirectional, testable state, actions, and effects.
Learning curve
Testability
Scalability
Boilerplate

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.

Layer diagram
Tap a layer to see its responsibility.
Play an event's flow through the layers.
AI-specific guide
Build order
  1. Ask for the `State` (Equatable) with all the feature properties first.
  2. Then the `Action` enum with every case (user intent + effect results).
  3. Then the `Reducer` (body), case by case; request it incrementally, not all at once.
  4. Define the `@Dependency` clients separately, with their test value.
  5. Finally the view observing the Store.
Example prompts
State + Action
Using TCA (Point-Free), define the `@ObservableState struct State: Equatable` and the `Action` enum for a `Library` feature with a book list and loading state. Do not write the reducer yet.
Incremental reducer
Now implement the `body` of `@Reducer Library` handling `.onAppear` (fire an effect loading books via `@Dependency(\.booksClient)`) and `.booksLoaded`. Keep the reducer pure: no side-effects outside `.run`.
Supervision checklist
  • 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.
Common AI mistakes
  • 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.
Use cases
App with lots of shared state and intertwined side-effects.
Unidirectional flow and isolated effects give full traceability.
Team that needs exhaustive state tests.
The pure reducer enables step-by-step state-change tests.
Simple SwiftUI app or a team new to TCA.
The curve and boilerplate are not justified; use MV.
Representative code
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
  • Highly traceable unidirectional flow.
  • Exceptional testability (pure reducers).
  • Feature composition at scale.
Cons
  • Steep learning curve.
  • Dependency on an external library.
  • Noticeable boilerplate for simple features.
Other architectures
Patterns that exist in the iOS/macOS ecosystem but that we do not recommend by default for new SwiftUI. Here is the info for anyone who wants to dig deeper.
MVVM-C (Coordinators)
What it is: MVVM plus a Coordinator layer that centralizes navigation and flow between screens.
When: Popular in large UIKit apps to pull navigation out of view controllers.
Why it's not in the main set: In pure SwiftUI, navigation is handled with `NavigationStack` and a `NavigationPath` bound to the view (often shared via `@Environment`), so a full Coordinator layer is usually redundant. Hence it is outside the main set.
MVC
What it is: Model-View-Controller: the classic UIKit pattern, with the view controller as mediator.
When: The basis of UIKit for years; still present in legacy code.
Why it's not in the main set: In iOS practice it degenerated into the "Massive View Controller". SwiftUI replaces it with its declarative model; not a recommendation for new projects.
VIPER
What it is: View, Interactor, Presenter, Entity, Router: very granular separation of responsibilities.
When: Very large UIKit apps seeking strict modularity and testing.
Why it's not in the main set: It produces a lot of boilerplate and fits poorly with SwiftUI’s declarative model. Clean Architecture covers the same goal more modernly.
VIP (Clean Swift)
What it is: A Clean variant for UIKit with a closed unidirectional View → Interactor → Presenter → View cycle.
When: UIKit teams wanting Clean with a strict per-screen data cycle.
Why it's not in the main set: Heavily tied to UIKit and the view controller cycle; in SwiftUI, TCA covers unidirectional flow more idiomatically.
MVP
What it is: Model-View-Presenter: the Presenter holds presentation logic and updates a passive view.
When: Common in UIKit (and Android) as an alternative to MVC with passive views.
Why it's not in the main set: It assumes a passive view the Presenter pushes to; SwiftUI inverts that with declarative binding, making it redundant.
Redux / unidireccional
What it is: A global store with immutable state, actions, and reducers; one-directional data flow.
When: It inspired TCA; used by teams coming from the web (Redux).
Why it's not in the main set: In the Apple ecosystem, TCA is the mature embodiment of these ideas with first-class tooling and testing. We cover it via TCA.
Hexagonal (Ports & Adapters)
What it is: The app core defines "ports" (protocols) and external details are swappable "adapters".
When: Systems needing to strictly isolate the domain from infrastructure.
Why it's not in the main set: It shares its core idea with Clean Architecture (framework-independent domain via protocols); for iOS we represent it with Clean.