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
- Transaction: Represents a single financial expense (e.g., "$5.00 for Coffee").
- Category: Represents a grouping of expenses (e.g., "Food & Drink", "Transportation").
- One
Categorycan have manyTransactions. - One
Transactionbelongs to exactly oneCategory(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
- 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 theircategoryproperty becomesnil. They become "uncategorized." - 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 associatedReceiptPhoto). - 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. - 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 -> Transactionrelationship. 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
nilas thecategorytoassignCategorywill gracefully sever the relationship. Core Data will remove the transaction from the old category'stransactionsset and set the transaction'scategorypointer tonil.
3. Fetching Transactions for a Specific Category
- Access
category.transactions(which returns anNSSet) and convert it to an array. - Execute an
NSFetchRequeston theTransactionentity using anNSPredicate.
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
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
- Object Graph vs Relational: We learned that Core Data manages relationships as object graphs, handling the complexities of SQL joins invisibly.
- Inverse Relationships: We ensured our graph remains consistent and memory-leak-free by always defining inverse relationships.
- Delete Rules: We explored Nullify, Cascade, and Deny, selecting Nullify for our Expense Tracker to prevent accidental historical data loss.
- Architecture: We built a solid Repository layer to encapsulate Core Data relationship assignment, keeping our SwiftUI ViewModels clean, testable, and strictly main-thread-bound.
- Thread Safety: We learned the golden rule: relationships can only be formed between objects on the exact same context, and how to use
NSManagedObjectIDto pass objects between threads. - Performance: We conquered the N+1 problem using
relationshipKeyPathsForPrefetchingto eliminate faulting overhead during UI rendering.