Chapter 4: MVVM Architecture for Core Data

The Case Against Property Wrappers in Complex Apps

  1. Tight Coupling to the Persistence Framework: When a SwiftUI view uses @FetchRequest, the view becomes intimately aware of the persistence framework. If you ever want to migrate away from Core Data to a different local database (like Realm or SQLite), or if you want to transition to a purely network-driven architecture, you have to rewrite your UI layer.
  2. Preview Nightmares: SwiftUI Previews are one of the framework's best features, but they become a massive pain point when Core Data is involved. To preview a view using @FetchRequest, you must spin up an in-memory Core Data stack, populate it with mock entities, and inject the context into the environment. This slows down preview generation and adds unnecessary boilerplate.
  3. Scattered Business Logic: Filtering, sorting, and data manipulation often bleed into the UI layer. Your Views end up making decisions about data formatting, grouping, and filtering rather than just rendering state. This makes the logic impossible to unit test without UI tests.
  4. Threading Risks and Faults: SwiftUI views operate strictly on the main thread. Directly accessing NSManagedObject properties in the view can lead to severe concurrency crashes if those objects were fetched on a background context. Furthermore, accessing a relationship on a managed object might fire a "fault" (a lazily loaded object). If this fault firing triggers a heavy disk read on the main thread while the user is scrolling, your app will drop frames and stutter.
  5. Poor Testability: Testing UI is notoriously difficult and slow. By moving data access and business logic into a ViewModel and Repository, you can write fast, reliable, and headless unit tests against standard Swift structs without ever needing a UI test harness or simulator.

The Architecture Overview

flowchart TD subgraph UI_Layer ["UI Layer"] View[SwiftUI View] end subgraph Presentation_Layer ["Presentation Layer"] ViewModel[ViewModel] end subgraph Domain_Layer ["Domain Layer"] DomainModel[Swift Structs / POSOs] end subgraph Data_Layer ["Data Layer"] Repository[Repository Protocol] CoreDataRepo[CoreData Repository Implementation] NSFRC[NSFetchedResultsController] Context[NSManagedObjectContext] end View -->|"Reads State / Sends Intents"| ViewModel ViewModel -->|"Calls Methods"| Repository CoreDataRepo -.->|"Conforms"| Repository CoreDataRepo -->|"Configures & Reads"| NSFRC CoreDataRepo -->|"Fetches & Saves"| Context NSFRC -->|"Notifies Changes via Delegate"| CoreDataRepo CoreDataRepo -->|"Maps Entities to"| DomainModel CoreDataRepo -->|"Publishes Updates (DomainModels)"| ViewModel
  1. SwiftUI Views know absolutely nothing about Core Data. They only know about their specific ViewModel.
  2. ViewModels manage the state for the UI, handle user intents (like adding a new expense, swiping to delete), and communicate with the Repository.
  3. Domain Models are pure Swift structs that act as the currency of our application. They move between the Data Layer and the Presentation Layer.
  4. Repository is an abstraction (a Swift protocol) that defines what data operations are possible without exposing how they are done.
  5. CoreData Repository is the concrete implementation of the Repository protocol. It manages the NSManagedObjectContext, executes fetches, performs saves on background threads, and heavily utilizes NSFetchedResultsController to react to database changes efficiently.

Defining the Domain Models

import Foundation
import SwiftUI

/// A plain Swift struct representing an Expense. 
/// This is completely decoupled from Core Data.
public struct ExpenseModel: Identifiable, Hashable {
    public let id: UUID
    public let title: String
    public let amount: Double
    public let date: Date
    
    // We flatten relationships into simple types. 
    // Instead of holding a reference to a Category object, we extract the needed data.
    public let categoryName: String?
    public let categoryColorHex: String?
    
    public init(id: UUID = UUID(), 
                title: String, 
                amount: Double, 
                date: Date, 
                categoryName: String? = nil,
                categoryColorHex: String? = nil) {
        self.id = id
        self.title = title
        self.amount = amount
        self.date = date
        self.categoryName = categoryName
        self.categoryColorHex = categoryColorHex
    }
    
    /// A computed property to easily convert the hex string to a SwiftUI Color
    public var categoryColor: Color {
        guard let hex = categoryColorHex else { return .gray }
        return Color(hex: hex) ?? .gray
    }
}

Creating the Repository Protocol

import Foundation

public enum RepositoryError: Error, LocalizedError {
    case fetchFailed(UnderlyingError: Error)
    case saveFailed(UnderlyingError: Error)
    case entityNotFound
    case validationError(reason: String)
    
