@NSModelActor and @NSMainModelActor bring SwiftData-style isolation patterns to Core Data
without requiring SwiftData itself.
This guide is written for library users. It explains:
- when to use each macro
- what code the macros generate
- how to structure your actor or main-actor type
- how to use the convenience APIs
- how to test these types safely
- which constraints are intentional in the current implementation
CoreDataEvolution re-exports CoreData, so normal use sites usually only need:
import CoreDataEvolutionYou do not normally need a separate import CoreData.
Use @NSModelActor when the type should own a private Core Data context and serialize its work
through an actor.
import CoreDataEvolution
@NSModelActor
actor ItemStore {
func createItem(timestamp: Date) throws -> NSManagedObjectID {
let item = Item(context: modelContext)
item.timestamp = timestamp
try modelContext.save()
return item.objectID
}
}Use @NSMainModelActor when the type should always operate on viewContext from the main actor.
import CoreDataEvolution
@MainActor
@NSMainModelActor
final class ItemViewModel {
func createItem(timestamp: Date) throws {
let item = Item(context: modelContext)
item.timestamp = timestamp
try modelContext.save()
}
}Rule of thumb:
@NSModelActor: background work, isolated writes, actor-based APIs@NSMainModelActor: UI-facing orchestration that must stay on the main actor
For an actor declaration:
@NSModelActor
actor ItemStore {}the macro adds:
nonisolated let modelExecutor: NSModelObjectContextExecutornonisolated let modelContainer: NSPersistentContainerinit(container: NSPersistentContainer)unless disabledNSModelActorconformance
The generated initializer always uses:
let context = container.newBackgroundContext()That is an intentional behavior contract in this package.
For a class declaration:
@MainActor
@NSMainModelActor
final class ItemViewModel {}the macro adds:
let modelContainer: NSPersistentContainerinit(modelContainer: NSPersistentContainer)unless disabledNSMainModelActorconformance
modelContext is not stored directly. The protocol extension always resolves it as:
modelContainer.viewContextBoth protocols expose a small convenience surface.
The context that should be used by your methods.
NSModelActor: the context wrapped bymodelExecutorNSMainModelActor:viewContext
Load an object by NSManagedObjectID and expected type:
guard let item = self[itemID, as: Item.self] else {
throw StoreError.itemNotFound
}This is useful when the caller only has an object ID and the actor should rehydrate the object inside its own isolation domain.
Two overloads are available on both protocols:
try await handler.withContext { context in
// inspect or query the actor's context
}
try await handler.withContext { context, container in
// inspect the context and also access the container
}These APIs are primarily for:
- tests
- debugging
- verification queries that do not deserve a dedicated production API
They are synchronous closures executed inside the type's existing isolation boundary. They do not create a new scheduling layer.
For production writes, prefer dedicated mutation methods on the actor or class instead of exposing raw context access everywhere.
If you need extra stored properties or a custom context setup, disable initializer generation:
@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)
}
}For @NSMainModelActor:
@MainActor
@NSMainModelActor(disableGenerateInit: true)
final class ItemViewModel {
let screenName: String
init(modelContainer: NSPersistentContainer, screenName: String) {
self.modelContainer = modelContainer
self.screenName = screenName
}
}When you disable the generated initializer, you are responsible for assigning every generated stored property correctly.
For @NSModelActor, that means:
modelContainermodelExecutor
For @NSMainModelActor, that means:
modelContainer
For schema-backed tests, prefer:
let container = try NSPersistentContainer.makeTest(model: MySchema.objectModel)This helper intentionally:
- uses an on-disk SQLite store and clears stale files before loading
- deletes stale sidecar files before loading
- serializes container creation and
loadPersistentStores
Treat this helper as a one-shot test container by default:
- the default name comes from the call site (
#fileID+#function) - that is usually the right choice for one container per test method
- if one test method needs multiple containers, pass distinct
testNamevalues
This SQLite-backed approach is intentional:
- it avoids the shared-state and deadlock risks of
/dev/null - it exercises a more realistic SQLite + WAL setup than shared in-memory stores
- in heavily parallel suites, it is often more robust than shared in-memory approaches
Do not switch back to /dev/null or a shared in-memory URL.
In tests, the recommended pattern is:
- call the actor's public API
- verify state with
withContext
Example:
let stack = try TestStack()
let handler = DataHandler(container: stack.container, viewName: "test")
_ = try await handler.createItem(timestamp: .now)
let count = try await handler.withContext { context in
let request = Item.fetchRequest()
return try context.fetch(request).count
}
#expect(count == 1)This keeps the mutation path realistic while still allowing direct assertions.
If you are testing macro-generated runtime schema instead of .xcdatamodeld, use:
let container = try NSPersistentContainer.makeRuntimeTest(modelTypes: Item.self, Tag.self)That path is intended for test and debug workflows only. It is not a replacement for production Core Data model versioning.
The macros mirror the attached type's visibility for generated members.
One special case exists:
- if the attached type is
privateorfileprivate - generated witness members use
fileprivate
That is required so the synthesized conformance extension can still see the witnesses.
- attach it to an
actor - if you disable init generation, assign
modelContainerandmodelExecutoryourself - the generated default initializer always uses
newBackgroundContext()
- attach it to a
class - mark the type
@MainActor - if you disable init generation, assign
modelContaineryourself - the type always uses
viewContext
@MainActor remains a source-level requirement. The macro does not silently rewrite the attached
type's isolation attributes for you.
Bad:
@NSMainModelActor
final class ItemViewModel {}Good:
@MainActor
@NSMainModelActor
final class ItemViewModel {}The macro does not currently enforce @MainActor itself. This is still a source-level rule you
should follow rather than something the macro silently rewrites on your behalf.
Bad:
@NSModelActor(disableGenerateInit: true)
actor ItemStore {
init(container: NSPersistentContainer) {}
}Good:
@NSModelActor(disableGenerateInit: true)
actor ItemStore {
init(container: NSPersistentContainer) {
modelContainer = container
modelExecutor = .init(context: container.newBackgroundContext())
}
}withContext is intentionally low-level. Use it for tests and debugging, not as a replacement for
clear domain methods.
Prefer:
try await store.updateTimestamp(id: itemID, to: .now)over exposing every operation through raw context closures.
For background actors:
- keep public methods small and task-oriented
- load objects inside the actor by object ID
- save explicitly after mutations
- use
withContextonly for assertions or debugging
For main-actor handlers:
- keep UI coordination on the main actor
- reserve heavy write flows for background actors when appropriate
- use the same
NSPersistentContainerwhen the UI and background actors need to cooperate
These are intentional in the current design:
@NSModelActorusesnewBackgroundContext()by default@NSMainModelActorusesviewContextwithContextis synchronous within the current isolation domain- the package does not try to hide raw Core Data save semantics
- test helpers prioritize store isolation and parallel-suite stability over in-memory convenience
This guide is the detailed reference for the actor macros and testing helpers.
The README can stay shorter and focus on:
- what the library does
- why the actor macros exist
- a minimal usage example
- links to this guide for the full workflow