Chapter 10: Managing Relationships

Understanding Relationships in Core Data: Beyond SQLite

graph LR subgraph Relational_Database_Mindset ["Relational Database Mindset"] T[Transaction Table\nFK: category_id] -.->|"JOIN ON category_id = id"| C[Category Table\nid] end subgraph Core_Data_Object_Graph_Mindset ["Core Data Object Graph Mindset"] TObj((Transaction Object)) -->|"category property"| CObj((Category Object)) CObj -->|"transactions set"| TObj end

The Expense Tracker Model

  1. Transaction: Represents a single financial expense (e.g., "$5.00 for Coffee").
  2. Category: Represents a grouping of expenses (e.g., "Food & Drink", "Transportation").
  • One Category can have many Transactions.
  • One Transaction belongs to exactly one Category (or zero, if uncategorized).
classDiagram class Category { +UUID id +String name +String colorHex +NSSet~Transaction~ transactions +awakeFromInsert() } class Transaction { +UUID id +Double amount +Date date +String note +Category category +validateForInsert() } Category "1" -- "*" Transaction : has (One-to-Many)

The Crucial Role of Inverse Relationships

[!WARNING] Failing to define an inverse relationship is one of the most common causes of silent Core Data corruption.

Configuring Relationships: Delete Rules and Data Integrity

  1. Nullify (Default): The relationship pointer is simply set to nil. If you delete "Food", all transactions previously categorized as "Food" will still exist in the database, but their category property becomes nil. They become "uncategorized."
  2. Cascade: Deletion cascades down the graph. If you delete "Food", every single transaction in the "Food" category is completely deleted from the database. Use this for strong parent-child ownership (e.g., if you delete a Transaction, you definitely want to Cascade-delete its associated ReceiptPhoto).
  3. Deny: Prevents the deletion of the source object entirely if there is at least one related object at the destination. You cannot delete the "Food" category as long as it has transactions. Calling context.delete(foodCategory) will result in a validation error when you try to save. The user must manually move or delete the transactions first.
  4. No Action: Core Data does absolutely nothing to the destination objects. The destination objects will still contain a raw pointer to the deleted object, leading to a crash if accessed. Never use this unless you are manually managing graph integrity (which is almost never).
flowchart TD subgraph Nullify_Rule ["Nullify Rule"] A1[Delete Category: Food] --> B1(Transaction 1: Pizza) A1 --> C1(Transaction 2: Coffee) B1 -->|"Category becomes nil"| D1(Transaction 1: Pizza\nCategory: nil) C1 -->|"Category becomes nil"| E1(Transaction 2: Coffee\nCategory: nil) end subgraph Cascade_Rule ["Cascade Rule"] A2[Delete Category: Food] --> B2(Transaction 1: Pizza) A2 --> C2(Transaction 2: Coffee) B2 -->|"Hard Delete"| D2((Destroyed)) C2 -->|"Hard Delete"| E2((Destroyed)) end subgraph Deny_Rule ["Deny Rule"] A3[Delete Category: Food] --> B3(Check for Transactions) B3 -->|"Transactions Exist"| C3[Block Deletion\nThrow Validation Error] B3 -->|"No Transactions"| D3[Allow Deletion] end

[!IMPORTANT] For our app, we will use Nullify for the Category -> Transaction relationship. We do not want users losing their historical financial data just because they decided to reorganize their categories.

The MVVM Architecture for Core Data Relationships

sequenceDiagram participant View as SwiftUI View participant VM as ViewModel participant Repo as CoreDataRepository participant FRC as NSFetchedResultsController participant CD as Core Data Context View->>VM: User taps "Assign Category" VM->>Repo: assignCategory(category, to: transaction) Repo->>CD: transaction.category = category Note over Repo,CD: Core Data updates inverse automatically Repo->>CD: try context.save() CD-->>FRC: Context Did Save Notification FRC-->>Repo: controllerDidChangeContent Repo->>Repo: Extract updated array Repo-->>VM: AsyncStream yields new state VM->>VM: State updates VM-->>View: @Observable triggers UI refresh

Implementing the Repository Layer

1. Defining the Protocols

import Foundation
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

@objc(Category)
public class Category: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var colorHex: String?
    @NSManaged public var transactions: NSSet?
}

