@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 disabledinit(observationDomain: CDEObservationDomain)with Swift compiler 6.2+ on iOS 17+ / macOS 14+ platform families, unless disabledNSModelActorconformance
The generated initializer always uses:
let context = container.newBackgroundContext()That is an intentional behavior contract in this package.
The Observation-aware initializer is available when CDE's MainActor Observation runtime is available (Swift compiler 6.2+ plus the platform availability below):
@MainActor
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *)
init(observationDomain: CDEObservationDomain)It creates a background context from the domain's container and registers that context with the domain:
let container = observationDomain.modelContainer
let context = container.newBackgroundContext()
observationDomain.registerChangeProducer(context: context)
modelExecutor = NSModelObjectContextExecutor(context: context)
modelContainer = containerUse this initializer when the actor owns background writes and you want saves from that actor context
to produce property-level Observation metadata. The generated actor keeps the domain and producer
registration alive for its own context. Prefer the generated saveObservedChanges() wrapper for
observed update paths; successful direct modelContext.save() calls from the same registered context
can also participate in precise routing, but saveObservedChanges() is the documented actor-facing
Observation save API. Inline construction is valid:
let writer = ItemStore(observationDomain: CDEObservationDomain(container: container))Releasing the actor releases its retained domain/registration, and that teardown is safe even if the actor's final release happens off the MainActor. If the retained domain is explicitly invalidated while the actor lives, the actor remains a valid Core Data actor; its context is no longer a registered Observation producer, so normal unregistered-context fallback rules apply.
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.
For actors created with init(observationDomain:), the generated no-argument
saveObservedChanges() saves the actor context through the retained Observation setup:
try await saveObservedChanges()This method is the actor-facing API for property-level Observation invalidation of updated objects. In a bound actor, the retained producer registration snapshots updated fields during save and the domain routes only the affected observable key paths after a successful save. If save throws, CDE clears its staged Observation metadata and rethrows without rolling back the actor context. Insert and delete operations do not need property-level metadata; use normal Core Data saves for those paths.
When the actor is not bound to an observation domain, this method falls back to a plain
modelContext.save() and logs a one-time runtime warning that no CDE Observation metadata will be
produced.
This no-argument overload is generated with the initializer set. When using
@NSModelActor(disableGenerateInit: true), define your own save wrapper if you need a no-argument
domain-bound Observation save API.
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
When a custom @NSModelActor initializer should keep the same Observation producer behavior as the
generated overload, retain the domain and producer registration alongside the context:
@NSModelActor(disableGenerateInit: true)
actor ItemStore {
private let observationDomain: CDEObservationDomain?
private let observationProducerRegistration: CDEObservationProducerRegistration?
init(observationDomain: CDEObservationDomain) {
let container = observationDomain.modelContainer
let context = container.newBackgroundContext()
self.observationDomain = observationDomain
observationProducerRegistration = observationDomain.registerChangeProducer(context: context)
modelExecutor = NSModelObjectContextExecutor(context: context)
modelContainer = container
}
deinit {
observationProducerRegistration?.invalidate()
}
}For custom actors, keep your own save wrapper if you need a no-argument domain-bound Observation save
API. The existing saveObservedChanges(in:) remains useful for plain actor contexts that do not
retain a producer registration.
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