Skip to main content
Technology & EngineeringIos Swift431 lines

App Architecture

MVVM and Clean Architecture patterns for structuring scalable, testable iOS applications

Quick Summary18 lines
You are an expert in application architecture for building iOS applications with Swift.

## Key Points

- **Model** — Plain data types and business rules.
- **View** — SwiftUI views that render state and forward user actions.
- **ViewModel** — Transforms model data into view-ready state; handles user intents.
- **Keep views dumb.** Views should read state from the view model and forward actions. No business logic in views.
- **Define repository protocols in the domain layer.** Concrete implementations live in the data layer. This inverts the dependency so domain never depends on infrastructure.
- **Use value types for models and DTOs.** Structs are simpler, thread-safe, and encourage immutability.
- **Inject dependencies through initializers.** Avoid singletons for anything stateful. Constructor injection makes dependencies explicit and enables testing.
- **One ViewModel per screen.** Shared state should live in a repository or service, not in a view model passed between screens.
- **Name use cases as verbs.** `FetchProductsUseCase`, `PlaceOrderUseCase`, `ValidateEmailUseCase`. Each represents one specific action.
- **Separate DTOs from domain models.** API response shapes change independently of your domain. Mapping between them at the data layer boundary isolates the app from backend changes.
- **Over-engineering small apps.** A to-do app does not need Clean Architecture with use cases and repository protocols. Start with MVVM and add layers only when complexity justifies them.
- **Massive ViewModels.** If a view model exceeds 200-300 lines, split it. Extract use cases, break the screen into child views with their own view models, or use helper types.
skilldb get ios-swift-skills/App ArchitectureFull skill: 431 lines
Paste into your CLAUDE.md or agent config

App Architecture — iOS/Swift

You are an expert in application architecture for building iOS applications with Swift.

Core Philosophy

Architecture in iOS development exists to serve one purpose: making it easy to change code without breaking other code. MVVM and Clean Architecture are not goals in themselves but tools for managing complexity as an app grows. The right architecture is the simplest one that keeps your codebase maintainable at its current scale. A two-screen app with Clean Architecture layers, use case protocols, and a DI container is over-engineered; a fifty-screen app without clear separation between UI and business logic is a maintenance disaster. Start lean and add structure when you feel real pain, not theoretical pain.

The dependency rule is the single most important principle: inner layers must never know about outer layers. Your domain entities and business rules should be pure Swift with no imports of UIKit, SwiftUI, or any infrastructure framework. When domain code defines a repository protocol and the data layer implements it, you can swap your networking stack, database, or caching strategy without touching a single line of business logic. This inversion of control is what makes architecture pay off over time.

Testability is the practical litmus test for good architecture. If writing a unit test for a piece of logic requires launching a simulator, standing up a database, or importing SwiftUI, the architecture has failed. Every layer should be testable in isolation by injecting mock or fake dependencies through initializers. If you cannot test a ViewModel without a real network connection, the boundary between layers is leaking.

Anti-Patterns

  • God ViewModel that does everything: A ViewModel that fetches data, validates forms, formats strings for display, manages navigation, and coordinates multiple repositories has absorbed responsibilities from every layer. Split it into focused use cases, extract formatting into helper types, and delegate navigation to a coordinator or router.

  • Leaking framework types into the domain layer: Importing UIKit to use UIImage in a domain entity, or referencing SwiftUI's @Published in a business rule, couples your core logic to Apple's presentation frameworks. Use plain Swift types in the domain layer and map to framework types at the boundary.

  • Premature abstraction with protocols for everything: Creating a protocol for every concrete class "in case we need to swap it later" adds indirection without benefit. Introduce a protocol when you have two concrete implementations (production and test/mock) or a clear architectural boundary. One concrete class behind an unnecessary protocol is just noise.

  • Singleton abuse for stateful services: Using static let shared for repositories, caches, and API clients creates hidden dependencies, makes testing painful, and prevents multiple instances (e.g., per-user scopes). Use constructor injection and let a DI container manage lifetimes.

  • Circular dependencies between layers: Service A depends on Service B which depends on Service A. This usually signals a misplaced responsibility. Break the cycle by extracting the shared concern into a third type, introducing a protocol at the boundary, or rethinking which layer owns the responsibility.

Overview

Well-structured iOS apps separate concerns into distinct layers: presentation, domain logic, and data access. The two most common architectural patterns in the Swift ecosystem are MVVM (Model-View-ViewModel) and Clean Architecture. MVVM pairs naturally with SwiftUI's data-binding model, while Clean Architecture adds formal boundaries between use cases, repositories, and data sources. This skill covers both patterns and how to combine them effectively.

Core Concepts

MVVM (Model-View-ViewModel)

MVVM separates UI (View) from presentation logic (ViewModel) and data (Model):

  • Model — Plain data types and business rules.
  • View — SwiftUI views that render state and forward user actions.
  • ViewModel — Transforms model data into view-ready state; handles user intents.
// Model
struct Task: Identifiable, Codable {
    let id: UUID
    var title: String
    var isCompleted: Bool
    var dueDate: Date?
}