protocol TransactionRepository {
    // Basic CRUD
    func createTransaction(amount: Double, date: Date, note: String) -> Transaction
    func deleteTransaction(_ transaction: Transaction)
    
    // Relationships
    func assignCategory(_ category: Category?, to transaction: Transaction)
    
    // Targeted Fetching
    func fetchTransactions(for category: Category?) -> [Transaction]
    
    // Reactive Observation
    var transactionsStream: AsyncStream<[Transaction]> { get }
}

protocol CategoryRepository {
    func createCategory(name: String, colorHex: String) -> Category
    func deleteCategory(_ category: Category)
    func fetchAllCategories() -> [Category]
    var categoriesStream: AsyncStream<[Category]> { get }
}

2. Assigning and Managing the Relationship

import Foundation
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

@objc(Category)
public class Category: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var colorHex: String?
    @NSManaged public var transactions: NSSet?
}

protocol TransactionRepository {
    func createTransaction(amount: Double, date: Date, note: String) -> Transaction
    func deleteTransaction(_ transaction: Transaction)
    func assignCategory(_ category: Category?, to transaction: Transaction)
    func fetchTransactions(for category: Category?) -> [Transaction]
    var transactionsStream: AsyncStream<[Transaction]> { get }
}

class CoreDataTransactionRepository: NSObject, TransactionRepository {
    private let context: NSManagedObjectContext
    private let fetchedResultsController: NSFetchedResultsController
    
    // We use an AsyncStream so new subscribers can asynchronously receive the latest data array.
    private var transactionsContinuation: AsyncStream<[Transaction]>.Continuation?
    public lazy var transactionsStream: AsyncStream<[Transaction]> = {
        AsyncStream { continuation in
            self.transactionsContinuation = continuation
        }
    }()
    
    init(context: NSManagedObjectContext) {
        self.context = context
        
        let request = NSFetchRequest(entityName: "Transaction")
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Transaction.date, ascending: false)]
        
        // TIP: Prefetching prevents the N+1 query problem when displaying lists.
        // We will cover this in detail in the Performance section below.
        request.relationshipKeyPathsForPrefetching = ["category"]
        
        self.fetchedResultsController = NSFetchedResultsController(
            fetchRequest: request,
            managedObjectContext: context,
            sectionNameKeyPath: nil,
            cacheName: nil
        )
        
        super.init()
        self.fetchedResultsController.delegate = self as? NSFetchedResultsControllerDelegate
        
        do {
            try self.fetchedResultsController.performFetch()
            updatePublisher()
        } catch {
            print("CRITICAL: Failed to initialize transactions fetch: \(error)")
        }
    }
    
    private func updatePublisher() {
        if let fetchedObjects = fetchedResultsController.fetchedObjects {
            transactionsContinuation?.yield(fetchedObjects)
        }
    }
    
    // MARK: - Relationships
    
    func assignCategory(_ category: Category?, to transaction: Transaction) {
        // Enforce thread safety. Relationships must be modified on the context's queue.
        context.performAndWait {
            // Because we established an inverse relationship in the model editor,
            // this single line updates BOTH transaction.category AND category.transactions.
            transaction.category = category
            
            do {
                if context.hasChanges {
                    try context.save()
                }
            } catch {
                print("Failed to assign category. Rolling back changes. Error: \(error)")
                // Revert the in-memory graph to match the persistent store on failure
                context.rollback()
            }
        }
    }
    
    func createTransaction(amount: Double, date: Date, note: String) -> Transaction { fatalError() }
    func deleteTransaction(_ transaction: Transaction) {}
    func fetchTransactions(for category: Category?) -> [Transaction] { return [] }
}

[!NOTE] Passing nil as the category to assignCategory will gracefully sever the relationship. Core Data will remove the transaction from the old category's transactions set and set the transaction's category pointer to nil.

3. Fetching Transactions for a Specific Category

  1. Access category.transactions (which returns an NSSet) and convert it to an array.
  2. Execute an NSFetchRequest on the Transaction entity using an NSPredicate.
import Foundation
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

@objc(Category)
public class Category: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var colorHex: String?
    @NSManaged public var transactions: NSSet?
}

