Welcome to CoreDataEvolution, a library aimed at modernizing Core Data by incorporating the elegance and safety of SwiftData-style concurrency. This library is designed to simplify and enhance Core Data’s handling of multithreading, drawing inspiration from SwiftData's @ModelActor feature, enabling efficient, safe, and scalable operations.
Don't miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to Fatbobman's Swift Weekly and receive weekly insights and valuable content directly to your inbox.
SwiftData introduced modern concurrency features like @ModelActor, making it easier to handle concurrent data access with safety guaranteed by the compiler. However, SwiftData's platform requirements and limited maturity in certain areas have deterred many developers from adopting it. CoreDataEvolution bridges the gap, bringing SwiftData’s advanced design into the Core Data world for developers who are still reliant on Core Data.
- Core Data Reform: Achieving Elegant Concurrency Operations like SwiftData
- Practical SwiftData: Building SwiftUI Applications with Modern Approaches
- Concurrent Programming in SwiftData
-
Custom Executors for Core Data Actors
CoreDataEvolution provides custom executors that ensure all operations on managed objects are performed on the appropriate thread associated with their managed object context. It uses aUnownedJob-based serial executor path compatible with the minimum supported OS versions. -
@NSModelActor Macro
The@NSModelActormacro simplifies Core Data concurrency, mirroring SwiftData’s@ModelActormacro. It generates the necessary boilerplate code to manage a Core Data stack within an actor, ensuring safe and efficient access to managed objects. -
NSMainModelActor Macro
NSMainModelActoris the main-thread companion macro for classes. It bindsmodelContexttoviewContextand provides the same convenience access APIs (subscript,withContext) throughNSMainModelActorprotocol extensions. -
Elegant Actor-based Concurrency
CoreDataEvolution allows you to create actors with custom executors tied to Core Data contexts, ensuring that all operations within the actor are executed serially on the context’s thread.
Here’s how you can use CoreDataEvolution to manage concurrent Core Data operations with an actor:
import CoreDataEvolution
@NSModelActor
actor DataHandler {
func updateItem(identifier: NSManagedObjectID, timestamp: Date) throws {
guard let item = self[identifier, as: Item.self] else {
throw MyError.objectNotExist
}
item.timestamp = timestamp
try modelContext.save()
}
}In this example, the @NSModelActor macro simplifies the setup, automatically creating the required executor and Core Data stack inside the actor. Developers can then focus on their business logic without worrying about concurrency pitfalls.
This approach allows you to safely integrate modern Swift concurrency mechanisms into your existing Core Data stack, enhancing performance and code clarity.
You can disable the automatic generation of the constructor by using disableGenerateInit:
@NSModelActor(disableGenerateInit: true)
public actor DataHandler {
let viewName: String
func createNemItem(_ timestamp: Date = .now, showThread: Bool = false) throws -> NSManagedObjectID {
let item = Item(context: modelContext)
item.timestamp = timestamp
try modelContext.save()
return item.objectID
}
init(container: NSPersistentContainer, viewName: String) {
modelContainer = container
self.viewName = viewName
let context = container.newBackgroundContext()
context.name = viewName
modelExecutor = .init(context: context)
}
}NSMainModelActor is the main-thread companion macro for classes:
@MainActor
@NSMainModelActor
final class DataHandler {
func updateItem(identifier: NSManagedObjectID, timestamp: Date) throws {
guard let item = self[identifier, as: Item.self] else {
throw MyError.objectNotExist
}
item.timestamp = timestamp
try modelContext.save()
}
}Types decorated with @NSModelActor and @NSMainModelActor gain these APIs through their corresponding protocol extensions (NSModelActor and NSMainModelActor):
| Property | Description |
|---|---|
modelContext: NSManagedObjectContext |
The managed object context associated with this actor. All Core Data operations should go through this context. |
modelContainer: NSPersistentContainer |
The persistent container that owns this actor's context. |
Retrieve a managed object by its NSManagedObjectID, cast to the expected type. Returns nil if the object does not exist or the cast fails.
// Inside an actor method
guard let item = self[objectID, as: Item.self] else {
throw MyError.objectNotFound
}
item.timestamp = .now
try modelContext.save()Provides direct, synchronous access to the actor's context from within the actor's isolation. The closure runs synchronously with no additional scheduling overhead.
This method is primarily intended for unit tests — use it to inspect the persistent store state after a write operation, without going through the actor's higher-level API.
// Verify state after a write — single-context overload
try await handler.withContext { context in
let request = Item.fetchRequest()
let items = try context.fetch(request)
#expect(items.count == 1)
}An overload also provides the NSPersistentContainer when needed:
withContext { context, container in ... }.
Note: For production writes, prefer the actor's dedicated mutation methods so that save/rollback logic remains consistent.
Creates an isolated, on-disk SQLite store for each test, avoiding the two most common pitfalls:
/dev/null(shared in-memory): all tests sharing the same URL read from and write to the same store — parallel execution causes data leakage and deadlocks.- Named in-memory stores: WAL sidecar files (
.sqlite-shm,.sqlite-wal) can linger between runs, producing phantom data.
makeTest solves this by using #fileID-#function as the default testName, so each test call site automatically gets its own store file. Stale files from the previous run are deleted before the store loads.
@Test func createItem() async throws {
// testName defaults to #fileID-#function — each test call site gets its own store
let container = NSPersistentContainer.makeTest(model: MySchema.objectModel)
let handler = DataHandler(container: container)
// … test body …
}Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
model |
NSManagedObjectModel |
— | The managed object model for your schema. |
testName |
String |
"" |
Optional explicit store name. When empty, the name is derived from call-site #fileID-#function. |
fileID |
String |
#fileID |
Call-site file identity used when testName is empty. |
function |
String |
#function |
Call-site function identity used when testName is empty. |
subDirectory |
String |
"CoreDataEvolutionTestTemp" |
Temp sub-directory that holds the SQLite files. |
Note: Store files are not deleted immediately after a test completes — they are cleaned up at the start of the next run with the same
testName, so you can inspect them for debugging if needed.
You can add CoreDataEvolution to your project using Swift Package Manager by adding the following dependency to your Package.swift file:
dependencies: [
.package(url: "https://github.com/fatbobman/CoreDataEvolution.git", .upToNextMajor(from: "0.7.3"))
]Then, import the module into your Swift files:
import CoreDataEvolution- iOS 13.0+ / macOS 10.15+ / watchOS 6.0+ / visionOS 1.0+ / tvOS 13.0+
- Swift 6.0
Note: The custom executor uses a compatible UnownedJob serial-executor path to support the minimum deployment targets.
We welcome contributions! Whether you want to report issues, propose new features, or contribute to the code, feel free to open issues or pull requests on the GitHub repository.
CoreDataEvolution is available under the MIT license. See the LICENSE file for more information.
Special thanks to the Swift community for their continuous support and contributions. Thanks to @rnine for sharing and validating the iOS 13+ compatibility approach that inspired this adaptation.