Getting Started with SwiftUI and MVVM Architecture
Getting Started with SwiftUI and MVVM Architecture
SwiftUI has revolutionized iOS development with its declarative syntax and powerful features. When combined with the MVVM (Model-View-ViewModel) architecture pattern, you can build highly maintainable and testable applications.
Why MVVM with SwiftUI?
MVVM is particularly well-suited for SwiftUI because of its reactive nature. The ViewModel acts as a bridge between your Model (data) and View (UI), handling business logic and state management.
Think of MVVM like a restaurant:
- Model = The ingredients and recipes (raw data)
- ViewModel = The chef who prepares and processes ingredients (business logic)
- View = The beautifully plated dish served to customers (UI presentation)
Key Benefits:
- Separation of Concerns: Clear separation between UI and business logic - like having separate kitchen staff for prep, cooking, and plating
- Testability: ViewModels can be tested independently - you can test your chef's cooking without needing the entire restaurant
- Reusability: ViewModels can be shared across multiple views - one recipe (ViewModel) can serve multiple dishes (Views)
- Maintainability: Easier to maintain and scale your codebase - if the recipe changes, you only update the chef's instructions, not the entire restaurant
- SwiftUI Integration: Works seamlessly with @Published and ObservableObject - automatic notifications when the food is ready
Understanding the MVVM Components
Model
The Model represents your data structures and business entities. It should be pure data with no UI dependencies.What makes a good Model? Think of your Model as a blueprint or contract. It defines what data exists and how it's structured, but doesn't care about where it comes from or how it's displayed. A User is a User whether it's shown on an iPhone, iPad, or in a unit test.
Key principles:
- No import SwiftUI - Models should work anywhere (iOS, macOS, backend, tests)
- Codable for easy JSON parsing
- Identifiable for use in SwiftUI lists
- Computed properties for derived data (like formatting)
- Value types (structs) for thread safety and predictability
swift
// Model - Pure data structures
struct User: Identifiable, Codable {
let id: UUID
let name: String
let email: String
let avatarURL: URL?
let joinDate: Date
var displayName: String {
name.isEmpty ? "Anonymous" : name
}
}
struct Post: Identifiable, Codable {
let id: UUID
let userId: UUID
let title: String
let content: String
let createdAt: Date
let likes: Int
}
ViewModel
The ViewModel contains presentation logic and acts as the intermediary between Model and View.The ViewModel is your data conductor. Think of it like a news anchor who receives raw information (Model) from reporters and presents it in a digestible format for viewers (View). The anchor decides what to show, when to show it, handles breaking news (async updates), and responds to viewer feedback (user interactions).
What the ViewModel handles:
- Data fetching: Making API calls, database queries
- Business logic: Filtering, sorting, formatting data for display
- State management: Loading states, error states, success states
- User actions: Button taps, form submissions translated into business operations
- Computed properties: Derived data that Views can directly consume
swift
// ViewModel - Presentation logic and state management
@MainActor
class UserViewModel: ObservableObject {
// Published properties that Views can observe
@Published var users: [User] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Published var searchText = ""
// Dependencies injected for testability
private let userService: UserServiceProtocol
private let analytics: AnalyticsService
init(userService: UserServiceProtocol = UserService(),
analytics: AnalyticsService = AnalyticsService.shared) {
self.userService = userService
self.analytics = analytics
}
// Computed property for filtered results
var filteredUsers: [User] {
guard !searchText.isEmpty else { return users }
return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
// Async operation with error handling
func fetchUsers() async {
isLoading = true
errorMessage = nil
do {
users = try await userService.getUsers()
analytics.logEvent("users_fetched", parameters: ["count": users.count])
} catch {
errorMessage = "Failed to load users: \(error.localizedDescription)"
analytics.logError(error)
}
isLoading = false
}
// User actions
func deleteUser(_ user: User) async throws {
try await userService.deleteUser(id: user.id)
users.removeAll { $0.id == user.id }
}
func refreshData() async {
await fetchUsers()
}
}
View
The View is purely declarative and observes the ViewModel.Views are dumb, and that's a good thing. Your View should be like a digital billboard - it displays whatever content it receives and responds to interactions, but contains zero business logic. This makes Views incredibly simple, predictable, and easy to modify.
What Views should do:
- Display data from the ViewModel
- Respond to user interactions by calling ViewModel methods
- Handle UI-specific logic (animations, navigation, layout)
- Observe ViewModel changes via @StateObject or @ObservedObject
- Make network calls
- Perform data transformations
- Contain business logic
- Directly access services or databases
swift
// View - Pure UI with no business logic
struct UserListView: View {
@StateObject private var viewModel = UserViewModel()
@State private var selectedUser: User?
var body: some View {
NavigationStack {
ZStack {
if viewModel.isLoading {
ProgressView("Loading users...")
} else {
userListContent
}
}
.navigationTitle("Users")
.searchable(text: $viewModel.searchText)
.refreshable {
await viewModel.refreshData()
}
.task {
await viewModel.fetchUsers()
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
if let error = viewModel.errorMessage {
Text(error)
}
}
}
}
private var userListContent: some View {
List {
ForEach(viewModel.filteredUsers) { user in
UserRow(user: user)
.onTapGesture {
selectedUser = user
}
.swipeActions {
Button(role: .destructive) {
Task {
try? await viewModel.deleteUser(user)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.sheet(item: $selectedUser) { user in
UserDetailView(user: user)
}
}
}
struct UserRow: View {
let user: User
var body: some View {
HStack {
AsyncImage(url: user.avatarURL) { image in
image.resizable()
} placeholder: {
Color.gray
}
.frame(width: 50, height: 50)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(user.displayName)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}
Advanced MVVM Patterns
1. Dependency Injection for Testing
Why Dependency Injection matters: Imagine trying to test a car's steering wheel, but the wheel is permanently welded to the engine. You can't test the steering independently - you'd need the entire car. Dependency Injection is like having modular parts that plug together. Want to test the steering? Plug it into a test engine. Want to use it in a real car? Plug it into the real engine.
The Protocol Pattern: By defining protocols (interfaces), we create contracts that say "I need something that can fetch users" without caring whether it's a real network service or a mock for testing. This is the foundation of testable, flexible code.
Real-world benefit: During development, you can swap the real API service with a mock that returns instant data. No more waiting 2 seconds for each API call while testing your UI. In tests, you can simulate error conditions, slow networks, or edge cases that would be hard to reproduce with a real backend.
swift
// Protocol for dependency injection
protocol UserServiceProtocol {
func getUsers() async throws -> [User]
func deleteUser(id: UUID) async throws
}
// Production implementation
class UserService: UserServiceProtocol {
func getUsers() async throws -> [User] {
let url = URL(string: "https://api.example.com/users")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([User].self, from: data)
}
func deleteUser(id: UUID) async throws {
let url = URL(string: "https://api.example.com/users/\(id)")!
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
_ = try await URLSession.shared.data(for: request)
}
}
// Mock for testing
class MockUserService: UserServiceProtocol {
var mockUsers: [User] = []
var shouldThrowError = false
func getUsers() async throws -> [User] {
if shouldThrowError {
throw NSError(domain: "test", code: -1)
}
return mockUsers
}
func deleteUser(id: UUID) async throws {
mockUsers.removeAll { $0.id == id }
}
}
2. State Management with Combine
What is Combine and why use it? Combine is Apple's framework for reactive programming. Think of it as a sophisticated notification system with superpowers. Instead of manually checking "did the user type something new?" every few milliseconds, Combine lets you create pipelines that automatically react to changes.
The Search Pipeline Analogy: Imagine a water filtration system:
- Source ($searchQuery) - Raw water enters the system
- Debounce - Wait 500ms to make sure water flow is stable (don't process every single keystroke)
- RemoveDuplicates - Filter out if it's the same water (skip if search query hasn't actually changed)
- Filter - Remove impurities (ignore empty searches)
- Sink - Clean water comes out (perform the search)
swift
import Combine
@MainActor
class SearchViewModel: ObservableObject {
@Published var searchQuery = ""
@Published var searchResults: [User] = []
@Published var isSearching = false
private var cancellables = Set()
private let searchService: SearchService
init(searchService: SearchService = SearchService()) {
self.searchService = searchService
setupSearchPipeline()
}
private func setupSearchPipeline() {
$searchQuery
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.sink { [weak self] query in
Task {
await self?.performSearch(query: query)
}
}
.store(in: &cancellables)
}
private func performSearch(query: String) async {
isSearching = true
defer { isSearching = false }
do {
searchResults = try await searchService.search(query: query)
} catch {
searchResults = []
}
}
}
3. Handling Complex State
The Problem with Boolean Flags: Many developers start with multiple boolean flags: isLoading, hasError, isSuccess. But this creates impossible states. What if isLoading = true AND hasError = true? Is it loading or erroring?
The Enum Solution: Using an enum ensures your state is always valid. Think of it like a traffic light - it can only be red, yellow, or green. Never red AND green simultaneously.
Benefits:
- Type safety: Compiler catches impossible states
- Clarity: Always know exactly what state you're in
- Exhaustive switching: Compiler forces you to handle all cases
- Associated values: Each state can carry relevant data (loaded state has posts, error state has the error)
swift
@MainActor
class PostViewModel: ObservableObject {
enum LoadingState {
case idle
case loading
case loaded([Post])
case error(Error)
}
@Published var state: LoadingState = .idle
func loadPosts() async {
state = .loading
do {
let posts = try await PostService.shared.fetchPosts()
state = .loaded(posts)
} catch {
state = .error(error)
}
}
}
struct PostListView: View {
@StateObject private var viewModel = PostViewModel()
var body: some View {
Group {
switch viewModel.state {
case .idle:
Color.clear
.task { await viewModel.loadPosts() }
case .loading:
ProgressView()
case .loaded(let posts):
List(posts) { post in
PostRow(post: post)
}
case .error(let error):
ErrorView(error: error) {
Task { await viewModel.loadPosts() }
}
}
}
}
}
Best Practices
1. Keep ViewModels Focused
Each ViewModel should have a single responsibility. Don't create massive "God" ViewModels.Why it matters: A ViewModel that handles user profile, posts, messaging, and settings becomes a maintenance nightmare. Think of it like a Swiss Army knife vs. specialized tools - while a Swiss Army knife can do many things, professional chefs use dedicated knives for different tasks because they're better at their specific job.
Rule of thumb: If your ViewModel file is over 200 lines, it's probably doing too much. Break it into smaller, focused ViewModels.
2. Use @MainActor
Mark ViewModels with @MainActor to ensure UI updates happen on the main thread.What's @MainActor? It's Swift's way of saying "everything in this class runs on the main thread." SwiftUI requires all UI updates to happen on the main thread, and @MainActor enforces this automatically.
Without @MainActor: You might accidentally update UI from a background thread → crash With @MainActor: Compiler ensures thread safety → no crashes
3. Avoid View Logic in ViewModels
Don't import SwiftUI in ViewModels. Keep them UI-agnostic for better testability.The Separation Principle: Your ViewModel should not know about Colors, Fonts, or Views. It deals in data and logic only. This means you can test your ViewModel on a Mac, Linux server, or in continuous integration without needing an iOS simulator.
Bad: viewModel.buttonColor = .red Good: viewModel.isError = true (View decides red means error)
4. Proper Error Handling
Always handle errors gracefully and provide meaningful messages to users.User-friendly errors: Don't show users "Error 500" or technical stack traces. Transform errors into actionable messages:
- ❌ "NSURLErrorDomain -1009"
- ✅ "No internet connection. Please check your WiFi and try again."
5. Memory Management
Use [weak self] in closures to avoid retain cycles.The Retain Cycle Problem: Imagine two people holding hands in a circle, and you ask them to let go. They can't - each is waiting for the other. This is a retain cycle. The ViewModel holds the closure, the closure holds the ViewModel → memory leak.
Solution: [weak self] breaks the cycle by saying "I'll hold you loosely. If you get deallocated, I'll become nil and that's okay."
swift
timer.sink { [weak self] _ in
self?.updateData()
}
Testing ViewModels
Why test ViewModels? Testing ViewModels is like having a safety net for your business logic. When you refactor code, add features, or fix bugs, tests ensure you didn't accidentally break something that was working.
The AAA Pattern (Arrange, Act, Assert): Think of testing like a science experiment:
- Arrange (Given): Set up the test environment - create mock data, configure dependencies
- Act (When): Perform the action you want to test - call a method, trigger an event
- Assert (Then): Verify the results - check if the outcome matches expectations
swift
@MainActor
final class UserViewModelTests: XCTestCase {
var sut: UserViewModel!
var mockService: MockUserService!
override func setUp() {
super.setUp()
mockService = MockUserService()
sut = UserViewModel(userService: mockService)
}
func testFetchUsersSuccess() async {
// Given
let expectedUsers = [
User(id: UUID(), name: "John", email: "john@example.com", avatarURL: nil, joinDate: Date())
]
mockService.mockUsers = expectedUsers
// When
await sut.fetchUsers()
// Then
XCTAssertEqual(sut.users.count, 1)
XCTAssertEqual(sut.users.first?.name, "John")
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.errorMessage)
}
func testFetchUsersError() async {
// Given
mockService.shouldThrowError = true
// When
await sut.fetchUsers()
// Then
XCTAssertTrue(sut.users.isEmpty)
XCTAssertNotNil(sut.errorMessage)
XCTAssertFalse(sut.isLoading)
}
}
Common Pitfalls to Avoid
1. Putting UI Code in ViewModels
Problem: import SwiftUI in your ViewModel file Why it's bad: You're tying your business logic to the UI framework, making it impossible to test without a simulator Solution: Keep ViewModels pure Swift - no SwiftUI, no UIKit Example: Instead of var textColor: Color, use var isError: Bool and let the View decide the color2. Not Using Dependency Injection
Problem: Creating services directly: let api = APIService() Why it's bad: You can't swap it with a mock for testing, can't change implementations easily Solution: Inject dependencies through initializers using protocols Real impact: Without DI, your tests will make real network calls, fail randomly, and be impossible to run offline3. Forgetting @MainActor
Problem: Updating @Published properties from background threads Why it's bad: SwiftUI panics when you try to update UI from non-main threads → purple warnings, crashes Solution: Mark your ViewModel class with @MainActor Debugging tip: If you see "Publishing changes from background threads is not allowed", this is your problem4. Overusing @Published
Problem: Making every property @Published "just in case" Why it's bad: Performance overhead - each @Published property sets up observation machinery Solution: Only use @Published for properties that Views actually observe Example: Internal calculation properties don't need @Published5. Creating Retain Cycles
Problem: Strong self references in closures: timer.sink { self.update() } Why it's bad: ViewModel never deallocates → memory leak → app gets slower over time Solution: Use [weak self] in closures and optional chaining Detection: Use Xcode Instruments' Leaks tool to find theseConclusion
MVVM with SwiftUI provides a robust architecture for building maintainable iOS applications. By following these patterns and best practices, you can create apps that are easy to test, maintain, and scale.
Your MVVM Journey - Start Here:
- Week 1: Build a simple app with basic MVVM - just Model, ViewModel, and View
- Week 2: Add dependency injection for one service - see how much easier testing becomes
- Week 3: Implement proper state management with enums - eliminate impossible states
- Week 4: Write your first ViewModel tests - experience the confidence they provide
Common Question: "Is MVVM overkill for small apps?" Not if you plan to grow. Starting with good architecture is like building a house with a solid foundation. Sure, you could skip the foundation for a shed, but if you want to add a second floor later, you'll regret it.
Remember: Start simple, add complexity only when needed, and always prioritize code readability and testability. Your future self (and your teammates) will thank you.
The best architecture is the one that makes your code easy to understand, easy to test, and easy to change. MVVM with SwiftUI achieves all three.
Frequently Asked Questions (FAQ)
Q: Do I need MVVM for small SwiftUI apps?
For prototypes or single-screen apps, plain SwiftUI views are fine. However, once you have 3+ screens or plan to add features later, MVVM prevents technical debt. The upfront 10-15% time investment saves 40-60% refactoring time later. Start with MVVM if your app will live longer than 3 months.
Q: How do I handle navigation with MVVM in SwiftUI?
Use a Coordinator pattern or Router alongside MVVM. ViewModels should trigger navigation events, not perform navigation directly. Example: ViewModel publishes @Published var shouldNavigate = false, View observes this and uses NavigationLink or programmatic navigation. This keeps ViewModels testable and Views focused on presentation.
Q: Should ViewModels communicate with each other?
No. ViewModels should never directly reference other ViewModels. Use a shared Service layer or State Manager for cross-screen communication. Example: LoginViewModel updates UserService, ProfileViewModel observes UserService. This prevents tight coupling and makes testing easier.
Q: How do I test ViewModels that use Combine publishers?
Use XCTest with expectations or Combine's sink in tests. Example: Create ViewModel, call method, collect published values using collect(1) or first(), assert expected values. For time-based publishers, use Combine Schedulers (TestScheduler) to control timing and make tests deterministic.
Q: What's the difference between @StateObject and @ObservedObject?
@StateObject creates and owns the ViewModel (use in parent view that creates the VM). @ObservedObject observes an existing ViewModel passed from parent (use in child views). Wrong choice causes ViewModels to be recreated on every view update, losing state and causing bugs. Rule: Creator uses @StateObject, receivers use @ObservedObject.
Q: How do I handle errors and loading states in MVVM?
Use an enum for state management: enum ViewState. ViewModel has @Published var state: ViewState<[Item]> = .idle. View switches on state to show loading spinner, content, or error. This eliminates impossible states (e.g., showing loading and error simultaneously) and makes error handling explicit.
Related Guides
📖 Master iOS Development: [iOS Development Hub 2025: Complete Swift, SwiftUI & UIKit Guide](/en/blog/ios-development-hub-2025) - Comprehensive resource covering Swift fundamentals, SwiftUI, UIKit, MVVM architecture, Firebase backend integration, performance optimization, testing strategies, and iOS developer career guidance.
Happy coding! 🚀

Ali Mert Güleç
Mobil Odaklı Full Stack Mühendis
7+ yıllık iOS, Android ve React Native geliştirme uzmanlığı ile olağanüstü mobil deneyimler yaratmaya tutkulu. Dünya çapında işletmelerin fikirlerini milyonlarca aktif kullanıcıya sahip başarılı uygulamalara dönüştürmelerine yardımcı oldum.