    public var errorDescription: String? {
        switch self {
        case .fetchFailed(let err): return "Failed to load data: \(err.localizedDescription)"
        case .saveFailed(let err): return "Failed to save data: \(err.localizedDescription)"
        case .entityNotFound: return "The requested record could not be found."
        case .validationError(let reason): return "Invalid data: \(reason)"
        }
    }
}
import Foundation
import Observation

/// Protocol defining the data operations for Expenses.
public protocol ExpenseRepositoryProtocol {
    /// A stream that yields an array of expenses whenever the underlying data changes.
    var expensesStream: AsyncStream<[ExpenseModel]> { get }
    
    /// Fetches the latest expenses immediately.
    func fetchExpenses() throws
    
    /// Adds a new expense to the data store. 
    /// Note the use of async throws for background processing.
    func addExpense(title: String, amount: Double, date: Date, categoryId: UUID?) async throws
    
    /// Deletes an existing expense by its unique identifier.
    func deleteExpense(with id: UUID) async throws
}

Implementing the Core Data Repository

Setting up the Basic Repository

import Foundation
import CoreData

public class CoreDataExpenseRepository: ExpenseRepositoryProtocol {
    private let context: NSManagedObjectContext
    private var expensesContinuation: AsyncStream<[ExpenseModel]>.Continuation?
    public lazy var expensesStream: AsyncStream<[ExpenseModel]> = {
        AsyncStream { continuation in
            self.expensesContinuation = continuation
        }
    }()
    
    public init(context: NSManagedObjectContext) {
        self.context = context
        try? fetchExpenses()
    }
    
    public func fetchExpenses() throws {
        let request: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \ExpenseEntity.date, ascending: false)]
        
        do {
            let entities = try context.performAndWait {
                try context.fetch(request)
            }
            
            let models = entities.compactMap { entity -> ExpenseModel? in
                guard let id = entity.id, 
                      let title = entity.title, 
                      let date = entity.date else {
                    return nil 
                }
                
                return ExpenseModel(
                    id: id,
                    title: title,
                    amount: entity.amount,
                    date: date,
                    categoryName: entity.category?.name,
                    categoryColorHex: entity.category?.colorHex
                )
            }
            expensesContinuation?.yield(models)
        } catch {
            throw RepositoryError.fetchFailed(UnderlyingError: error)
        }
    }
    
    public func addExpense(title: String, amount: Double, date: Date, categoryId: UUID?) async throws {}
    public func deleteExpense(with id: UUID) async throws {}
}

[!NOTE] Why map to structs? By extracting primary primitive values (Strings, Doubles, Dates) out of NSManagedObject instances into immutable ExpenseModel structs, our presentation layer is completely completely immune to Core Data faulting crashes and thread boundary violations.

Implementing Write Operations and Refreshing State

extension CoreDataExpenseRepository {
    
    /// Helper to perform background saves safely.
    private func performBackgroundSave(block: @escaping (NSManagedObjectContext) throws -> Void) async throws {
        let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
        backgroundContext.parent = self.context 
        
        try await backgroundContext.perform {
            try block(backgroundContext)
            
            if backgroundContext.hasChanges {
                do {
                    try backgroundContext.save()
                } catch {
                    throw RepositoryError.saveFailed(UnderlyingError: error)
                }
            }
        }
        
        try await self.context.perform {
            if self.context.hasChanges {
                do {
                    try self.context.save()
                } catch {
                    throw RepositoryError.saveFailed(UnderlyingError: error)
                }
            }
            // Refresh local state and yield new array to AsyncStream
            try? self.fetchExpenses()
        }
    }
    
    public func addExpense(title: String, amount: Double, date: Date, categoryId: UUID?) async throws {
        // Basic validation before touching Core Data
        guard !title.trimmingCharacters(in: .whitespaces).isEmpty else {
            throw RepositoryError.validationError(reason: "Title cannot be empty")
        }
        guard amount > 0 else {
            throw RepositoryError.validationError(reason: "Amount must be greater than zero")
        }
        
        try await performBackgroundSave { bgContext in
            let newExpense = ExpenseEntity(context: bgContext)
            newExpense.id = UUID()
            newExpense.title = title
            newExpense.amount = amount
            newExpense.date = date
            
            // If a category was provided, we must fetch it on THIS background context.
            // You cannot fetch a category on the main context and assign it to an 
            // entity on the background context!
            if let categoryId = categoryId {
                let request: NSFetchRequest<CategoryEntity> = CategoryEntity.fetchRequest()
                request.predicate = NSPredicate(format: "id == %@", categoryId as CVarArg)
                request.fetchLimit = 1
                
                if let category = try bgContext.fetch(request).first {
                    newExpense.category = category
                }
            }
        }
    }
    