// ViewModel
@MainActor
class TaskListViewModel: ObservableObject {
    @Published private(set) var tasks: [Task] = []
    @Published private(set) var isLoading = false
    @Published var errorMessage: String?

    private let repository: TaskRepository

    init(repository: TaskRepository) {
        self.repository = repository
    }

    func loadTasks() async {
        isLoading = true
        defer { isLoading = false }

        do {
            tasks = try await repository.fetchAll()
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func toggleCompletion(_ task: Task) async {
        var updated = task
        updated.isCompleted.toggle()

        do {
            try await repository.update(updated)
            if let index = tasks.firstIndex(where: { $0.id == task.id }) {
                tasks[index] = updated
            }
        } catch {
            errorMessage = "Failed to update task"
        }
    }

    func delete(_ task: Task) async {
        do {
            try await repository.delete(task.id)
            tasks.removeAll { $0.id == task.id }
        } catch {
            errorMessage = "Failed to delete task"
        }
    }
}

// View
struct TaskListView: View {
    @StateObject private var viewModel: TaskListViewModel

    init(repository: TaskRepository) {
        _viewModel = StateObject(wrappedValue: TaskListViewModel(repository: repository))
    }

    var body: some View {
        List {
            ForEach(viewModel.tasks) { task in
                TaskRow(task: task) {
                    Task { await viewModel.toggleCompletion(task) }
                }
            }
            .onDelete { indexSet in
                let tasksToDelete = indexSet.map { viewModel.tasks[$0] }
                Task {
                    for task in tasksToDelete {
                        await viewModel.delete(task)
                    }
                }
            }
        }
        .overlay {
            if viewModel.isLoading { ProgressView() }
        }
        .task { await viewModel.loadTasks() }
        .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
            Button("OK") { viewModel.errorMessage = nil }
        } message: {
            Text(viewModel.errorMessage ?? "")
        }
    }
}

Clean Architecture Layers

Clean Architecture organizes code into concentric layers with strict dependency rules — inner layers never depend on outer layers:

┌──────────────────────────────────────┐
│          Presentation (UI)           │  Views, ViewModels
├──────────────────────────────────────┤
│          Domain (Business)           │  Use Cases, Entities, Repository Protocols
├──────────────────────────────────────┤
│          Data (Infrastructure)       │  Repository Impls, API Clients, DB
└──────────────────────────────────────┘

Domain Layer

Contains pure business logic with no framework dependencies:

// Entity
struct Product: Identifiable {
    let id: UUID
    let name: String
    let price: Decimal
    let category: Category
    let isAvailable: Bool
}

// Repository Protocol (defined in domain, implemented in data)
protocol ProductRepository {
    func fetchAll() async throws -> [Product]
    func fetch(id: UUID) async throws -> Product
    func search(query: String) async throws -> [Product]
    func save(_ product: Product) async throws
}

// Use Case
struct FetchProductsUseCase {
    private let repository: ProductRepository

    init(repository: ProductRepository) {
        self.repository = repository
    }

    func execute(category: Category? = nil) async throws -> [Product] {
        let products = try await repository.fetchAll()
        if let category {
            return products.filter { $0.category == category && $0.isAvailable }
        }
        return products.filter { $0.isAvailable }
    }
}

struct SearchProductsUseCase {
    private let repository: ProductRepository

    init(repository: ProductRepository) {
        self.repository = repository
    }

    func execute(query: String) async throws -> [Product] {
        guard query.count >= 2 else { return [] }
        return try await repository.search(query: query)
    }
}

Data Layer

Implements repository protocols and manages data sources:

// API Data Transfer Object
struct ProductDTO: Decodable {
    let id: String
    let name: String
    let priceInCents: Int
    let categorySlug: String
    let available: Bool

    func toDomain() -> Product {
        Product(
            id: UUID(uuidString: id) ?? UUID(),
            name: name,
            price: Decimal(priceInCents) / 100,
            category: Category(slug: categorySlug),
            isAvailable: available
        )
    }
}

// Repository Implementation
class ProductRepositoryImpl: ProductRepository {
    private let apiClient: APIClient
    private let cache: ProductCache

    init(apiClient: APIClient, cache: ProductCache) {
        self.apiClient = apiClient
        self.cache = cache
    }

    func fetchAll() async throws -> [Product] {
        if let cached = await cache.getAll(), !cached.isEmpty {
            return cached
        }

        let dtos: [ProductDTO] = try await apiClient.send(Endpoint(path: "/products"))
        let products = dtos.map { $0.toDomain() }
        await cache.store(products)
        return products
    }

    func fetch(id: UUID) async throws -> Product {
        let dto: ProductDTO = try await apiClient.send(Endpoint(path: "/products/\(id.uuidString)"))
        return dto.toDomain()
    }

    func search(query: String) async throws -> [Product] {
        let dtos: [ProductDTO] = try await apiClient.send(
            Endpoint(path: "/products/search", queryItems: [URLQueryItem(name: "q", value: query)])
        )
        return dtos.map { $0.toDomain() }
    }

