Reinventing Core Data Development with SwiftData Principles

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In modern application development, an efficient persistence framework is crucial. The emergence of SwiftData once caught the eyes of many Core Data developers, who anticipated the advent of a new era integrating modern programming concepts. This article will explore how to apply SwiftData’s way of thinking within Core Data, focusing on data modeling and concurrent programming.

This article will not delve deeply into technical details but will instead focus on design ideas, strategies, and considerations.

It’s 2024—Is SwiftData Ready?

SwiftData made a surprising debut at WWDC 2023, bringing great excitement to developers. Although the initial version (iOS 17) was not fully featured and slightly underperformed, it already showed exciting potential. With iterative updates in minor versions, SwiftData’s stability gradually improved, winning the favor of some developers.

Some early adopters have applied SwiftData to new projects and were pleasantly surprised to find that the framework not only embodies modern programming philosophies but also significantly improves development efficiency and simplifies complex operations. Even more gratifying is the seamless collaboration between SwiftData and SwiftUI, as well as its perfect integration with Swift’s strict concurrency checks, which allowed developers to see a hopeful future. Many firmly believe that SwiftData will eventually become the most important data management framework in the Apple ecosystem.

However, the release of iOS 18 cast a shadow over this beautiful vision. A year after its first appearance, SwiftData underwent a major underlying refactoring. Although this adjustment was aimed at shifting from a strong coupling with Core Data to supporting more flexible multi-persistence solutions—a direction undoubtedly correct—it seems that the significant changes led to considerable impact on the new version’s stability.

Disappointingly, a large amount of SwiftData code that ran well on iOS 17 encountered various problems in the new version. For a data persistence framework that shoulders heavy responsibilities, these issues are undoubtedly fatal. What’s more worrying is that the complexity of these problems means they may not be thoroughly resolved in the short term. It is foreseeable that throughout the iOS 18 cycle, developers choosing to use SwiftData will have to continuously grapple with these challenges.

As a developer passionate about SwiftData, I have been closely following its development. Through in-depth learning and practice, I not only clarified some concepts I couldn’t grasp in Core Data but also greatly enhanced my understanding of modern programming paradigms, API design, and especially new features of the Swift language. Last year, I confidently began refactoring an application using SwiftData. Although I encountered many challenges during the process, mainly due to the lack of certain features, the overall development experience was still pleasant.

However, SwiftData’s performance on iOS 18 put me in a dilemma. For an application centered on data management, stability and reliability are non-negotiable. After repeated deliberation, I had to make a tough decision: abandon the thousands of lines of SwiftData code I had completed and return to Core Data.

This decision was not made lightly. I am well aware of SwiftData’s potential and advantages, but at this stage, project stability and reliability must come first. Nonetheless, the inspiration SwiftData brought me was not in vain. When rebuilding the Core Data project, I decided to integrate the modern thinking I learned from SwiftData, using a more innovative approach to harness this time-tested framework.

Compared to Core Data, SwiftData has shown revolutionary breakthroughs in several aspects. In the following content, we will focus on two key areas: data modeling and concurrent programming. These two aspects are where SwiftData has the most significant advantages over Core Data. Through in-depth analysis, we will explore how to inject SwiftData’s advanced concepts into Core Data, thereby creating a data management solution that is both stable and reliable, yet modern and innovative.

Declaring Data Models

Modeling Approach

When using Core Data to build data models, developers usually start with the model editor provided by Xcode. Although Apple tries to downplay the relationship between data models and the underlying database in the editor (e.g., entities corresponding to tables, attributes corresponding to fields), developers inevitably get influenced while working in the editor mode. They often adopt modeling strategies that emphasize performance and space efficiency. Additionally, as the complexity of the model increases, it becomes challenging to adjust the declaration code for each entity individually. Many developers opt to use the model code automatically generated by Xcode or make only minor adjustments.

SwiftData fundamentally reverses this modeling approach. Developers first declare entity types according to standard Swift classes, which makes them focus more on the usability and semantic expression of the model in data operations and view code. As for how to save in persistent storage, as long as it conforms to the modeling specifications, SwiftData will handle the conversion automatically.

Considering that in the current Apple ecosystem, developers are more inclined to use persistence frameworks for mobile and desktop applications, constructing model declaration code suitable for the presentation layer aligns better with most needs. The final entity code should be easily and directly usable in views and other code without requiring adjustments.

