DEV Community

SwiftUI MVVM Architecture: A Complete Guide for 2026

Building scalable iOS apps requires a solid architecture. MVVM (Model-View-ViewModel) has become the go-to pattern for SwiftUI developers. In this guide, I'll show you how to implement MVVM properly with practical examples.

Why MVVM for SwiftUI?

SwiftUI's declarative nature pairs perfectly with MVVM:

  • Separation of concerns: UI logic stays in Views, business logic in ViewModels
  • Testability: ViewModels can be unit tested without UI
  • Reusability: ViewModels can be shared across different views
  • Maintainability: Changes in one layer don't affect others

The Three Layers

1. Model

The Model represents your data and business logic:

struct User: Identifiable, Codable {
    let id: UUID
    var name: String
    var email: String
    var isActive: Bool
}
Enter fullscreen mode Exit fullscreen mode

2. ViewModel

The ViewModel acts as a bridge between Model and View:

import SwiftUI

@Observable
class UserViewModel {
    private(set) var users: [User] = []
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    private let networkService: NetworkService

    init(networkService: NetworkService = .shared) {
        self.networkService = networkService
    }

    func fetchUsers() async {
        isLoading = true
        errorMessage = nil

        do {
            users = try await networkService.fetch(endpoint: "/users")
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    func toggleUserStatus(_ user: User) {
        guard let index = users.firstIndex(where: { $0.id == user.id }) else { return }
        users[index].isActive.toggle()
    }
}
Enter fullscreen mode Exit fullscreen mode

3. View

The View displays data and sends user actions to ViewModel:

struct UsersListView: View {
    @State private var viewModel = UserViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading...")
                } else if let error = viewModel.errorMessage {
                    ContentUnavailableView(
                        "Error",
                        systemImage: "exclamationmark.triangle",
                        description: Text(error)
                    )
                } else {
                    usersList
                }
            }
            .navigationTitle("Users")
            .task {
                await viewModel.fetchUsers()
            }
        }
    }

    private var usersList: some View {
        List(viewModel.users) { user in
            UserRowView(user: user) {
                viewModel.toggleUserStatus(user)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Use @observable (iOS 17+)

The new @Observable macro simplifies state management:

@Observable
class ViewModel {
    var data: [Item] = []  // Automatically tracked
}
Enter fullscreen mode Exit fullscreen mode

2. Dependency Injection

Make your ViewModels testable:

class ProductViewModel {
    private let repository: ProductRepositoryProtocol

    init(repository: ProductRepositoryProtocol = ProductRepository()) {
        self.repository = repository
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Protocol-Oriented Design

Define protocols for your services:

protocol NetworkServiceProtocol {
    func fetch<T: Decodable>(endpoint: String) async throws -> T
}

class NetworkService: NetworkServiceProtocol {
    static let shared = NetworkService()

    func fetch<T: Decodable>(endpoint: String) async throws -> T {
        // Implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Error Handling

Always handle errors gracefully:

enum AppError: LocalizedError {
    case networkError
    case decodingError
    case unauthorized

    var errorDescription: String? {
        switch self {
        case .networkError: return "Network connection failed"
        case .decodingError: return "Failed to process data"
        case .unauthorized: return "Please log in again"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Project Structure

MyApp/
├── Models/
│   ├── User.swift
│   └── Product.swift
├── ViewModels/
│   ├── UserViewModel.swift
│   └── ProductViewModel.swift
├── Views/
│   ├── UsersListView.swift
│   └── ProductDetailView.swift
├── Services/
│   ├── NetworkService.swift
│   └── StorageService.swift
└── Utilities/
    └── Extensions.swift
Enter fullscreen mode Exit fullscreen mode

Testing Your ViewModel

final class UserViewModelTests: XCTestCase {
    var sut: UserViewModel!
    var mockService: MockNetworkService!

    override func setUp() {
        mockService = MockNetworkService()
        sut = UserViewModel(networkService: mockService)
    }

    func testFetchUsersSuccess() async {
        mockService.mockUsers = [User(id: UUID(), name: "Test", email: "test@test.com", isActive: true)]

        await sut.fetchUsers()

        XCTAssertEqual(sut.users.count, 1)
        XCTAssertNil(sut.errorMessage)
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

MVVM with SwiftUI creates clean, testable, and maintainable code. The key principles:

  • Keep Views dumb - they only display data
  • ViewModels handle all business logic
  • Use protocols for dependency injection
  • Write tests for your ViewModels

Want a complete starter project with MVVM already set up?

I've created SwiftUI Starter Kit Pro - a production-ready template with:

  • 5 screens (Onboarding, Auth, Home, Profile, Settings)
  • MVVM architecture implemented
  • NetworkManager & StorageManager included
  • 20+ reusable UI components
  • Dark mode support

Save 40+ hours of setup time and start building your app today!

Top comments (0)