@PersistentModel brings a Swift-first declaration style to Core Data models while keeping the
runtime on top of NSManagedObject.
This guide is written for library users. It focuses on:
- the supported declaration style
- generated APIs and metadata
- required modeling rules
- common mistakes and the diagnostics you should expect
The current design is intentionally strict. The goal is predictable code generation, stable macro expansion, and tooling-friendly validation.
CoreDataEvolution re-exports CoreData, so normal model source files usually only need:
import CoreDataEvolutionYou do not normally need a separate import CoreData.
@PersistentModel is a source-layer representation of a Core Data model.
For production use, you should still have a real .xcdatamodeld or .xcdatamodel.
That means:
- you still model entities and attributes in Xcode
- you still control migrations at the Core Data model layer
@PersistentModelgives you a better Swift-facing declaration of that modelcde-toolhelps keep the source layer and the model layer aligned
If you read this guide with a SwiftData mindset, the easiest mental model is:
- SwiftData
@Modeltries to be the model - CoreDataEvolution
@PersistentModelis the Swift source representation of the Core Data model you already have
A @PersistentModel type is still an NSManagedObject subclass. You declare stored properties in
Swift, and the macros generate the boilerplate needed for:
- Core Data key-value accessors
- typed
Keysandpath - field metadata for sort and predicate building
- runtime schema metadata for test/debug-only model construction
- optional relationship helper methods
The macro system works together with these supporting macros:
@Attribute@Ignore@Composition@CompositionField@Relationship
import CoreDataEvolution
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
var title: String = ""
var timestamp: Date? = nil
}Requirements shown in this example:
- The type must be a
class. - The type must inherit from
NSManagedObject. - The type must declare
@objc(EntityName)explicitly. - Every persisted property must be optional, or provide a default value.
For a valid model type, the macro generates:
KeysPathsPathRootpath__cdFieldTable__cdRelationshipProjectionTable- internal relationship-target validation helpers
__cdRuntimeEntitySchema- conditional
fetchRequest()when the type does not already declare one - optional convenience
init(...)whengenerateInit: true - to-many relationship getters
- unordered to-many helpers: single-value and batch
addTo*/removeFrom* - ordered to-many helpers: single-value and batch
addTo*/removeFrom*, plusinsertInto*(_:at:) - no generated to-many setters
PersistentEntityconformanceCDRuntimeSchemaProvidingconformance
You should treat these generated members as implementation details. Write your model declarations in source, and let the macro own the generated layer.
Plain stored properties default to persisted attributes:
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
var title: String = ""
var notes: String? = nil
var createdAt: Date = .distantPast
}Use @Attribute when you need more control.
@Attribute(persistentName: "name")
var title: String = ""This means:
- Swift property name:
title - Core Data persistent field name:
name
This is intentionally different from SwiftData's originalName.
- In SwiftData,
originalNameis a migration hint. It means "this property used to be named something else in an older schema version." - In CoreDataEvolution,
persistentNameis a storage mapping. It means "this Swift property is backed by this Core Data field name right now."
Example:
// SwiftData-style migration meaning
@Attribute(originalName: "name")
var title: String
// CoreDataEvolution storage mapping meaning
@Attribute(persistentName: "name")
var title: String = ""This renamed mapping also feeds the library's typed path system automatically. You still write Swift-facing property names in code, while sort and predicate construction resolve them to the correct Core Data field name internally.
Example:
let sort = try NSSortDescriptor(
Item.self,
path: Item.path.title,
order: .asc
)
let predicate = NSPredicate(
format: "%K == %@",
Item.path.title.raw,
"hello"
)With @Attribute(persistentName: "name") var title: String = "", the generated path still uses
title in Swift code, but maps it to the persistent field name when building %K-based
predicates or sort descriptors.
For a dedicated guide to this mapping layer, including the motivation, NSSortDescriptor,
NSPredicate, and to-many quantifier examples, see TypedPathGuide.md.
@Attribute(.unique)
var slug: String = "".unique is a simple trait. It represents a single-field uniqueness constraint.
@Attribute(.transient)
var cachedSummary: String = ""transient means:
- the property belongs to the Core Data model
- the value is not persisted to the store
- it is different from
@Ignore
Current restrictions for transient:
- only supported with
.defaultstorage - cannot be combined with
.unique - cannot be combined with
.raw,.codable,.transformed, or.composition
enum Status: String {
case draft
case published
}
@Attribute(storageMethod: .raw)
var status: Status? = .draftUse .raw for enums or other RawRepresentable types.
struct ItemConfig: Codable, Equatable {
var retryCount: Int = 0
}
@Attribute(storageMethod: .codable)
var config: ItemConfig? = nil@Attribute(storageMethod: .transformed(name: "CDEStringListTransformer"))
var keywords: [String]? = nilFor schema-backed models, .transformed(...) means the Core Data field type should match the
transformer's stored output type.
Example:
- if the transformer stores
NSString, the model field should beString - if the transformer stores
NSData, the model field should beBinary Data - only model the field as
Transformablewhen the schema intentionally uses a transformable payload path such asNSSecureUnarchiveFromData
Transformer-backed attributes currently support two source forms:
.transformed(CDEStringListTransformer.self).transformed(name: "CDEStringListTransformer")
For schema-backed models, the name: form is the canonical shape because Core Data stores the
transformer registration name in the model itself. In both forms, the transformer must be
registered before the property is first accessed, for example during app launch or test bootstrap.
Decode failure policies only make sense for storage methods that actually decode values.
@Attribute(
storageMethod: .codable,
decodeFailurePolicy: .fallbackToDefaultValue
)
var config: ItemConfig? = nilSupported policies:
.fallbackToDefaultValue.debugAssertNil
For .codable and .transformed, .fallbackToDefaultValue now effectively means
"fallback to nil". These storage methods currently require optional declarations and do not
support non-nil source defaults, so there is no separate model-backed value to restore.
decodeFailurePolicy does not apply to .composition. Composition properties are currently
optional-only, but that rule is enforced through the storage method itself rather than through
decode-failure configuration.
For a dedicated guide to storage choices, tradeoffs, and current limits of .default, .raw,
.codable, .transformed, and .composition, see
StorageMethodGuide.md.
Relationship shape is still inferred from the Swift property type:
Tag?-> to-one relationshipSet<Tag>-> unordered to-many relationship[Tag]-> ordered to-many relationship
That only answers the cardinality question.
Every relationship still needs explicit relationship metadata in source:
inversedeleteRulepersistentNamewhen the Swift property name differs from the Core Data relationship name
Important:
inversealways points to the relationship name stored in the Core Data model on the other side- it does not point to the other Swift property name
So a real relationship 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]
}There is no separate public inverse-only relationship macro.
To-many relationship accessors return ordinary Swift collections:
Set<Target>bridges from the underlyingNSSet[Target]bridges from the underlyingNSOrderedSet
That bridge constructs a new Swift collection on every read. This is the intended convenience API, but it also means repeated access to a large to-many relationship is not free. For hot paths, prefer a fetch request or another query-oriented API instead of repeatedly reading the relationship property.
To-many relationships intentionally generate getters only.
T?generates a getter and setterSet<T>generates a getter, but no setter[T]generates a getter, but no setter
Mutate to-many relationships through the generated helper methods instead. Helper names derive from
the Swift property name, not persistentName.
// Unordered to-many: var tags: Set<Tag>
func addToTags(_ value: Tag)
func removeFromTags(_ value: Tag)
func addToTags(_ values: Set<Tag>)
func removeFromTags(_ values: Set<Tag>)
// Ordered to-many: var orderedTags: [Tag]
func addToOrderedTags(_ value: Tag)
func removeFromOrderedTags(_ value: Tag)
func addToOrderedTags(_ values: [Tag])
func removeFromOrderedTags(_ values: [Tag])
func insertIntoOrderedTags(_ value: Tag, at index: Int)Valid:
var category: Category?Invalid:
var category: CategoryWhy:
- the current model rules require relationship declarations to follow a predictable optional/default model
- non-optional to-one relationships are rejected at macro validation time
Valid:
var tags: Set<Tag>
var orderedTags: [Tag]Invalid:
var tags: Set<Tag>?
var orderedTags: [Tag]?Why:
- Core Data models for this workflow still represent to-many relationships as optional internally
- the macro maps that model-level optional to an empty Swift collection on read
- Swift declarations must therefore use the collection type directly
- use
Set<T>or[T], notSet<T>?or[T]?
Core Data models using this library must define inverse relationships.
If the inverse is missing, tooling and validation reject the model.
Relationship cardinality is still inferred from the Swift property type, but relationship metadata is explicit in source.
Example:
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
@Relationship(persistentName: "owner", inverse: "books", deleteRule: .nullify)
var tag: Tag?
}
@objc(Tag)
@PersistentModel
final class Tag: NSManagedObject {
@Relationship(persistentName: "books", inverse: "owner", deleteRule: .nullify)
var items: Set<Item>
}@Relationship(...) always carries:
persistentNamewhen the Swift property name differs from the Core Data relationship nameinversedeleteRule
It can also carry optional count bounds when the Core Data model declares them:
minimumModelCountmaximumModelCount
Example:
@Relationship(
inverse: "documents",
deleteRule: .deny,
minimumModelCount: 1,
maximumModelCount: 3
)
var owner: Owner?You do not need to write these count arguments when the model uses Core Data's default bounds for that relationship shape:
- optional to-one: minimum
0, maximum1 - non-optional to-one: minimum
1, maximum1 - to-many: minimum
0, with no upper bound unless the model explicitly constrains it. In Core Data,maxCount == 0means "unbounded" for to-many relationships.
Write them only when the model declares non-default minimum or maximum counts.
There is no source-level inverse inference in the current model DSL.
When a relationship uses persistentName, the inverse argument still points to the relationship
name stored in the Core Data model on the other side.
It does not point to the other Swift property name.
Example:
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
@Relationship(persistentName: "owner", inverse: "books", deleteRule: .nullify)
var tag: Tag?
}
@objc(Tag)
@PersistentModel
final class Tag: NSManagedObject {
@Relationship(persistentName: "books", inverse: "owner", deleteRule: .nullify)
var items: Set<Item>
}Currently supported delete rules:
.nullify.cascade.deny
.noAction is intentionally unsupported.
Example:
@objc(Document)
@PersistentModel
final class Document: NSManagedObject {
@Relationship(inverse: "authoredDocuments", deleteRule: .nullify)
var author: User?
@Relationship(inverse: "editedDocuments", deleteRule: .nullify)
var editor: User?
}
@objc(User)
@PersistentModel
final class User: NSManagedObject {
@Relationship(inverse: "author", deleteRule: .nullify)
var authoredDocuments: Set<Document>
@Relationship(inverse: "editor", deleteRule: .nullify)
var editedDocuments: Set<Document>
}This works the same way for self-referencing models.
@objc(Category)
@PersistentModel
final class Category: NSManagedObject {
@Relationship(inverse: "children", deleteRule: .nullify)
var parent: Category?
@Relationship(inverse: "parent", deleteRule: .nullify)
var children: Set<Category>
}Use @Composition for value-like grouped data that should still participate in typed paths and
model metadata.
In CoreDataEvolution, the source-level term is composition. It corresponds to Core Data's
composite attribute feature at the model layer.
This feature requires the platform support behind Core Data composite attributes:
- iOS 17+
- macOS 14+
- tvOS 17+
- watchOS 10+
- visionOS 1+
@Composition
struct Location {
var x: Double = 0
var y: Double? = nil
}
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
@Attribute(storageMethod: .composition)
var location: Location? = nil
}For a schema-backed Core Data model, this source declaration must match a real composite attribute
in .xcdatamodeld.
In Xcode's model editor, that means:
- the entity must declare a top-level attribute such as
location - that top-level attribute's type must be
Composite - the model must also declare the matching composite type, for example
Location - that composite type must contain the leaf fields, such as
xandy
Do not model schema-backed .composition as a Transformable attribute in Xcode. The runtime
schema/test-only helpers can use a transformable dictionary payload, but a real .xcdatamodeld
workflow should use a real Core Data composite attribute.
Also do not flatten the composition into top-level entity fields such as lat and lng while
keeping the Swift source as var location: Location?. If the entity does not actually contain a
top-level composite attribute named location, assigning location at runtime will fail because
the Core Data model and the Swift source are describing different shapes.
When a composition leaf needs a different Core Data field name, use
@CompositionField(persistentName: ...):
@Composition
struct Location {
@CompositionField(persistentName: "lat")
var latitude: Double = 0
@CompositionField(persistentName: "lng")
var longitude: Double? = nil
}This gives composition leaves the same kind of persistent-name mapping that attributes and relationships already support:
- Swift-facing code still uses
location.latitude - the generated composition field table maps that leaf to the persistent field
lat - typed paths, sort descriptors, and
%Kpredicate interpolation still start from the Swift path and resolve to the persistent path internally
A composition type must be:
- a
struct - non-generic
- made of stored
varproperties only - limited to supported primitive field types and optionals
- non-nested
- free of conversion behavior in the current implementation
Current rules for @CompositionField:
- use it only on stored
varfields inside a@Compositionstruct - it only supports
persistentName - it is the only supported way to rename a composition leaf field
- it does not reuse
@Attribute
@Composition generates metadata used by:
- typed paths
- encode/decode helpers
- runtime schema metadata for test/debug model construction
Use @Ignore for pure Swift properties that should not participate in the Core Data model.
@Ignore
var transientCache: [String: Int] = [:]@Ignore means:
- not persisted
- not part of the generated Core Data schema
- not part of typed path metadata
- still included in generated init when
generateInit: true
Use @Ignore for in-memory state. Use @Attribute(.transient) for Core Data transient
attributes.
The current implementation keeps default-value semantics strict.
A persisted property must be one of:
- optional
- non-optional with an explicit default value
Valid:
var title: String = ""
var notes: String? = nil
var createdAt: Date = .distantPastInvalid:
var title: StringThe declared Swift default value should match the default configured in your Core Data model.
For example, if the model defines title with a default of "", your Swift declaration should
also use:
var title: String = ""Do not treat the Swift default as a way to override the model's default value.
The declared default value is used for these purposes:
- to make the declaration explicit and toolable
- to satisfy the "optional or default" model rule
- as a fallback when custom decoding fails, depending on storage method and decode failure policy
It is not used to rewrite or replace the default value stored in the .xcdatamodeld.
The rule keeps several parts of the system consistent:
- macro-generated accessors
- tooling generate/validate
- runtime schema generation for tests and debugging
Optional properties may explicitly write = nil, but they do not have to.
Both are accepted:
var notes: String?
var notes: String? = nilCustom storage rules are stricter than primitive .default storage.
Current rules:
-
.raw -
.codable -
.transformed -
.composition -
.codable,.transformed, and.compositioncurrently require optional properties -
for those three storage methods, the only supported explicit default is
nil -
.rawremains a separate case because its backing primitive can still align with a model-backed default in some cases
@PersistentModel does not generate an init by default.
@PersistentModel(generateInit: true)When enabled, the generated init:
- includes persisted attributes
- includes persisted attributes declared with
.raw,.codable,.transformed, and.composition - includes
@Ignoreproperties - excludes relationships
- does not inject a Core Data context parameter
- does not assign default parameter values; callers must pass every included argument explicitly
Example:
@objc(Item)
@PersistentModel(generateInit: true)
final class Item: NSManagedObject {
var title: String = ""
@Attribute(storageMethod: .codable)
var config: ItemConfig? = nil
@Ignore
var transientCache: [String: Int] = [:]
@Relationship(inverse: "items", deleteRule: .nullify)
var tags: Set<Tag>
}Generated init shape:
convenience init(
title: String,
config: ItemConfig?,
transientCache: [String: Int]
)@PersistentModel also emits runtime schema metadata.
This supports pure Swift model construction for:
- tests
- debug utilities
- non-Xcode workflows that do not want to depend on
.xcdatamodeld
This runtime schema path is intentionally limited:
- it is test/debug-only
- it is not a replacement for production
.xcdatamodeld - it does not guarantee model hash or migration compatibility
- unsupported runtime primitive types fail generation instead of silently downgrading schema
Example:
let model = try NSManagedObjectModel.makeRuntimeModel(Item.self, Tag.self)
let container = try NSPersistentContainer.makeRuntimeTest(
modelTypes: Item.self, Tag.self
)makeRuntimeModel and makeRuntimeTest intentionally use slightly different call shapes, but both
consume the same runtime schema provider types.
Before using @PersistentModel, make sure all of these are true.
- must be a
class - must inherit from
NSManagedObject - must declare
@objc(EntityName)explicitly - must not declare multiple stored properties in a single
vardeclaration
- every persisted attribute must be optional or have a default value
.uniqueis supported.transientis supported only with.default- derived attributes are not supported
- renamed attributes use
persistentName:
- to-one relationships must be optional
- to-many relationships must use
Set<T>or[T] - to-many relationships must not be declared as optional in Swift
- relationships must have inverses in the Core Data model
- every relationship must declare
@Relationship(persistentName:inverse:deleteRule:) - relationship count bounds are optional source metadata and should only be written when the model declares non-default min/max values
- supported relationship delete rules are
.nullify,.cascade, and.deny .noActionis not supported
- composition type must use
@Composition - composition leaf renames must use
@CompositionField(persistentName: ...) - composition type must be a non-generic
struct - composition fields must be supported primitive fields
- nested composition is not currently supported
Invalid:
var title: String = "", subtitle: String = ""Reason:
@PersistentModeldoes not support multi-binding stored declarations- split them into separate
vardeclarations
Correct:
var title: String = ""
var subtitle: String = ""Invalid:
var category: CategoryCorrect:
var category: Category?Invalid:
var tags: Set<Tag>?Correct:
var tags: Set<Tag>Invalid:
var tag: Tag?Correct:
@Relationship(inverse: "items", deleteRule: .nullify)
var tag: Tag?Invalid:
@Relationship(inverse: "items", deleteRule: .noAction)
var tag: Tag?Correct:
@Relationship(inverse: "items", deleteRule: .nullify)
var tag: Tag?Invalid:
@Attribute(.transient, storageMethod: .raw)
var cachedSummary: String = ""Correct:
@Attribute(.transient)
var cachedSummary: String = ""Derived Attribute is currently out of scope.
If your Core Data model uses derived attributes, the current toolchain rejects that model.
Examples of expected diagnostics:
@PersistentModel can only be attached to a class declaration.@PersistentModel type must inherit from NSManagedObject.@PersistentModel type must declare @objc(ClassName) explicitly.@PersistentModel does not support declaring multiple stored properties in one var declaration.To-one relationship properties must be optional.Optional to-many relationship ... is not supported.Relationship property 'tag' must declare @Relationship(inverse: ..., deleteRule: ...).@Relationship does not support deleteRule: .noAction.@Attribute trait .transient only supports .default storage.Derived Attribute is not supported.
A good style looks like this:
import CoreDataEvolution
@Composition
struct Location {
var x: Double = 0
var y: Double? = nil
}
enum ItemStatus: String {
case draft
case published
}
@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
@Attribute(.unique)
var slug: String = ""
@Attribute(persistentName: "name")
var title: String = ""
@Attribute(storageMethod: .raw)
var status: ItemStatus? = .draft
@Attribute(storageMethod: .composition)
var location: Location? = nil
@Attribute(.transient)
var cachedSummary: String = ""
@Ignore
var uiState: [String: Int] = [:]
@Relationship(inverse: "items", deleteRule: .nullify)
var tag: Tag?
}
@objc(Tag)
@PersistentModel
final class Tag: NSManagedObject {
var name: String = ""
@Relationship(inverse: "tag", deleteRule: .nullify)
var items: Set<Item>
}This style matches the current macro, tooling, and runtime-schema expectations closely.
@PersistentModel is intentionally conservative in the current implementation.
Not supported yet:
- Derived Attribute
- nested composition
- entity inheritance
- production-oriented runtime model replacement for
.xcdatamodeld - optional to-many declarations
- non-optional to-one declarations
When in doubt, prefer the simplest declaration shape that matches the rules in this guide.