In practice, I adopted the following steps:

  • Initial Model Declaration: First, without considering the correspondence with the underlying layer, declare the entity type according to standard Swift classes, ensuring clear semantics and explicit types for ease of use.

  • Optimize the Underlying Model: When building entities in the model editor, prioritize performance and storage capacity. There’s no need to deliberately correspond with the declaration code used for the presentation layer, and automatic generation of model code is turned off.

  • Adjust Declaration Code: Adjust the initially declared Swift classes to conform to the managed object format, inspect each property individually, and perform conversions at the code declaration level for properties and types that cannot be directly mapped.

  • Construct Safe Initializers: Ensure that model instances have the necessary data upon creation, improving code safety and readability.

In SwiftData, we only need to complete the first step; the rest is automatically handled by the framework. Although doing this in Core Data adds some workload, we can strictly control the conversion methods and underlying types, giving us more control over the model compared to SwiftData.

Optionals and Default Values

In Core Data, the optional value option in the model editor often confuses developers because it doesn’t equate to the optional types in the Swift language; the two are not strictly corresponding. For example, in the model editor, a string attribute that has a default value set and the optional value option turned off corresponds to String? in the automatically generated code; whereas a floating-point attribute with the optional value option turned on corresponds to a non-optional Double.

Note: The optional value option in the model editor predates the Swift language. It indicates whether the field corresponding to the attribute in persistent storage can be empty (NULL).

SwiftData solves this inconsistency through pure code declarations. For example, if we want to declare an Item model that conforms to cloud synchronization rules, we can directly use the following code:

Swift
@Model
public final class Item {
    public var name: String = ""
    public var height: Double?
    public init(name: String, height: Double? = nil) {
        self.name = name
        self.height = height
    }
}

In this code, name is non-optional and has a default value, making it convenient to use in views. The height is declared as Swift’s optional type, clearly indicating that this property can be absent.

Following the modeling approach mentioned above, we first declare the model in Core Data:

Swift
// Initial declaration
public final class Item {
    public var name: String = ""
    public var height: Double?
}

But when we try to convert the above code into a Core Data managed object, we find that it doesn’t directly correspond:

Swift
@objc(Item)
public final class Item: NSManagedObject {}

extension Item {
    @NSManaged
    public var name: String
    @NSManaged
    public var height: Double? // Property cannot be marked @NSManaged because its type cannot be represented in Objective-C
}

image-20241012110005794

This is because Core Data is essentially based on Objective-C, which requires that the property types declared in Swift must have corresponding convertible objects in Objective-C. From the error message, we can see that Core Data cannot automatically convert Double?. Therefore, we need to manually handle the conversion at the code level:

Swift
@objc(Item)
public final class Item: NSManagedObject {}

extension Item {
    @NSManaged
    public var name: String

    public var height: Double? {
        get {
            heightValueNumber?.doubleValue
        }
        set {
            heightValueNumber = newValue as NSNumber?
        }
    }

    @NSManaged
    var heightValueNumber: NSNumber? // Not declared as public
}

To maintain consistency in the public property name, we name the corresponding stored property in the model editor as heightValueNumber, retain its original NSNumber type, and manually implement type conversion and mapping.

Enums and Codable

SwiftData also provides automatic conversion capabilities for properties of enum types and types conforming to the Codable protocol. However, as I mentioned in another article, the underlying format after conversion may not match expectations (usually implemented based on Composite attributes). Considering that the current enum types in SwiftData cannot be directly used for predicate filtering, whether in SwiftData or Core Data, I recommend using the rawValue approach for enum types and performing conversion at the code declaration level:

Swift
extension Item {
    ...
    public var valueDisplayMode: DataDisplayMode {
        get {
            DataDisplayMode(rawValue: valueDisplayModeRaw) ?? .average
        }
        set {
            valueDisplayModeRaw = newValue.rawValue
        }
    }
}

public enum DataDisplayMode: Int16, Sendable, Equatable {
    case sum = 1
    case average = 2
}

For complex property types, traditional Core Data solutions usually implement conversions based on NSSecureUnarchiveFromDataTransformer, but this method doesn’t align with Swift’s characteristics. In new projects, we can directly use encoding and decoding methods based on Codable to save data:

Swift
extension Item {
    ...
    public var options: [ItemOption]? {
        get {
            guard let data = optionsData else { return nil }
            return try? JSONDecoder().decode([ItemOption].self, from: data)
        }
        set {
            optionsData = try? JSONEncoder().encode(newValue)
        }
    }

    @NSManaged var optionsData: Data?
}

Although WWDC 2023 introduced the Composite attributes feature for Core Data, given that SwiftData’s conversion rules for types conforming to the Codable protocol are not yet fully clear, I will still use the encoding and decoding method based on Codable to persist data. Even if the project upgrades to SwiftData in the future, we can ensure the consistency of underlying data through computed properties, thereby reducing potential migration risks.

Although this handling method is different from SwiftData’s default form, at this stage, I also recommend that SwiftData developers adopt this method and consider using its default mode after SwiftData’s stability and conversion rules are fully clarified.

Relationships

When declaring the code for Core Data entity relationships, especially many-to-many relationships, I didn’t make many adjustments:

Swift
extension Item {
    @NSManaged public var note: Note?

    @NSManaged public var datas: Set<ItemData>? // Replaced NSSet with Set

    @objc(addDatasObject:)
    @NSManaged public func addToDatas(_ value: ItemData)

    @objc(removeDatasObject:)
    @NSManaged public func removeFromDatas(_ value: ItemData)

    @objc(addDatas:)
    @NSManaged public func addToDatas(_ values: NSSet)

    @objc(removeDatas:)
    @NSManaged public func removeFromDatas(_ values: NSSet)
}

The main reasons are:

  • Declaring many-to-many relationships as arrays doesn’t bring substantial benefits and may cause semantic confusion: representing an unordered set as an ordered collection.

  • Attempting to convert to SwiftData’s array-based append method for operating on to-many relationships will lead to performance degradation, as I elaborated in Relationships in SwiftData: Changes and Considerations.

It is worth noting that since the add and remove code for to-many relationships is manually written, you need to pay special attention to spelling issues, as the compiler will not check the corresponding Objective-C method names (e.g., @objc(removeDatas)).

Constructors

In SwiftData’s pure code modeling, providing a constructor is a crucial feature. Through constructors, developers can clearly specify the content that needs to be assigned, ensuring that the model instance has the necessary data when created.

However, in Core Data, developers usually create instances in the following way, which is not as good as SwiftData in terms of safety and expressing the model designer’s intentions:

Swift
let item = Item(context: context)
item.name = "hello"
// Assign values to other properties

To improve, we need to declare a custom constructor for the managed object type:

Swift
extension Item {
    public convenience init(
        name: String,
        height: Double? = nil,
        valueDisplayMode: DataDisplayMode = .sum,
        options: [ItemOption]? = nil
    ) {
        self.init(entity: Self.entity(), insertInto: nil)
        self.name = name
        self.height = height
        self.valueDisplayMode = valueDisplayMode
        self.options = options
    }
}

In the constructor, we avoid providing the context when creating the instance through self.init(entity: Self.entity(), insertInto: nil). In this way, like in SwiftData, we need to explicitly insert it into the context after creating the instance:

Swift
let item = Item(name: "fat")
context.insert(item) // Explicitly insert into context

For some Core Data developers, explicitly inserting into the context may take some time to adapt, but this approach helps unify with SwiftData’s development model.

To understand the specific construction details of managed objects, please read Exploring CoreData - From Data Model Creation to Managed Object Instances.

It should be noted that I did not provide parameters for relationship data in the constructor. This is because relationships are created within the context, and a managed object instance that is not yet registered in the context cannot correctly handle the relationship data provided in the constructor. Therefore, all relationship data needs to be set after inserting into the context:

Swift
let note = Note(name: "note1")
context.insert(note)
let item = Item(name: "fat")
context.insert(item)
item.note = note

This rule also applies to SwiftData. Although some developers like to directly provide relationship data in the constructor, practice has shown that such code is not always stable. To ensure the reliability of the code, it’s best to set the relationship data after inserting into the context.

Model Validation

Traditionally, whether using Core Data (relying on the model editor to automatically generate code) or SwiftData, developers rarely need to write separate tests for the model declaration code, because the correspondence between the model code and the underlying model is automatically handled by the framework.

But since we now manually write the model declaration code, it is very necessary to immediately write tests to verify the validity of the model after declaring it, mainly focusing on the following points:

  • Constructors: Ensure that all necessary properties are covered.

  • Custom Conversions: Verify that the conversion parts meet expectations.

  • Many-to-Many Relationships: Check whether the relationship setting parts can run correctly (whether there are spelling errors).