class CoreDataTransactionRepository: NSObject {
    let context: NSManagedObjectContext
    var transactionsStream: AsyncStream<[Transaction]> { AsyncStream { _ in } }
    
    init(context: NSManagedObjectContext) {
        self.context = context
    }
    
    func createTransaction(amount: Double, date: Date, note: String) -> Transaction { fatalError() }
    func deleteTransaction(_ transaction: Transaction) {}
    func assignCategory(_ category: Category?, to transaction: Transaction) {}
}

extension CoreDataTransactionRepository {
    func fetchTransactions(for category: Category?) -> [Transaction] {
        let request = NSFetchRequest(entityName: "Transaction")
        
        if let category = category {
            // Predicate querying the relationship directly.
            // Core Data safely converts the object to its underlying ID for the SQL query.
            request.predicate = NSPredicate(format: "category == %@", category)
        } else {
            // Fetching all uncategorized transactions
            request.predicate = NSPredicate(format: "category == nil")
        }
        
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Transaction.date, ascending: false)]
        request.relationshipKeyPathsForPrefetching = ["category"]
        
        var results: [Transaction] = []
        
        // performAndWait ensures we block and return the results safely
        context.performAndWait {
            do {
                results = try context.fetch(request)
            } catch {
                print("Failed to fetch transactions for category: \(error)")
            }
        }
        
        return results
    }
}

4. Reactive Updates with NSFetchedResultsController

import Foundation
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

class CoreDataTransactionRepository: NSObject {
    var fetchedResultsController: NSFetchedResultsController!
    var transactionsContinuation: AsyncStream<[Transaction]>.Continuation?
    
    func updateStream() {
        guard let fetchedObjects = fetchedResultsController.fetchedObjects else { return }
        // The ViewModels consuming this stream will asynchronously receive the fresh array
        transactionsContinuation?.yield(fetchedObjects)
    }
}

extension CoreDataTransactionRepository: NSFetchedResultsControllerDelegate {
    func controllerDidChangeContent(_ controller: NSFetchedResultsController) {
        updateStream()
    }
}

Building the ViewModels

The Category Detail ViewModel

import Foundation
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

@objc(Category)
public class Category: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var colorHex: String?
    @NSManaged public var transactions: NSSet?
}

protocol TransactionRepository {
    func fetchTransactions(for category: Category?) -> [Transaction]
    var transactionsStream: AsyncStream<[Transaction]> { get }
}

@MainActor
@Observable class CategoryDetailViewModel {
    var transactions: [Transaction] = []
    
    private let transactionRepository: TransactionRepository
    private let category: Category
    
    init(category: Category, transactionRepository: TransactionRepository) {
        self.category = category
        self.transactionRepository = transactionRepository
        
        // Initial manual fetch for immediate display before the stream fires
        self.transactions = transactionRepository.fetchTransactions(for: category)
        
        // Observe changes to the overall transaction list to stay reactive.
        // If a new transaction is created and assigned to this category elsewhere in the app,
        // this view will update automatically.
        Task { @MainActor in
            for await allTransactions in transactionRepository.transactionsStream {
                // Filter the updated list for this specific category
                self.transactions = allTransactions.filter { $0.category == self.category }
            }
        }
    }
    
    func totalSpent() -> Double {
        // High-order functions keep the logic clean and functional
        transactions.reduce(0) { $0 + $1.amount }
    }
}

The Assign Category ViewModel

import Foundation
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

@objc(Category)
public class Category: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var colorHex: String?
    @NSManaged public var transactions: NSSet?
}

protocol TransactionRepository {
    func assignCategory(_ category: Category?, to transaction: Transaction)
}

protocol CategoryRepository {
    var categoriesStream: AsyncStream<[Category]> { get }
}

@MainActor
@Observable class AssignCategoryViewModel {
    var categories: [Category] = []
    let transaction: Transaction
    
    private let categoryRepository: CategoryRepository
    private let transactionRepository: TransactionRepository
    
    init(transaction: Transaction, categoryRepository: CategoryRepository, transactionRepository: TransactionRepository) {
        self.transaction = transaction
        self.categoryRepository = categoryRepository
        self.transactionRepository = transactionRepository
        
        Task { @MainActor in
            for await updatedCategories in categoryRepository.categoriesStream {
                self.categories = updatedCategories
            }
        }
    }
    