    public func deleteExpense(with id: UUID) async throws {
        try await performBackgroundSave { bgContext in
            // Find the entity to delete within the background context
            let request: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
            request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
            request.fetchLimit = 1
            
            guard let entityToDelete = try bgContext.fetch(request).first else {
                throw RepositoryError.entityNotFound
            }
            
            bgContext.delete(entityToDelete)
        }
    }
}

ViewModels: The Bridge to SwiftUI

sequenceDiagram participant View as SwiftUI View participant VM as ExpenseListViewModel participant Repo as ExpenseRepository participant FRC as NSFetchedResultsController View->>VM: onAppear() VM->>Repo: fetchExpenses() Repo->>FRC: performFetch() FRC-->>Repo: Core Data Entities Repo->>Repo: Map to [ExpenseModel] Repo-->>VM: Yields via AsyncStream VM->>VM: Updates properties VM-->>View: View redraws with new data

Implementing ExpenseListViewModel

import Foundation
import Observation

@MainActor
@Observable
public class ExpenseListViewModel {
    
    // View States
    public var expenses: [ExpenseModel] = []
    public var isLoading: Bool = false
    public var errorMessage: String? = nil
    
    private let repository: ExpenseRepositoryProtocol
    
    // Dependency Injection allows us to pass a MockRepository for testing
    public init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        setupBindings()
    }
    
    private func setupBindings() {
        // Listen for changes from the repository and update the UI state.
        Task { @MainActor in
            for await newExpenses in repository.expensesStream {
                self.expenses = newExpenses
            }
        }
    }
    
    public func loadData() {
        isLoading = true
        errorMessage = nil
        do {
            try repository.fetchExpenses()
            isLoading = false
        } catch {
            isLoading = false
            self.errorMessage = "Failed to fetch expenses: \(error.localizedDescription)"
        }
    }
    
    public func delete(at offsets: IndexSet) {
        // Translate IndexSet from SwiftUI List to Domain Model UUIDs
        let idsToDelete = offsets.map { expenses[$0].id }
        
        Task {
            for id in idsToDelete {
                do {
                    try await repository.deleteExpense(with: id)
                } catch {
                    self.errorMessage = "Failed to delete expense: \(error.localizedDescription)"
                }
            }
        }
    }
}
  • @MainActor: Guarantees that UI updates happen on the main thread. Because our Task iterates over the stream, ensuring the ViewModel itself is on the main actor protects SwiftUI.
  • Dependency Injection: By injecting ExpenseRepositoryProtocol, this ViewModel is entirely decoupled from Core Data.
  • Error Handling: Instead of crashing or failing silently, the ViewModel catches errors and sets an errorMessage string that SwiftUI can effortlessly bind to an .alert().

Connecting to SwiftUI

import SwiftUI

struct ExpenseListView: View {
    
    @State private var viewModel: ExpenseListViewModel
    
    init(repository: ExpenseRepositoryProtocol) {
        // Inject the repository into the ViewModel
        _viewModel = State(wrappedValue: ExpenseListViewModel(repository: repository))
    }
    
    var body: some View {
        NavigationView {
            ZStack {
                // State 1: Loading
                if viewModel.isLoading && viewModel.expenses.isEmpty {
                    ProgressView("Loading Expenses...")
                } 
                // State 2: Empty
                else if viewModel.expenses.isEmpty {
                    VStack {
                        Image(systemName: "tray")
                            .font(.largeTitle)
                            .foregroundColor(.gray)
                        Text("No expenses yet. Tap + to add one!")
                            .foregroundColor(.secondary)
                            .padding(.top, 8)
                    }
                } 
                // State 3: Populated
                else {
                    List {
                        ForEach(viewModel.expenses) { expense in
                            ExpenseRowView(expense: expense)
                        }
                        .onDelete(perform: viewModel.delete)
                    }
                    // Optimization tip: Use plain list style for better performance on large lists
                    .listStyle(.plain) 
                }
            }
            .navigationTitle("Expenses")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: {
                        // Action to present Add Expense screen
                    }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .onAppear {
                viewModel.loadData()
            }
            // Error Handling UI
            .alert(item: Binding<AlertItem?>(
                get: { viewModel.errorMessage.map { AlertItem(message: $0) } },
                set: { _ in viewModel.errorMessage = nil }
            )) { alertItem in
                Alert(title: Text("Error"), message: Text(alertItem.message))
            }
        }
    }
}

// A simple helper for SwiftUI Alerts
struct AlertItem: Identifiable {
    let id = UUID()
    let message: String
}

// Subview for rendering an individual row
struct ExpenseRowView: View {
    let expense: ExpenseModel
    