As the model changes and relationships adjust, the validation methods also need to be continuously updated.

Modularization

Modularization is an important feature of modern programming. Therefore, at the beginning of creating the model, it should be encapsulated into an independent library, which not only reduces the exposure of irrelevant APIs (e.g., heightValueNumber does not have public permission), but also facilitates unit testing for each library.

In my project, since libraries are created separately for multiple different data operation logics, the data model code needs to be in a separate library to facilitate use by other libraries. This data model library has a very singular function, mainly providing type declarations. Considering the particularity of Core Data when constructing the container (a managed object model file can only be held by one instance in the application), in addition to exposing the necessary managed object types, it is also necessary to provide a unique NSManagedObjectModel instance for the entire project. (Since SwiftData’s model is entirely code-based, there is no such limitation)

Swift
@preconcurrency import CoreData

public enum DBModelConfiguration {
    public nonisolated static let objectModel: NSManagedObjectModel = {
        guard let modelURL = Bundle.module.url(forResource: "CareLog", withExtension: "momd") else {
            fatalError("Couldn't find the CareLog.momd file in the module bundle.")
        }
        guard let model = NSManagedObjectModel(contentsOf: modelURL) else {
            fatalError("Couldn't load the CareLog.momd file in the module bundle.")
        }
        return model
    }()
}

Since NSManagedObjectModel is not Sendable, we need to use @preconcurrency to eliminate warnings. In my current project (with Swift 6 mode enabled), among all the Core Data code, this is the only place that needs manual intervention to avoid concurrency safety warnings.

Additionally, if the model code of SwiftData is encapsulated into a separate library, you may need to include relevant predicate settings in that library. The reason is that when constructing predicates in SwiftData, it relies on KeyPaths. Therefore, if some properties in the model are non-public (such as the rawValue of enums and other underlying stored properties), these properties can only be used as filter conditions when they are in the same library as the model. In contrast, Core Data’s predicates are constructed based on strings and do not care about the visibility of properties, so they are not subject to this limitation.

Helge Heß developed a third-party library called ManagedModels, aiming to provide an experience similar to the @Model feature in SwiftData.

Concurrency

If in SwiftData, developers need to relinquish some control to enjoy the advantages of pure code modeling, then the changes SwiftData brings to concurrency are pure benefits for developers.

Replacing perform with Actors

SwiftData’s @ModelActor provides an isolated environment with controllable execution threads for data operation code (based on custom actor executors using context threads). In this isolated environment, developers can write code without any concerns, no longer constrained by the perform method.

Fortunately, the custom actor executor feature is included in Swift 5.9 (requires iOS 17+). With the @NSModelActor macro I developed for Core Data, Core Data developers can also enjoy the same concurrent programming experience as in SwiftData.

For more details, please read Core Data Reform: Achieving Elegant Concurrency Operations like SwiftData.

Swift
@NSModelActor
public actor DataHandler {}

extension DataHandler {
    // Public methods
    public func deleteItemData(
        for itemDataID: PersistentObjectID,
        saveImmediately: Bool
    ) throws(DataHandlerError) { ... }

    public func deleteItemDatas(
        for itemDataIDs: [PersistentObjectID],
        saveImmediately: Bool
    ) throws(DataHandlerError) { ... }

    public func createItemData(
        with viewModel: ItemDataVM,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) -> PersistentObjectID { ... }
  
    public func updateItemData(
        with viewModel: ItemDataVM,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) { ... }

    // Internal methods
    func createItemDataObject(
        with itemDataVM: ItemDataVM,
        in item: Item,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) -> ItemData { ... }

    func updateItemDataObject(
        for itemData: ItemData,
        with itemDataVM: ItemDataVM,
        saveImmediately: Bool = true
    ) throws(DataHandlerError) { ... }
}

Notice that all public APIs in the actor only accept and return Sendable NSManagedObjectIDs. For methods used only within the actor, it’s safe to use managed objects as parameters. This is a basic principle for safe concurrent programming, applicable to both Core Data and SwiftData.

Since Xcode 16, Apple has annotated NSManagedObjectID and NSPersistentContainer in Core Data with @unchecked Sendable. This makes their concurrency characteristics consistent with corresponding types in SwiftData, allowing developers to safely pass instances of these two types across different threads.

