DEV Community

Building a Complete iOS App with SwiftUI and MVVM in 2026

If you're starting a new iOS project in 2026, SwiftUI combined with MVVM (Model-View-ViewModel) is the gold standard architecture. After building several production apps with this pattern, I want to share a complete, practical guide that goes beyond theory.

In this article, we'll build a real app structure from scratch — covering project organization, View/ViewModel separation, async networking, and testability.

Why MVVM Still Wins in 2026

SwiftUI was designed with reactive data flow in mind. MVVM maps perfectly to this paradigm:

  • Model holds your data structures and business logic
  • View is your SwiftUI layout — purely declarative
  • ViewModel bridges the two, exposing published properties that Views observe

Other patterns like TCA (The Composable Architecture) have gained traction, but MVVM remains the most pragmatic choice for most teams. It's simple to understand, easy to test, and scales well.

Project Structure

Here's how I organize every SwiftUI + MVVM project:

MyApp/
├── App/
│   ├── MyApp.swift
│   └── AppDelegate.swift
├── Models/
│   ├── User.swift
│   ├── Post.swift
│   └── APIResponse.swift
├── ViewModels/
│   ├── HomeViewModel.swift
│   ├── ProfileViewModel.swift
│   └── AuthViewModel.swift
├── Views/
│   ├── Home/
│   │   ├── HomeView.swift
│   │   └── PostCardView.swift
│   ├── Profile/
│   │   ├── ProfileView.swift
│   │   └── EditProfileView.swift
│   └── Auth/
│       ├── LoginView.swift
│       └── SignUpView.swift
├── Services/
│   ├── NetworkManager.swift
│   ├── AuthService.swift
│   └── StorageService.swift
├── Utilities/
│   ├── Extensions/
│   ├── Constants.swift
│   └── Helpers.swift
└── Resources/
    ├── Assets.xcassets
    └── Localizable.strings
Enter fullscreen mode Exit fullscreen mode

Key principles:

  • Each feature gets its own folder under Views
  • ViewModels mirror the View structure
  • Services are shared across the app
  • Models are plain Swift structs (often Codable)

Building the Model Layer

Start with clean, Codable data models:

struct User: Codable, Identifiable {
    let id: UUID
    let name: String
    let email: String
    let avatarURL: URL?
    let createdAt: Date
}

struct Post: Codable, Identifiable {
    let id: UUID
    let title: String
    let body: String
    let authorId: UUID
    let createdAt: Date
    let likesCount: Int

    var formattedDate: String {
        createdAt.formatted(date: .abbreviated, time: .shortened)
    }
}

struct APIResponse<T: Codable>: Codable {
    let data: T
    let message: String?
    let success: Bool
}
Enter fullscreen mode Exit fullscreen mode

Notice that models contain zero UI logic. They're pure data containers with optional computed properties for formatting.

The ViewModel: Where Logic Lives

Here's a production-ready ViewModel pattern:

import SwiftUI

@Observable
final class HomeViewModel {
    // MARK: - Published State
    var posts: [Post] = []
    var isLoading = false
    var errorMessage: String?
    var searchText = ""

    // MARK: - Filtered Data
    var filteredPosts: [Post] {
        guard !searchText.isEmpty else { return posts }
        return posts.filter { post in
            post.title.localizedCaseInsensitiveContains(searchText) ||
            post.body.localizedCaseInsensitiveContains(searchText)
        }
    }

    // MARK: - Dependencies
    private let networkManager: NetworkManager

    init(networkManager: NetworkManager = .shared) {
        self.networkManager = networkManager
    }

