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
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
}
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"
}
}
}
Key patterns here:
- Using
@Observable(iOS 17+) instead ofObservableObject— 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"
}
}
}
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)
}
}
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")
}
}
5 Pro Tips for Production Apps
Use
@ObservableoverObservableObject— it only triggers view updates for properties that actually changed, not the entire object.Inject dependencies — never hard-code singletons in ViewModels. Use protocol-based dependency injection for testability.
Keep Views dumb — if you see
if/elselogic deciding what data to show, move it to the ViewModel.Use
.taskmodifier — it automatically cancels when the view disappears, preventing memory leaks.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)