Chapter 4: MVVM Architecture for Core Data
The Case Against Property Wrappers in Complex Apps
- 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. - 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. - 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.
- Threading Risks and Faults: SwiftUI views operate strictly on the main thread. Directly accessing
NSManagedObjectproperties 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. - 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
- SwiftUI Views know absolutely nothing about Core Data. They only know about their specific ViewModel.
- ViewModels manage the state for the UI, handle user intents (like adding a new expense, swiping to delete), and communicate with the Repository.
- Domain Models are pure Swift structs that act as the currency of our application. They move between the Data Layer and the Presentation Layer.
- Repository is an abstraction (a Swift
protocol) that defines what data operations are possible without exposing how they are done. - CoreData Repository is the concrete implementation of the Repository protocol. It manages the
NSManagedObjectContext, executes fetches, performs saves on background threads, and heavily utilizesNSFetchedResultsControllerto 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
NSManagedObjectinstances into immutableExpenseModelstructs, 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 ourTaskiterates 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
errorMessagestring 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
- The
AddExpenseViewModeltriggers a save via the Repository. - The Repository performs the save on a background context to prevent UI freezing.
- Core Data automatically merges the background context into the main context (because
automaticallyMergesChangesFromParent = true). - The
NSFetchedResultsControllerlistening to the main context detects the save automatically. - The Repository maps the newly fetched objects and yields the updated array of domain models to the stream.
- The
ExpenseListViewModelreceives the new models. - The
ExpenseListViewupdates 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
- Absolute Decoupling: Core Data framework constructs (
NSManagedObject,NSManagedObjectContext,NSFetchRequest) never bleed into the SwiftUI presentation layer. Views deal exclusively with immutable Swift structs. - Deterministic Thread Safety: By containing context execution inside repository boundaries, we enforce synchronous reading over
performAndWaitand isolate all write mutations onto private background contexts. - Instantaneous Testability: Because
ExpenseListViewModelrelies on the abstractExpenseRepositoryProtocol, you can inject a lightweightMockExpenseRepository. You can rigorously test complex ViewModel filtering and transformation logic without booting an underlying database store.