    func assign(_ category: Category) {
        transactionRepository.assignCategory(category, to: transaction)
    }
    
    func removeCategory() {
        transactionRepository.assignCategory(nil, to: transaction)
    }
}

Building the SwiftUI Views

The Category Detail View

import SwiftUI
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

@objc(Category)
public class Category: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var colorHex: String?
    @NSManaged public var transactions: NSSet?
}

@MainActor
@Observable class CategoryDetailViewModel {
    var transactions: [Transaction] = []
    func totalSpent() -> Double { return 0 }
}

struct CategoryDetailView: View {
    let category: Category
    @State var viewModel: CategoryDetailViewModel
    
    var body: some View {
        List {
            Section(header: Text("Summary").font(.headline)) {
                HStack {
                    Text("Total Spent")
                    Spacer()
                    Text("$\(viewModel.totalSpent(), specifier: "%.2f")")
                        .bold()
                        .foregroundColor(viewModel.totalSpent() > 0 ? .red : .primary)
                }
            }
            
            Section(header: Text("Transactions").font(.headline)) {
                if viewModel.transactions.isEmpty {
                    Text("No transactions in this category.")
                        .foregroundColor(.secondary)
                        .italic()
                } else {
                    ForEach(viewModel.transactions) { transaction in
                        HStack {
                            VStack(alignment: .leading, spacing: 4) {
                                Text(transaction.note ?? "Unknown")
                                    .font(.subheadline)
                                    .fontWeight(.medium)
                                Text(transaction.date ?? Date(), style: .date)
                                    .font(.caption)
                                    .foregroundColor(.secondary)
                            }
                            Spacer()
                            Text("$\(transaction.amount, specifier: "%.2f")")
                                .font(.callout)
                        }
                        .padding(.vertical, 4)
                    }
                }
            }
        }
        .listStyle(InsetGroupedListStyle())
        .navigationTitle("Category Details")
        .navigationBarTitleDisplayMode(.inline)
    }
}

The Assign Category View

import SwiftUI
import CoreData
import Observation

@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var amount: Double
    @NSManaged public var date: Date?
    @NSManaged public var note: String?
    @NSManaged public var category: Category?
}

@objc(Category)
public class Category: NSManagedObject, Identifiable {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
    @NSManaged public var colorHex: String?
    @NSManaged public var transactions: NSSet?
}

@MainActor
@Observable class AssignCategoryViewModel {
    var categories: [Category] = []
    var transaction: Transaction!
    func assign(_ category: Category) {}
    func removeCategory() {}
}

extension Color {
    init(hex: String) { self.init(uiColor: .black) }
}

struct AssignCategoryView: View {
    @Environment(\.dismiss) var dismiss
    @State var viewModel: AssignCategoryViewModel
    
    var body: some View {
        NavigationView {
            List {
                Section {
                    Button(role: .destructive, action: {
                        viewModel.removeCategory()
                        dismiss()
                    }) {
                        Label("Uncategorize", systemImage: "trash.slash")
                    }
                }
                
                Section(header: Text("Available Categories")) {
                    ForEach(viewModel.categories) { category in
                        Button(action: {
                            viewModel.assign(category)
                            dismiss()
                        }) {
                            HStack {
                                Circle()
                                    .fill(Color(hex: category.colorHex ?? "#000000"))
                                    .frame(width: 16, height: 16)
                                
                                Text(category.name ?? "Unnamed")
                                    .foregroundColor(.primary)
                                
                                Spacer()
                                
                                // Show a checkmark if this is the currently assigned relationship
                                if viewModel.transaction.category == category {
                                    Image(systemName: "checkmark")
                                        .foregroundColor(.blue)
                                        .fontWeight(.bold)
                                }
                            }
                        }
                    }
                }
            }
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("Assign Category")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Cancel", role: .cancel) { dismiss() }
                        .fontWeight(.semibold)
                }
            }
        }
    }
}

Advanced Topic: Threading and Context Hierarchies