    func save(_ product: Product) async throws {
        try await apiClient.sendVoid(
            Endpoint(path: "/products/\(product.id)", method: .put, body: product)
        )
    }
}

Dependency Injection

A simple DI container without third-party libraries:

@MainActor
class DependencyContainer {
    static let shared = DependencyContainer()

    // Data layer
    lazy var apiClient = APIClient(
        baseURL: URL(string: "https://api.example.com")!,
        tokenProvider: tokenProvider
    )
    lazy var tokenProvider: TokenProvider = KeychainTokenProvider()
    lazy var productCache = ProductCache()

    // Repositories
    lazy var productRepository: ProductRepository = ProductRepositoryImpl(
        apiClient: apiClient,
        cache: productCache
    )
    lazy var userRepository: UserRepository = UserRepositoryImpl(apiClient: apiClient)

    // Use cases
    func makeFetchProductsUseCase() -> FetchProductsUseCase {
        FetchProductsUseCase(repository: productRepository)
    }

    func makeSearchProductsUseCase() -> SearchProductsUseCase {
        SearchProductsUseCase(repository: productRepository)
    }

    // View models
    func makeProductListViewModel() -> ProductListViewModel {
        ProductListViewModel(
            fetchProducts: makeFetchProductsUseCase(),
            searchProducts: makeSearchProductsUseCase()
        )
    }
}

Implementation Patterns

Feature Module Structure

Organize code by feature rather than by layer for better discoverability:

Sources/
├── App/
│   ├── MyApp.swift
│   └── DependencyContainer.swift
├── Features/
│   ├── Products/
│   │   ├── Domain/
│   │   │   ├── Product.swift
│   │   │   ├── ProductRepository.swift
│   │   │   └── FetchProductsUseCase.swift
│   │   ├── Data/
│   │   │   ├── ProductDTO.swift
│   │   │   └── ProductRepositoryImpl.swift
│   │   └── Presentation/
│   │       ├── ProductListViewModel.swift
│   │       ├── ProductListView.swift
│   │       └── ProductRow.swift
│   └── Auth/
│       ├── Domain/
│       ├── Data/
│       └── Presentation/
└── Shared/
    ├── Networking/
    ├── Persistence/
    └── UI/

ViewState Pattern

Represent all possible view states explicitly:

enum ViewState<T> {
    case idle
    case loading
    case loaded(T)
    case error(String)

    var isLoading: Bool {
        if case .loading = self { return true }
        return false
    }
}

@MainActor
class ProductListViewModel: ObservableObject {
    @Published private(set) var state: ViewState<[Product]> = .idle

    private let fetchProducts: FetchProductsUseCase

    init(fetchProducts: FetchProductsUseCase) {
        self.fetchProducts = fetchProducts
    }

    func load() async {
        state = .loading
        do {
            let products = try await fetchProducts.execute()
            state = .loaded(products)
        } catch {
            state = .error(error.localizedDescription)
        }
    }
}

struct ProductListView: View {
    @StateObject private var viewModel: ProductListViewModel

    var body: some View {
        Group {
            switch viewModel.state {
            case .idle:
                Color.clear
            case .loading:
                ProgressView("Loading products...")
            case .loaded(let products):
                List(products) { product in
                    ProductRow(product: product)
                }
            case .error(let message):
                ContentUnavailableView {
                    Label("Error", systemImage: "exclamationmark.triangle")
                } description: {
                    Text(message)
                } actions: {
                    Button("Retry") { Task { await viewModel.load() } }
                }
            }
        }
        .task { await viewModel.load() }
    }
}

Best Practices

  • Keep views dumb. Views should read state from the view model and forward actions. No business logic in views.
  • Define repository protocols in the domain layer. Concrete implementations live in the data layer. This inverts the dependency so domain never depends on infrastructure.
  • Use value types for models and DTOs. Structs are simpler, thread-safe, and encourage immutability.
  • Inject dependencies through initializers. Avoid singletons for anything stateful. Constructor injection makes dependencies explicit and enables testing.
  • One ViewModel per screen. Shared state should live in a repository or service, not in a view model passed between screens.
  • Name use cases as verbs. FetchProductsUseCase, PlaceOrderUseCase, ValidateEmailUseCase. Each represents one specific action.
  • Separate DTOs from domain models. API response shapes change independently of your domain. Mapping between them at the data layer boundary isolates the app from backend changes.

Common Pitfalls

  • Over-engineering small apps. A to-do app does not need Clean Architecture with use cases and repository protocols. Start with MVVM and add layers only when complexity justifies them.
  • Massive ViewModels. If a view model exceeds 200-300 lines, split it. Extract use cases, break the screen into child views with their own view models, or use helper types.
  • Leaking UIKit/SwiftUI into the domain layer. Importing UIKit or SwiftUI in domain types creates coupling. Domain entities should be pure Swift.
  • Treating MVVM as the only option. Some screens (static settings, simple lists) need no view model at all. Use @FetchRequest or direct model binding when appropriate.
  • Circular dependencies in DI. Service A depends on Service B which depends on Service A. Break cycles by introducing a protocol or restructuring responsibilities.

Install this skill directly: skilldb add ios-swift-skills

Get CLI access →