Chapter 3: Data Modeling & Relationships
The Expense Tracker Schema
- Category: Represents a bucket for expenses (e.g., "Groceries", "Entertainment", "Rent").
- Transaction: Represents a single financial record, consisting of an amount, a date, and optionally some notes.
Design Decisions: Data Types
- Identifiers (UUID vs URIRepresentation): Core Data provides an internal identifier for every object called
NSManagedObjectID. However, this ID can change (for instance, a temporary ID becomes a permanent ID after the context is saved). Furthermore, if you ever migrate to a cloud backend or sync across devices, Core Data's internal object IDs will not match across devices. Therefore, we always define our ownidattribute of typeUUID. - Colors (String vs Custom Value Transformer): We need to store a color for each category. It is tempting to store a
UIColoror SwiftUIColorusing a Transformable attribute. Do not do this. Storing UI-specific types in the database couples your data layer to a specific UI framework. If you ever port your app to macOS or want to serialize the database to JSON,UIColoris useless. Instead, we store the color as a HexString(e.g.,"#FF5733") and let the UI layer parse it.
import Foundation
import CoreData
@objc(Transaction)
public class Transaction: NSManagedObject {}
@objc(Category)
public class Category: NSManagedObject {}
extension Transaction {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
return NSFetchRequest<Transaction>(entityName: "Transaction")
}
@NSManaged public var id: UUID?
@NSManaged public var amount: Double
@NSManaged public var date: Date?
@NSManaged public var notes: String?
@NSManaged public var category: Category?
}
Creating the .xcdatamodeld File
Defining the Entities
- Click the Add Entity button at the bottom of the editor.
- Rename the new entity to
Category. - Add another entity and rename it to
Transaction.
[!NOTE] Entity names should always be singular (e.g.,
Category, notCategories), following standard object-oriented naming conventions. You are defining the blueprint for one object.
Setting Up Attributes
id(UUID): A unique identifier for the category.name(String): The display name (e.g., "Food").colorHex(String): A hex code to represent the category's color.iconName(String): An SF Symbols icon name.id(UUID): A unique identifier.amount(Double): The monetary value.date(Date): When the transaction occurred.notes(String): Optional text for extra details.
Optionality and Defaults
- Optional vs Non-Optional: By default, Xcode makes all attributes optional. This is a defensive mechanism, but in domain modeling, optionality should strictly reflect your business logic. For our domain,
id,name,amount, anddateshould be strictly non-optional. Uncheck the "Optional" checkbox for these.notescan remain optional. - Default Values: You can provide default values for non-optional attributes. However, be cautious: setting a default date to "now" in the model editor means the default is the date the model was compiled, not the date the object is created. We will handle initialization programmatically to avoid this trap.
[!CAUTION] If you uncheck "Optional" but fail to provide a value when creating the object in code, your app will crash when calling
context.save(). Core Data enforces these validation rules strictly.
Relationships: Tying It All Together
Creating the One-to-Many Relationship
- Select the
Transactionentity. - In the Relationships section, click the + button.
- Name the relationship
category. - Set the Destination to
Category. - In the Relationships section, click the + button.
- Name the relationship
transactions. - Set the Destination to
Transaction. - In the Data Model Inspector, change the Type from "To One" to "To Many".
The Critical Importance of Inverse Relationships
import Foundation
import CoreData
func assignCategory(myTransaction: Transaction, groceriesCategory: Category) {
myTransaction.category = groceriesCategory
}
- On the
Categoryentity, select thetransactionsrelationship. - In the Data Model Inspector, set the Inverse to
category. - Go back to the
Transactionentity, select thecategoryrelationship, and verify its Inverse is now automatically set totransactions.
Configuring Delete Rules and Performance
- Nullify (Default): The destination's relationship pointer is set to
nil. If we delete "Groceries", its transactions become orphaned (theircategoryproperty becomesnil). - Cascade: Deleting the source deletes all destination objects. If we delete "Groceries", all transactions under "Groceries" are also deleted.
- Deny: Prevents deletion of the source if any destination objects exist. You can't delete "Groceries" if it has transactions.
- No Action: Does nothing. The destination object still thinks it points to the deleted object. Never use this unless you are managing the graph manually, as it guarantees a crash if accessed.
[!WARNING] Performance Tip: Cascade deletion requires Core Data to load (fault) every destination object into memory to fire their lifecycle methods (like
prepareForDeletion) and delete them one by one. If aCategoryhas 100,000 transactions, deleting the category will cause a massive memory spike and freeze the main thread. In extreme scenarios, you must bypass Cascade and use aNSBatchDeleteRequestto delete the transactions directly in SQLite. For standard iOS apps, however, Cascade is perfectly fine.
Code Generation: The "UIKit Way"
- Class Definition (Default): Xcode magically generates the class and properties behind the scenes. You don't see the code in your project navigator, and you can't easily add custom properties or domain logic to the class.
- Category/Extension: Xcode generates an extension with the properties, but expects you to write the main class definition.
- Manual/None: Xcode generates nothing automatically on build. You manually trigger the code generation, giving you 100% control over the output.
- Select the
Categoryentity. In the Data Model Inspector, change Codegen to Manual/None. - Repeat this for the
Transactionentity.
Generating the NSManagedObject Subclasses
Category+CoreDataClass.swift(The class definition)Category+CoreDataProperties.swift(The properties extension containing the@NSManagedattributes)
import Foundation
import CoreData
@objc(Transaction)
public class Transaction: NSManagedObject {}
@objc(Category)
public class Category: NSManagedObject {}
extension Transaction {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
return NSFetchRequest<Transaction>(entityName: "Transaction")
}
// Safely removed optionals for guaranteed properties
@NSManaged public var id: UUID
@NSManaged public var amount: Double
@NSManaged public var date: Date
@NSManaged public var notes: String?
@NSManaged public var category: Category
}
import Foundation
import CoreData
@objc(Transaction)
public class Transaction: NSManagedObject {}
@objc(Category)
public class Category: NSManagedObject {}
extension Category {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Category> {
return NSFetchRequest<Category>(entityName: "Category")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var colorHex: String
@NSManaged public var iconName: String
@NSManaged public var transactions: NSSet? // Core Data uses NSSet for To-Many relationships
}
Adding Domain Logic to NSManagedObjects
import Foundation
import CoreData
@objc(Transaction)
public class Transaction: NSManagedObject {
@NSManaged public var date: Date
}
@objc(Category)
public class Category: NSManagedObject {
@NSManaged public var transactions: NSSet?
/// A sorted array of transactions for easy consumption.
/// Safely casts the Objective-C NSSet to a Swift Set, then sorts it.
var sortedTransactions: [Transaction] {
let set = transactions as? Set<Transaction> ?? []
return set.sorted { $0.date > $1.date }
}
/// Helper method triggered precisely when the object is inserted into the context.
public override func awakeFromInsert() {
super.awakeFromInsert()
// Guarantee that 'id' is populated immediately upon creation.
// We use setPrimitiveValue to avoid triggering KVO notifications during initialization.
setPrimitiveValue(UUID(), forKey: "id")
}
}
Verifying Your Model Setup in Xcode
- Verify Codegen Settings: For both
CategoryandExpense(orTransaction) entities, select the entity in the project editor, navigate to the Data Model Inspector on the right, and confirm that Codegen is explicitly set to Manual/None. If left on default settings (Class Definition), Xcode will silently generate hidden background duplicate declarations, triggering persistent compilation errors (Invalid redeclaration of class). - Confirm Inverse Relationships: Ensure that neither relationship displays a yellow compiler warning in Xcode. Every relationship must explicitly point back to its counterpart (e.g.,
Category.transactionsinverse isTransaction.category). - Validate Optionality Agreements: Ensure that attributes marked as non-optional in your Swift subclass extensions (such as
idordate) possess corresponding initialization guarantees—either through schema-level default values or deterministic runtime assignment insideawakeFromInsert().
Summary & Next Steps
- Design Intentionally: We chose portable UUIDs over platform-bound Core Data primary keys for public identification, and standardized on hex formatting strings over UI colors for platform independence.
- Configure Relationships: We established bidirectional connections, understanding that Inverse Relationships are mandatory for object graph integrity during runtime graph traversal. We evaluated the critical data retention and performance boundaries between Cascade, Deny, and Nullify deletion rules.
- Master Code Generation: We discarded Xcode's automated codegen in favor of the UIKit Way (
Manual/None), claiming complete engineering authority over ourNSManagedObjectsubclasses. We eliminated legacy Objective-C optionality artifacts from guaranteed non-nil properties and harnessedawakeFromInsert()for crash-free lifecycle initialization.