DEV Community

Daniil Kornilov
Daniil Kornilov

Posted on Edited on

A small SwiftUI MVVM example with dependency injection

Disclosure: This article was created or edited with AI assistance. Revised on 13 October 2026 to remove promotional links and unsupported outcome claims. It is educational guidance, not a verified personal case study or a measured result. Examples are illustrative; use only your own documented experience and measurements.

The sections below present options and illustrative examples. Adapt them to the requirements of your project or role; they do not establish a universal rule or promise a result.

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: Explicit responsibilities make dependencies easier to inspect; changes to shared contracts may still affect several 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

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

Scope of the code examples

These are separate teaching snippets, not a complete tested application or a production-ready package. Match availability to your deployment target, provide omitted model/service definitions, and check errors, cancellation, accessibility and persistence before adapting them.

Top comments (0)