    // MARK: - Actions
    func loadPosts() async {
        isLoading = true
        errorMessage = nil

        do {
            posts = try await networkManager.fetch(
                endpoint: "/posts",
                responseType: [Post].self
            )
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    func likePost(_ post: Post) async {
        do {
            try await networkManager.post(
                endpoint: "/posts/\(post.id)/like"
            )
            if let index = posts.firstIndex(where: { $0.id == post.id }) {
                posts[index] = Post(
                    id: post.id,
                    title: post.title,
                    body: post.body,
                    authorId: post.authorId,
                    createdAt: post.createdAt,
                    likesCount: post.likesCount + 1
                )
            }
        } catch {
            errorMessage = "Failed to like post"
        }
    }

    func deletePost(_ post: Post) async {
        do {
            try await networkManager.delete(
                endpoint: "/posts/\(post.id)"
            )
            posts.removeAll { $0.id == post.id }
        } catch {
            errorMessage = "Failed to delete post"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key patterns here:

  • Using @Observable (iOS 17+) instead of ObservableObject — it's more performant
  • Dependency injection through the initializer (great for testing)
  • All async operations are clearly separated
  • Error handling is user-friendly

Async Networking Layer

The networking layer should be generic and reusable:

final class NetworkManager {
    static let shared = NetworkManager()

    private let baseURL = "https://api.myapp.com/v1"
    private let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }()

    func fetch<T: Codable>(
        endpoint: String,
        responseType: T.Type
    ) async throws -> T {
        guard let url = URL(string: baseURL + endpoint) else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        // Add auth token if available
        if let token = try? KeychainManager.shared.get("authToken") {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.httpError(httpResponse.statusCode)
        }

        return try decoder.decode(T.self, from: data)
    }

    func post<T: Codable, B: Encodable>(
        endpoint: String,
        body: B? = nil as String?,
        responseType: T.Type = EmptyResponse.self as! T.Type
    ) async throws -> T {
        guard let url = URL(string: baseURL + endpoint) else {
            throw NetworkError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        if let body = body {
            request.httpBody = try JSONEncoder().encode(body)
        }

        let (data, _) = try await URLSession.shared.data(for: request)
        return try decoder.decode(T.self, from: data)
    }
}

enum NetworkError: LocalizedError {
    case invalidURL
    case invalidResponse
    case httpError(Int)
    case decodingError

    var errorDescription: String? {
        switch self {
        case .invalidURL: return "Invalid URL"
        case .invalidResponse: return "Invalid response from server"
        case .httpError(let code): return "Server error (\(code))"
        case .decodingError: return "Failed to process server response"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The View Layer: Keep It Clean

Views should be thin — just layout and bindings:

struct HomeView: View {
    @State private var viewModel = HomeViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading posts...")
                } else if let error = viewModel.errorMessage {
                    ErrorView(message: error) {
                        Task { await viewModel.loadPosts() }
                    }
                } else {
                    postsList
                }
            }
            .navigationTitle("Feed")
            .searchable(text: $viewModel.searchText)
            .refreshable {
                await viewModel.loadPosts()
            }
            .task {
                await viewModel.loadPosts()
            }
        }
    }

    private var postsList: some View {
        List(viewModel.filteredPosts) { post in
            PostCardView(post: post) {
                Task { await viewModel.likePost(post) }
            }
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    Task { await viewModel.deletePost(post) }
                } label: {
                    Label("Delete", systemImage: "trash")
                }
            }
        }
        .listStyle(.plain)
    }
}

struct PostCardView: View {
    let post: Post
    let onLike: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(post.title)
                .font(.headline)

            Text(post.body)
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .lineLimit(3)

            HStack {
                Text(post.formattedDate)
                    .font(.caption)
                    .foregroundStyle(.tertiary)

                Spacer()

                Button(action: onLike) {
                    Label("\(post.likesCount)", systemImage: "heart")
                }
                .buttonStyle(.borderless)
            }
        }
        .padding(.vertical, 8)
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Your ViewModels

One of MVVM's biggest advantages is testability:

@testable import MyApp
import XCTest

final class HomeViewModelTests: XCTestCase {
    var sut: HomeViewModel!
    var mockNetwork: MockNetworkManager!

    override func setUp() {
        super.setUp()
        mockNetwork = MockNetworkManager()
        sut = HomeViewModel(networkManager: mockNetwork)
    }

    func testLoadPostsSuccess() async {
        mockNetwork.mockPosts = [
            Post(id: UUID(), title: "Test", body: "Body", 
                 authorId: UUID(), createdAt: Date(), likesCount: 5)
        ]

        await sut.loadPosts()

        XCTAssertEqual(sut.posts.count, 1)
        XCTAssertNil(sut.errorMessage)
        XCTAssertFalse(sut.isLoading)
    }

    func testLoadPostsFailure() async {
        mockNetwork.shouldFail = true

        await sut.loadPosts()

        XCTAssertTrue(sut.posts.isEmpty)
        XCTAssertNotNil(sut.errorMessage)
    }

    func testSearchFiltering() async {
        mockNetwork.mockPosts = [
            Post(id: UUID(), title: "SwiftUI Tips", body: "", 
                 authorId: UUID(), createdAt: Date(), likesCount: 0),
            Post(id: UUID(), title: "UIKit Legacy", body: "", 
                 authorId: UUID(), createdAt: Date(), likesCount: 0)
        ]

        await sut.loadPosts()
        sut.searchText = "SwiftUI"

        XCTAssertEqual(sut.filteredPosts.count, 1)
        XCTAssertEqual(sut.filteredPosts.first?.title, "SwiftUI Tips")
    }
}
Enter fullscreen mode Exit fullscreen mode

5 Pro Tips for Production Apps

  1. Use @Observable over ObservableObject — it only triggers view updates for properties that actually changed, not the entire object.

  2. Inject dependencies — never hard-code singletons in ViewModels. Use protocol-based dependency injection for testability.

  3. Keep Views dumb — if you see if/else logic deciding what data to show, move it to the ViewModel.

  4. Use .task modifier — it automatically cancels when the view disappears, preventing memory leaks.

  5. Separate navigation from views — consider a Coordinator or Router pattern for complex navigation flows.

Wrapping Up

SwiftUI + MVVM in 2026 is a mature, battle-tested combination. The key is discipline: keep your layers clean, inject your dependencies, and write tests for your ViewModels.

The patterns shown here scale from simple utility apps to complex, multi-feature applications. Start with this structure, and you'll save yourself countless hours of refactoring later.

If you want ready-made templates and components to speed up your SwiftUI development, check out my toolkit: https://pease163.github.io/digital-products/

Happy coding!

Top comments (0)