As for how to perform concurrent programming around @NSModelActor, this article will not elaborate further because the basic methods and ideas are consistent with those introduced in Practical SwiftData: Building SwiftUI Applications with Modern Approaches.

Testing

After encapsulating data logic code into an actor, especially when Swift 6 mode is enabled, some new changes occur in unit testing. For example, here’s a test code sample:

Swift
@Test
func createRootNoteTest() async throws {
    let container = PersistentController.createTestContainer(#function)
    let handler = DataHandler(container: container)
    // Create data
    try await handler.createRootNote(with: NoteVM.sample2)
    // Verify results
    ...
}

To verify whether data was successfully created without recreating the context (still using the context within the actor), we need to build an auxiliary method for the actor:

Swift
extension DataHandler {
    public func perform<T>(_ action: (NSManagedObjectContext) throws -> T) throws -> T where T: Sendable {
        try action(modelContext)
    }
}

This way, we can conveniently insert the logic that needs to be verified into the actor in the test code and check the results:

Swift
@Test
func createRootNoteTest() async throws {
    let container = PersistentController.createTestContainer(#function)
    let handler = DataHandler(container: container)
    // Create data
    try await handler.createRootNote(with: NoteVM.sample2)
    // Verify results
    let query = Note.query(for: .allRootNotes(sortBy: nil))
    // Run assertion code within the actor
    try await handler.perform { context in
        #expect(try context.count(for: query) == 1)
    }
}

Additionally, constructing an independent database file for each unit test is very important. This allows us to fully utilize the default parallel testing capabilities provided by Swift Testing, greatly improving testing efficiency:

Swift
extension PersistentController {
    /// Creates an NSPersistentContainer for testing (only includes entities in the specified configuration)
    static func createTestContainer(
        _ name: String,
        enablePHT: Bool = false,
        configurationName: String = DBModelConfiguration.privateConfigurationName,
        subDirectory: String = "DBCoreDataTestTemp"
    ) -> NSPersistentContainer {
        let tempURL = URL.temporaryDirectory
        // Create subDirectory in tempURL if it doesn't exist
        if !FileManager.default.fileExists(atPath: tempURL.appendingPathComponent(subDirectory).path) {
            try? FileManager.default
                .createDirectory(at: tempURL.appendingPathComponent(subDirectory), withIntermediateDirectories: true)
        }
        let url = tempURL.appendingPathComponent(subDirectory).appendingPathComponent(
            name + ".sqlite"
        )
        // Remove existing database file to ensure a fresh test environment each time
        if FileManager.default.fileExists(atPath: url.path) {
            try? FileManager.default.removeItem(at: url)
        }
        let container = NSPersistentContainer(name: name, managedObjectModel: model)
        let description = NSPersistentStoreDescription(url: url)
        description.configuration = configurationName
        description.shouldAddStoreAsynchronously = false
        // Enable Persistent History Tracking
        if enablePHT {
            description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
            description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        }
        container.persistentStoreDescriptions = [description]
        container.loadPersistentStores { _, error in
            if let error {
                fatalError("Failed to load Persistent Store: \(error)")
            }
        }
        return container
    }
}

Compared to using an in-memory database (/dev/null) with viewContext, data operations based on an independent database and actors in unit tests are more efficient and completely avoid test instability caused by insufficient data preparation in viewContext under serial mode.

The techniques for unit testing with SwiftData and Core Data are themselves topics worth exploring in depth, and I plan to write dedicated articles to discuss them in the future.

Conclusion

This article focuses on how to integrate SwiftData’s new thinking into Core Data projects. It is evident that developers often need to invest significant extra effort to achieve an experience similar to SwiftData. This also highlights the enormous amount of work SwiftData does behind the scenes to achieve automatic conversion, as it needs to accommodate more scenarios and enhance generality. Simultaneously, due to the introduction of a decoupling layer from Core Data, SwiftData faces substantial challenges in performance and stability.

So, after putting in more effort, how effective is Core Data code that applies SwiftData’s principles? For me, the results are very satisfying. Except for requiring more effort during the modeling process, I’ve achieved a SwiftData-like experience in all aspects and currently enjoy better performance and stability.

While it’s unfortunate that the new architecture hasn’t delivered the expected stability, if we can learn and grasp new knowledge from it and apply it to existing frameworks and projects, there will undoubtedly be valuable gains.