In my previous article, I discussed the current reality of Core Data in today’s projects: it hasn’t disappeared, and it still has unique value, but the disconnect between it and modern Swift projects is becoming increasingly apparent. In this article, I’d like to continue down that path and introduce an experimental project of mine: Core Data Evolution (CDE).
It’s not a new framework meant to replace Core Data, nor is it an attempt to drag developers back to old technology. More precisely, it’s my own answer to these disconnects: If I still value Core Data’s object graph model, its migration system, and its mature runtime capabilities, can I make it continue to exist in modern Swift projects in a more natural way?
Who CDE Is For
From the very first day I started conceiving this project, CDE’s goal was clear: it’s not designed for developers having their first encounter with persistence, nor was it born to lower the learning curve of Core Data.
It’s better suited for developers and teams who:
- Are already using Core Data
- Still value Core Data’s object graph modeling approach
- Still need its mature migration, storage, and runtime capabilities
- But clearly feel the friction between traditional Core Data code and modern Swift projects
In other words, CDE is more of a toolkit for improving the Core Data development experience, rather than a framework that “teaches you Core Data all over again.”
It primarily attempts to address the following problems:
- Providing a more modern model declaration approach for
NSManagedObjectsubclasses, making model source code feel more natural in Swift - Striking a better balance between flexibility, accuracy, and type safety
- Offering SwiftData-like actor isolation patterns to reduce the cognitive overhead of Core Data concurrency code
- Making data environment setup and assertions in unit tests safer and easier
- Reducing drift between model declaration code and
.xcdatamodeldthrough tooling
It’s important to note that: Projects using CDE still rely on xcdatamodeld as the sole model source in production.
This isn’t a compromise—it’s a foundational design premise.
Some Preliminary Considerations
Before introducing CDE’s capabilities, I’d like to explain a few design decisions behind it.
Why Keep the Model File
When I first encountered SwiftData, the most striking part was its macro-based modeling approach: model declarations looked as natural as ordinary Swift types, with macros filling in the persistence implementation details; meanwhile, the container could build its model directly from the macro-generated schema, without needing an external xcdatamodeld file.
That experience felt very modern and very elegant.
But as projects continued to evolve, I came to realize more and more clearly: once an app ships, model evolution is no longer just a matter of “how elegant is the code.” Even if every change meets lightweight migration requirements, you still have to maintain a set of versioned model code to track changes clearly. typealias can ease some of the noise, but the more model versions you have, the harder that noise becomes to ignore.
By contrast, an external model file may not be as “cool” and has its own inconveniences, but it has one very practical advantage: the project only ever needs to contain a single, current set of model source code.
Moreover, virtually all existing Core Data projects already depend on external model files. Not disrupting this established path also significantly lowers the barrier to adopting CDE.
Therefore, CDE made this choice:
- Continue to keep
.xcdatamodeldas the true model source in production - Use Swift Macros and tooling to add a modern Swift-style source code expression layer on top of it
Whether to Preserve All of Core Data’s Capabilities
CDE doesn’t prevent developers from using all of Core Data’s capabilities, but when designing the macros and CLI tool, I intentionally chose not to support certain features.
The reason isn’t complicated: these features either hinder cloud sync or would increase resistance when migrating to SwiftData or other modern solutions in the future.
For example, in the current version, CDE imposes some explicit constraints on source declarations and tooling:
- Persisted attributes must be Optional, or provide a default value
- Relationships must be Optional
- Every relationship must have an inverse
deleteRuledoes not support.noAction- Derived Attributes are not supported
- Not support entity inheritance
These restrictions aren’t meant to “weaken” Core Data, but to ensure that projects using CDE adhere from the start to a set of model conventions that are easier to maintain in the long run.
Developers can of course still bypass these constraints at the model layer, but aside from Derived Attributes, most of these adjustments already align with common lightweight migration requirements and shouldn’t cause substantial disruption to existing projects.
System Version Requirements
I’ve always hoped to keep CDE’s system requirements as low as possible, but due to limitations in certain system APIs and language capabilities, its current minimum supported versions are:
- iOS 13+
- macOS 10.15+
- watchOS 6+
- tvOS 13+
- visionOS 1+
This already covers a good number of modern Swift projects.
However, it’s worth noting that composition depends on Core Data’s composite attribute capability, so this part requires higher system versions:
- iOS 17+
- macOS 14+
- watchOS 10+
- tvOS 17+
- visionOS 1+
Model Declaration
@PersistentModel
CDE adopts an approach similar to SwiftData for declaring the source code representation layer of models.
A typical model declaration looks like this:
import CoreDataEvolution
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
var title: String = ""
var timestamp: Date?
var height: Double?
}
@PersistentModel automatically adds persistence access logic for these properties. For example, height would roughly expand to something like this after macro expansion:
{
get {
guard let number = value(forKey: "height") as? NSNumber else {
return nil
}
return number.doubleValue
}
set {
if let newValue {
setValue(NSNumber(value: newValue), forKey: "height")
} else {
setValue(nil, forKey: "height")
}
}
}
Its core value isn’t about “saving you a few lines of code,” but rather:
- Making
NSManagedObjectdeclarations more aligned with today’s Swift semantics - Pulling repetitive bridging boilerplate back into the macro generation layer
- Turning model source code into a more readable and tool-friendly representation layer
Storage Method
Beyond basic types, CDE also provides enhanced support for complex types, including:
RawRepresentableCodableValueTransformercomposition
Compared to SwiftData, I prefer that developers can clearly see how a value is persisted, rather than relying on some relatively opaque storage inference. That’s why CDE uses @Attribute(storageMethod:) to make the storage strategy explicitly visible in source code.
enum Status: String {
case draft
case published
}
@Attribute(storageMethod: .raw)
var status: Status? = .draft
struct ItemConfig: Codable, Equatable {
var retryCount: Int = 0
}
@Attribute(storageMethod: .codable)
var config: ItemConfig? = nil
@Attribute(storageMethod: .transformed(name: "CDEStringListTransformer"))
var keywords: [String]? = nil
For composite attributes supported starting from iOS 17, CDE also provides explicit support:
@Composition
struct GeoPoint {
var latitude: Double = 0
var longitude: Double = 0
}
@Attribute(storageMethod: .composition)
var location: GeoPoint? = nil
The value of this part lies in making the relationship between “richer type expressions at the Swift layer” and “how Core Data actually persists the data” clear and explicit, rather than scattered across a pile of manual bridging code.
For detailed information on Storage Method capabilities, see the Storage Method Guide.
Relationships
CDE requires developers to explicitly specify in model declarations:
- inverse
- delete rule
- optional relationship persistent name
A typical declaration looks like this:
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
@Relationship(inverse: "items", deleteRule: .nullify)
var tag: Tag?
@Relationship(inverse: "owner", deleteRule: .nullify)
var tags: Set<Tag>
@Relationship(inverse: "orderedOwner", deleteRule: .nullify)
var orderedTags: [Tag]
}
A few things to note here:
- To-one relationships must explicitly use Optional
- To-many relationships use
Set<T>or[T], notSet<T>?/[T]?(even though the model layer requires them to be Optional) - To-many relationships do not allow declaring default values
inverserefers to the persistent relationship name in the Core Data model, not the Swift property name on the other end
For to-many relationships, CDE doesn’t generate a setter. Instead, it generates a set of helper APIs, requiring developers to modify relationships through more explicit operations:
// Unordered to-many
func addToTags(_ value: Tag)
func removeFromTags(_ value: Tag)
func addToTags(_ values: Set<Tag>)
func removeFromTags(_ values: Set<Tag>)
// Ordered to-many
func addToOrderedTags(_ value: Tag)
func removeFromOrderedTags(_ value: Tag)
func addToOrderedTags(_ values: [Tag])
func removeFromOrderedTags(_ values: [Tag])
func insertIntoOrderedTags(_ value: Tag, at index: Int)
This isn’t merely a stylistic constraint—it’s about channeling relationship modifications from “arbitrary assignment” to “more explicit entry points.”
For more details on model declarations, see the PersistentModel Guide.
TypedPath
If @PersistentModel addresses the modernization of the “model declaration layer,” then TypedPath tackles another problem that’s especially real in long-lived projects: underlying model names are hard to change, but the presentation layer naming must keep evolving.
In Core Data projects, once an app ships—especially with cloud sync enabled—developers have almost no room to freely change underlying field names. But business semantics keep evolving, and a property name that seemed reasonable years ago may no longer fit the current project at all.
What CDE offers here is an approach that balances flexibility with type safety.
@Attribute(persistentName: "name")
var title: String = ""
This means:
- The Swift property name is
title - The underlying persistent field name remains
name
When building predicates and sort descriptors, developers can continue using the more natural Swift-layer naming:
let predicate = NSPredicate(
format: "%K == %@",
Item.path.title.raw,
"hello"
)
let sort = try NSSortDescriptor(
Item.self,
path: Item.path.title,
order: .asc
)
TypedPath automatically maps Item.path.title to the underlying name.
You can also use type-safe helper methods directly:
let predicate1 = Item.path.title.contains("Core Data")
let predicate2 = Item.path.score.greaterThan(80)
let predicate3 = Item.path.createdAt.lessThanOrEqual(Date())
Its value isn’t limited to regular attributes—it applies equally to relationships and compositions:
@Relationship(persistentName: "books", inverse: "owner", deleteRule: .nullify)
var items: Set<Item>
Here, items maps to the original to-many relationship books.
@Composition
struct Location {
@CompositionField(persistentName: "lat")
var latitude: Double = 0
@CompositionField(persistentName: "lng")
var longitude: Double? = nil
}
With this, the following expression still works:
let predicate = Item.path.location.latitude.greaterThan(80)
Beyond that, RawRepresentable and to-many relationships also provide corresponding type-safe building methods:
let predicate = Item.path.status.equals(Status.published)
let anySwiftTag = Item.path.tags.any.name.equals("Swift")
let allHighScore = Item.path.tags.all.score.greaterThan(80)
let noLegacyTag = Item.path.tags.none.name.contains("legacy")
From the perspective of the overall project, TypedPath matters because it sits right at the intersection of flexibility and type safety:
- It doesn’t force you to change the underlying model
- It allows Swift-layer naming to keep evolving
- It pushes runtime string errors as far as possible into verifiable source code structures
It’s worth mentioning that TypedPath’s path mapping is entirely based on static metadata generated by macros at compile time. When building predicates, it only performs O(1) table lookups, with no dependency on any runtime reflection, so it incurs no additional overhead on Core Data’s query performance.
For more details on TypedPath, see the Typed Path Guide.
Concurrency
Concurrency was actually the starting point of CDE.
The reason I originally began this project was that I wanted to bring Core Data a concurrency experience closer to SwiftData’s @ModelActor.
In this regard, CDE’s usage is very similar to SwiftData:
@NSModelActor
actor ItemStore {
func createItem(timestamp: Date) throws -> NSManagedObjectID {
let item = Item(context: modelContext)
item.timestamp = timestamp
try modelContext.save()
return item.objectID
}
}
The biggest change this brings isn’t that “the code looks more like SwiftData,” but rather:
- Concurrency boundaries are clearer
- Code is no longer wrapped in layers of
performclosures - Isolation domains are easier to reason about and verify
At the same time, CDE also provides some capabilities on top of this that are more tailored to real-world projects.
For example, when you set disableGenerateInit to true, the macro won’t automatically generate an initializer, allowing developers to add their own stored properties and context configuration logic within the actor:
@NSModelActor(disableGenerateInit: true)
actor ItemStore {
let viewName: String
init(container: NSPersistentContainer, viewName: String) {
modelContainer = container
self.viewName = viewName
let context = container.newBackgroundContext()
context.name = viewName
modelExecutor = .init(context: context)
}
}
Meanwhile, @NSMainModelActor always uses the container’s viewContext, making it easy to build main-thread data logic with the same mental model as @NSModelActor:
@MainActor
@NSMainModelActor
final class ItemViewModel {
func fetchItems() throws -> [Item] {
// ...
}
var itemsCount: Int {
(try? fetchItems().count) ?? 0
}
}
For more on concurrency, see the NSModelActor Guide. To understand the implementation principles behind
NSModelActor, read Achieving Elegant Concurrency Operations like SwiftData.
Testing
When writing tests for Core Data projects, I’ve repeatedly run into several problems:
- It’s hard to build a predictable data environment for each test unit
- Once data operations are encapsulated within an actor, assertions become awkward
- In non-Xcode environments like VSCode, Cursor, or AI Agents, how to minimize test dependencies on external model files
CDE’s testing efforts are essentially built around these pain points.
Building the Data Environment
NSPersistentContainer.makeTest by default creates an independent SQLite store for each test in a given directory, based on the current file (#fileID) and the current method name (#function).
@Test
func itemFetchTest() async throws {
let container = try NSPersistentContainer.makeTest(model: dataModel)
let dataHandler = DataHandler(container: container)
try await dataHandler.getItems()
}
Compared to a /dev/null-style in-memory store, this approach is better suited to Swift Testing’s default concurrent execution model and is closer to a real SQLite environment.
Additionally, if a single test method needs to create multiple containers, you can pass in different testName values explicitly to avoid conflicts.
Assertion Helpers
After adopting @NSModelActor, all data operations are isolated, and assertions in tests often become cumbersome.
To address this, CDE automatically provides a withContext method on actors, allowing you to inspect and assert against the context within the isolation domain:
@Test("withContext - fetch items after creation")
func fetchItemsAfterCreation() async throws {
let stack = try TestStack()
let handler = DataHandler(container: stack.container, viewName: "withContext-fetch")
_ = try await handler.createNemItem(Date())
_ = try await handler.createNemItem(Date())
let count = try await handler.withContext { context in
let request = Item.fetchRequest()
return try context.fetch(request).count
}
#expect(count == 2)
}
This makes “performing real operations through the actor, then asserting within the isolation domain” a very natural testing pattern.
Runtime Schema
While CDE still relies solely on external model files in production, to facilitate testing and development in non-Xcode environments, @PersistentModel also generates a runtime schema.
You can build a runtime model and test container like this:
let model = try NSManagedObjectModel.makeRuntimeModel(Item.self, Tag.self)
let container = try NSPersistentContainer.makeRuntimeTest(
modelTypes: Item.self, Tag.self
)
This path is explicitly aimed at test/debug scenarios, so it doesn’t attempt to fully cover every Core Data feature present in the external model file, such as indexes or certain storage details.
This isn’t a capability gap—it’s an intentional boundary. Its goal is to make testing and debugging more convenient, not to replace the production model system.
cde-tool
Xcode’s model editor can already generate a set of representation layer code. CDE’s cde-tool attempts something similar in the macro era, except the goal isn’t just simple code generation, but:
- Generation
- Validation
- Alignment
cde-tool is primarily responsible for:
- Generating configuration files based on model files
- Generating
@PersistentModel-style source code based on model files and configuration files - Validating whether manually created or modified source declarations still match the model file
In other words, it’s not the source of CDE’s core capabilities, but rather a companion tool that helps you maintain consistency at the engineering level over the long term.
You can absolutely choose not to use it and declare models by hand while using CDE’s macro capabilities directly. But if your project grows larger, models multiply, or you want to keep the relationship between “model – source code – generation layer” in a stable state over the long term, this tool will prove helpful.
For more information, see the cde-tool Guide.
Other Pieces of the Core Data Modernization Puzzle
That said, CDE doesn’t aim to become an “all-in-one” Core Data framework. It primarily addresses modernization in source code expression, concurrency isolation, and engineering workflows. But in the era of cloud sync, Core Data projects often encounter two other very real needs: how to handle continuously changing transaction histories, and how to monitor iCloud / CloudKit’s operational status.
To that end, I’ve also built two complementary tools: PersistentHistoryTrackingKit and iCloudSyncStatusKit.
PersistentHistoryTrackingKit is centered around Persistent History Tracking. It not only handles transaction merging and cleanup, but in version 2.0, it also introduces a hook mechanism that turns “history changes” into a unified entry point that can be integrated into business logic, enabling developers to organize data operations more consistently across sync, multi-target coordination, and background processing scenarios.
iCloudSyncStatusKit focuses on another often-overlooked problem: after integrating CloudKit, developers often need to know the current state of the iCloud account, network conditions, sync events, and overall availability. Only when this information becomes observable and expressible does cloud sync capability become truly maintainable from an engineering perspective.
If CDE answers the question “how to write Core Data more modernly,” then PersistentHistoryTrackingKit and iCloudSyncStatusKit fill in the answer to “how to run Core Data more reliably in the cloud sync era.” They don’t replace each other—rather, they form a more complete modern Core Data engineering stack from different layers.
What CDE Has Taught Me
Core Data Evolution started out as nothing more than an attempt to solve problems I kept encountering while working with Core Data.
But through the process of thinking about, designing, and implementing it, I’ve come to feel something more and more strongly: The “modern features” that Swift has introduced over the past few years aren’t just meant for new frameworks.
Macros, modern concurrency models, toolchain capabilities, combined with AI’s assistance on testing and boilerplate code—these are already enough to give many old frameworks and old implementations a usage pattern that fits today’s development landscape.
CDE is exactly this kind of experiment. For me, it has proven one thing: the problems with Core Data don’t necessarily have to be solved by “leaving Core Data”—sometimes, redesigning its expression layer, isolation layer, and workflows is already enough.
Modernization doesn’t necessarily mean starting from scratch. Helping a mature framework find a new way to express itself in a new programming environment is equally a path worth taking.