    var body: some View {
        HStack {
            // Category Color Indicator
            Circle()
                .fill(expense.categoryColor)
                .frame(width: 12, height: 12)
            
            VStack(alignment: .leading) {
                Text(expense.title)
                    .font(.headline)
                if let categoryName = expense.categoryName {
                    Text(categoryName)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }
            
            Spacer()
            
            Text(String(format: "$%.2f", expense.amount))
                .fontWeight(.bold)
        }
        .padding(.vertical, 4)
    }
}

The Power of Mocks in Previews

class MockExpenseRepository: ExpenseRepositoryProtocol {
    var expensesStream: AsyncStream<[ExpenseModel]> {
        AsyncStream { continuation in
            continuation.yield([
                ExpenseModel(title: "Coffee", amount: 4.50, date: Date(), categoryName: "Food", categoryColorHex: "#FFA500"),
                ExpenseModel(title: "Internet Bill", amount: 75.00, date: Date(), categoryName: "Utilities", categoryColorHex: "#0000FF")
            ])
            continuation.finish()
        }
    }
    
    func fetchExpenses() throws { /* No op */ }
    func addExpense(title: String, amount: Double, date: Date, categoryId: UUID?) async throws { /* No op */ }
    func deleteExpense(with id: UUID) async throws { /* No op */ }
}

struct ExpenseListView_Previews: PreviewProvider {
    static var previews: some View {
        ExpenseListView(repository: MockExpenseRepository())
    }
}

Complex Data Flows: Adding an Expense

sequenceDiagram participant User participant AddView as AddExpenseView participant AddVM as AddExpenseViewModel participant Repo as ExpenseRepository participant Context as NSManagedObjectContext participant FRC as NSFetchedResultsController participant ListVM as ExpenseListViewModel User->>AddView: Taps "Save" AddView->>AddVM: saveExpense(title, amount, ...) AddVM->>Repo: addExpense(title, amount, date) Note right of Repo: Switch to Background Thread Repo->>Context: insert() & save() Context-->>Context: Merges up to Main Context Note left of Context: Switch to Main Thread Context-->>FRC: Notifies FRC of merge FRC->>Repo: controllerDidChangeContent() Repo->>Repo: Maps new data to [ExpenseModel] Repo-->>ListVM: Yields via expensesStream ListVM->>ListVM: Updates expenses ListVM-->>User: SwiftUI List redraws automatically
  1. The AddExpenseViewModel triggers a save via the Repository.
  2. The Repository performs the save on a background context to prevent UI freezing.
  3. Core Data automatically merges the background context into the main context (because automaticallyMergesChangesFromParent = true).
  4. The NSFetchedResultsController listening to the main context detects the save automatically.
  5. The Repository maps the newly fetched objects and yields the updated array of domain models to the stream.
  6. The ExpenseListViewModel receives the new models.
  7. The ExpenseListView updates instantly.

Managing the Core Data Stack Instance

import SwiftUI
import CoreData

@main
struct ExpenseTrackerApp: App {
    
    // Core Data Stack initialized once at launch
    let persistentContainer: NSPersistentContainer
    
    // The central repository instance that drives the app
    let expenseRepository: ExpenseRepositoryProtocol
    
    init() {
        persistentContainer = NSPersistentContainer(name: "ExpenseTrackerModel")
        
        // CRITICAL: Ensure the context automatically merges changes saved from background contexts.
        // If this is false, saves on background threads will NEVER appear in our UI!
        persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
        
        // Recommended for SwiftUI apps to keep the UI smooth during merges
        persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        
        persistentContainer.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data failed to load: \(error)")
            }
        }
        
        // Initialize the concrete repository
        expenseRepository = CoreDataExpenseRepository(context: persistentContainer.viewContext)
    }
    
    var body: some Scene {
        WindowGroup {
            // Inject the repository down the hierarchy
            ExpenseListView(repository: expenseRepository)
        }
    }
}

Summary & Next Steps

  1. Absolute Decoupling: Core Data framework constructs (NSManagedObject, NSManagedObjectContext, NSFetchRequest) never bleed into the SwiftUI presentation layer. Views deal exclusively with immutable Swift structs.
  2. Deterministic Thread Safety: By containing context execution inside repository boundaries, we enforce synchronous reading over performAndWait and isolate all write mutations onto private background contexts.
  3. Instantaneous Testability: Because ExpenseListViewModel relies on the abstract ExpenseRepositoryProtocol, you can inject a lightweight MockExpenseRepository. You can rigorously test complex ViewModel filtering and transformation logic without booting an underlying database store.

The Reactive Challenge Ahead