graph TD subgraph MainContext___Main_Context__View_Context___ ["MainContext ["Main Context (View Context)"]"] CatMain["Category A
ID: 0x123"] end subgraph BackgroundContext___Background_Context__ ["BackgroundContext ["Background Context"]"] CatBG["Category A
ID: 0x123"] Trans1["New Transaction"] Trans1 -->|"Valid Assignment"| CatBG Trans1 -.->|"CRASH: Context Mismatch!"| CatMain end CatMain -->|"passing objectID is safe"| CatBG
import Foundation
import CoreData

@objc(Category)
public class Category: NSManagedObject {
    @NSManaged public var id: UUID?
    @NSManaged public var name: String?
}

@objc(Transaction)
public class Transaction: NSManagedObject {
    @NSManaged public var amount: Double
    @NSManaged public var category: Category?
}

func assignCategoryExample(selectedCategory: Category, backgroundContext: NSManagedObjectContext) {
    // On Main Thread: User selected a category
    let selectedCategoryID = selectedCategory.objectID

    // Move to background thread for heavy processing
    backgroundContext.perform {
        // 1. Fetch the local representation of the category into the background context
        guard let localCategory = try? backgroundContext.existingObject(with: selectedCategoryID) as? Category else {
            return // Category might have been deleted!
        }
        
        // 2. Create the transaction in the background context
        let newTransaction = Transaction(context: backgroundContext)
        newTransaction.amount = 50.0
        
        // 3. Assign the relationship safely (Both are now on the backgroundContext)
        newTransaction.category = localCategory
        
        try? backgroundContext.save()
    }
}

Advanced Topic: Performance, Faulting, and Memory

The N+1 Query Problem

Prefetching to the Rescue

import Foundation
import CoreData

@objc(Transaction)
public class Transaction: NSManagedObject {
    @NSManaged public var date: Date?
}

func prefetchExample(context: NSManagedObjectContext) {
    let request = NSFetchRequest(entityName: "Transaction")

    // Tell Core Data to load the Category object in the exact same SQL query!
    request.relationshipKeyPathsForPrefetching = ["category"]

    let fetchedResultsController = NSFetchedResultsController(
        fetchRequest: request,
        managedObjectContext: context,
        sectionNameKeyPath: nil,
        cacheName: nil
    )
}

Memory Management: Turning Objects Back into Faults

import Foundation
import CoreData

@objc(Transaction)
public class Transaction: NSManagedObject {}

func refreshExample(context: NSManagedObjectContext, allTransactions: [Transaction]) {
    // Inside a repository method responding to memory warnings
    context.perform {
        for transaction in allTransactions {
            // Turns the transaction and its loaded relationships back into a lightweight fault, freeing RAM
            context.refresh(transaction, mergeChanges: false) 
        }
    }
}

Advanced Tip: Validating Relationships

import Foundation
import CoreData

@objc(Category)
public class Category: NSManagedObject {}

@objc(Transaction)
public class Transaction: NSManagedObject {
    @NSManaged public var category: Category?
}

extension Transaction {
    public override func validateForInsert() throws {
        try super.validateForInsert()
        try validateCategoryRelationship()
    }

    public override func validateForUpdate() throws {
        try super.validateForUpdate()
        try validateCategoryRelationship()
    }

    private func validateCategoryRelationship() throws {
        if self.category == nil {
            let errorDict: [String: Any] = [
                NSLocalizedDescriptionKey: "A transaction must have an assigned category."
            ]
            throw NSError(domain: "ExpenseTrackerErrorDomain", code: 1001, userInfo: errorDict)
        }
    }
}

Summary

  1. Object Graph vs Relational: We learned that Core Data manages relationships as object graphs, handling the complexities of SQL joins invisibly.
  2. Inverse Relationships: We ensured our graph remains consistent and memory-leak-free by always defining inverse relationships.
  3. Delete Rules: We explored Nullify, Cascade, and Deny, selecting Nullify for our Expense Tracker to prevent accidental historical data loss.
  4. Architecture: We built a solid Repository layer to encapsulate Core Data relationship assignment, keeping our SwiftUI ViewModels clean, testable, and strictly main-thread-bound.
  5. Thread Safety: We learned the golden rule: relationships can only be formed between objects on the exact same context, and how to use NSManagedObjectID to pass objects between threads.
  6. Performance: We conquered the N+1 problem using relationshipKeyPathsForPrefetching to eliminate faulting overhead during UI rendering.