Core Data + Observation: From Property-Level Reactivity to a Freer Mental Model

The introduction of the Observation framework has refined SwiftUI’s state reactivity from the object level down to the property level, significantly reducing many unnecessary view recalculations caused by coarse-grained observation. More importantly, it makes the declarative logic of state management feel natural again: a view only needs to read the properties it actually depends on, and the framework can establish the corresponding dependency relationship from there.

Unfortunately, within Apple’s persistence framework ecosystem, this experience is mainly reflected in SwiftData. The large and stable Core Data ecosystem, which still powers many complex applications, has not received the same level of native integration. To bring the convenience of modern Swift features to this long-standing persistence framework, I recently explored and implemented Observation support in Core Data Evolution(CDE), giving NSManagedObject property-level precise observation capabilities.

This article discusses the motivation behind this feature, how to use it, its implementation approach, the engineering challenges involved, and some of the trade-offs made during development.

Observation Changes More Than Performance

When the Observation framework was first introduced, what impressed me most was its improvement to SwiftUI performance: property-level precise reactivity can greatly reduce many unnecessary view computations. But as I continued to use it more deeply, I gradually realized that, compared with performance, the change it brings to the developer’s mental model is even more important.

In the past, when using ObservableObject, the observation boundary was usually at the object level. As long as a view depended on an object, it could easily be affected by changes to unrelated properties on that object. Developers not only had to think about the structure of the data itself, but also had to consider how to split objects, how to split views, and where to place @ObservedObject or @StateObject in order to keep the refresh scope as reasonable as possible.

Observation changes this relationship. Properties of an Observable instance have a kind of “transitive” observability. When a view reads a property inside an Observation tracking environment, what gets recorded is the property access that occurs along the read path, rather than some observation container manually declared by the developer.

Swift
@Observable
class A {
  var a = 10
  var b = B()
}

@Observable
class B {
  var b = 10
}

class Root {
  let a = A()
  
  var b: Int {
    a.b.b
  }
}

In this example, Root.b itself is not an observable property rewritten by Observation. What really matters is that when a view reads root.b, the access to a.b.b during the evaluation of the computed property is recorded by Observation. This means developers can organize state and derived data in a more natural way, without reshaping their data structures into a set of wrapper types created solely to make observation work.

This is one of Observation’s greatest values for SwiftUI: it does not merely make views refresh fewer times; it also makes the state read path closer to the semantics of the business logic itself.

Core Data’s Expressiveness Gap

This point is especially clear in SwiftData. As persistence frameworks centered around object graph management, relationship expression and management are core strengths of frameworks like SwiftData and Core Data.

Suppose we have the following relationship chain:

Text
Note -> Item -> ItemData -> Memo

In a view displaying Memo.content, we also want to use the color of the corresponding Note and the icon of the corresponding Item. With SwiftData, the code can be very direct:

Swift
HStack {
  Image(systemName: memo.itemData.item.symbol)
  Text(memo.content)
    .foregroundStyle(memo.itemData.item.note.color)
}

The point here is not just syntactic simplicity. More importantly, property reads along the relationship chain can still participate in Observation tracking. The framework knows what the view has read, and therefore what it depends on. Developers do not need to manually split out a series of intermediate views just to preserve reactivity.

With Core Data, however, things become much more cumbersome. If you want to use data along a relationship chain in a single view while still preserving real-time responsiveness to changes in that data, you typically need to split each NSManagedObject along the chain into its own struct View and hold it with @ObservedObject. Otherwise, changes in some intermediate objects may not be accurately propagated to the final view.

This not only increases the amount of code, but also makes omissions easy. More importantly, it forces developers to reshape their view structure around the observation mechanism, rather than organizing the interface according to business semantics.

For this reason, I decided to fill this capability gap in CDE: to let NSManagedObject provide property-level observation capabilities similar to SwiftData’s PersistentModel. Most importantly, views can respond directly to changes in the data they read without using @ObservedObject.

Capability Boundaries: Solving Only the Part SwiftUI Needs Most

In A Deep Dive Into Observation, I introduced the basic mechanism of Observation. CDE already has the @PersistentModel model declaration macro, so from a technical perspective, adding Observation capabilities to Core Data models is feasible.

But “feasible” does not mean it should be turned into an all-encompassing Core Data observation system.

Core Data has many sources of change, and the granularity of its notifications is not consistent. A local save in viewContext, a background context merge, a re-merge caused by CloudKit / Persistent History Tracking, a batch update, a refresh, and a rollback can all provide different levels of information. If we forcibly promise property-level reactivity for every source of change, the API semantics would become vague, and developers might mistakenly believe they have a guarantee that does not actually exist.

Therefore, this implementation focuses only on the most important pain point in SwiftUI: allowing views to directly read properties and relationship chains on NSManagedObject, and to receive the most precise response possible after a save.

Its basic model can be summarized in one sentence: establish subscriptions when reading, publish changes after a successful save.

This also determines several explicit boundaries:

  • Observation consumption happens on the MainActor and primarily serves SwiftUI.
  • Property-level reactivity is only promised for paths where CDE can obtain changed fields.
  • For change sources with insufficient information, CDE conservatively falls back to object-level refresh.
  • Refresh happens after saving or merging, not immediately at the setter.
  • This capability only covers persistent properties and relationship accessors generated by the @PersistentModel macro.
  • This capability requires a Swift 6.2+ compiler and runs on system versions that support Observation, such as iOS 17+ / macOS 14+.

The key is not to make Core Data “more active,” but to make it “more expressible” where SwiftUI needs reactivity. The implementation choices that follow are all built around this boundary: be as precise as possible when Core Data can clearly provide change information, and degrade honestly when the information is insufficient.

Usage

From a usage perspective, I want this to feel as much like a switch as possible, rather than a new architecture.

First, enable MainActor Observation on models that need to be read directly by SwiftUI:

Swift
@objc(Item)
@PersistentModel(observation: .mainActor)
final class Item: NSManagedObject {
  var title: String = ""
  var summary: String = ""

  @Relationship(inverse: "items", deleteRule: .nullify)
  var tag: Tag?
}

The generated property and relationship getters will then have Observation registration capabilities. If a view continues reading properties on relationship targets, the model types along that relationship chain should also enable observation: .mainActor. After that, a view can directly hold and read Core Data objects:

Swift
struct ItemRow: View {
  let item: Item

  var body: some View {
    VStack(alignment: .leading) {
      Text(item.title)
      Text(item.tag?.name ?? "")
    }
  }
}

In this example, the view reads item.title, item.tag, and tag.name. When these properties, backed by CDE-generated accessors, change after a save, CDE triggers the corresponding Observation update. Developers do not need to declare an additional @ObservedObject for either Item or Tag.

Second, a CDEObservationDomain needs to be retained for the lifetime of the container. It is usually held by your store, container holder, or app-level data entry point:

Swift
@MainActor
final class Store {
  let container: NSPersistentContainer
  let observation: CDEObservationDomain

  init(container: NSPersistentContainer) {
    self.container = container
    observation = CDEObservationDomain(container: container)
  }
}

If the change comes from viewContext, a normal save is enough:

Swift
item.title = "New Title"
try container.viewContext.save()

If the change comes from a background writer such as @NSModelActor, it is recommended to initialize the actor with Observation support:

Swift
@NSModelActor
actor ItemWriter {
  func rename(id: NSManagedObjectID, to title: String) async throws {
    guard let item = self[id, as: Item.self] else { return }
    item.title = title
    try await saveObservedChanges()
  }
}

let writer = ItemWriter(observationDomain: store.observation)

saveObservedChanges() is mainly used for update operations. Only when updating properties of existing objects does CDE need to record “which fields changed” before saving. Insertions and deletions do not require property-level metadata and can still use normal Core Data saves.

If you use a regular background context, you can also create a registered context through observation.newObservedBackgroundContext(). For such contexts, when updating existing objects and expecting property-level reactivity, you should save through observation.saveObservedChanges(in:). Calling save() directly lacks the keyPath snapshot for that update and can only be handled through a coarser-grained path. Other operations can continue using the normal Core Data save flow.

There is only one core principle: for any background update where you want property-level reactivity, CDE must be given a chance to record “which fields changed” before the save.

For more complete examples and boundary explanations, see the project documentation: MainActor Observation Guide.

Implementation Approach

With the boundaries above in place, the implementation direction becomes clearer: do not try to turn Core Data into another SwiftData. Instead, add the bridge SwiftUI needs most within the lifecycle Core Data already handles well.

The process can be divided into three stages.

Stage 1: Establish Observation Dependencies During Reads

CDE’s @PersistentModel already generates accessors for properties and relationships. Therefore, when observation: .mainActor is enabled, the macro adds Observation read registration to these getters.

When SwiftUI reads this inside body:

Swift
Text(item.title)

or reads along a relationship chain:

Swift
Text(memo.itemData.item.note.color.description)

these property accesses are recorded by Observation tracking. Developers do not need to split views just to “make observation work,” nor do they need to wrap every NSManagedObject in @ObservedObject.

This step completes read registration: the accessors generated by CDE hand property access over to Observation, and the subsequent dependency relationship is maintained by SwiftUI / Observation tracking.

Stage 2: Generate a Change Snapshot During Save

After read registration is handed over to Observation, CDE still needs to generate a temporary change snapshot along Core Data’s save path, describing which changes in the current transaction can be published to Observation.

This snapshot is not new persistent state, nor is it a dependency tracking table. It is an intermediate result used to connect Core Data with Observation. It mainly contains:

  • the object IDs that changed;
  • the changed fields at the Core Data level;
  • the mapping from those fields to the keyPaths of CDE-generated accessors.

For a local save in viewContext, the change snapshot can be generated within the save flow and consumed immediately. For a background update, the corresponding save wrapper needs to generate the snapshot ahead of time, after which it enters the Domain routing flow during the viewContext merge.

This way, when publishing Observation changes later, CDE does not need to infer after the merge what exactly changed.

This step turns Core Data modifications into a change description that can be published through Observation.

Stage 3: Route to Observation After Merge

The first two stages have completed read registration and change snapshot generation respectively. This stage is only responsible for translating confirmed Core Data changes into property changes that Observation can understand after a save or merge.

CDEObservationDomain is bound to the viewContext of an NSPersistentContainer. It does not replace Core Data’s notification system, nor does it maintain a SwiftUI read-dependency table. Which views ultimately refresh is still determined by Observation tracking based on the previous read registrations.

The Domain cares about the completeness of the change information: whether the change can be located to an object, whether it can be further located to specific fields, and whether those fields can be mapped back to the keyPaths of CDE-generated accessors.

The handling of different change sources can be summarized in the following table:

Change SourceInformation Available to the DomainHandlingReactivity Precision
Local save in viewContextObject ID + changed fieldsDirectly notifies the corresponding keyPath after a successful saveProperty-level
@NSModelActor.saveObservedChanges() or observation.saveObservedChanges(in:)Object ID + changed fields temporarily stored before saveConsumed and routed by the Domain when viewContext mergesProperty-level
Unregistered context, CloudKit external import, and similar mergesUsually only object IDLocates the corresponding instance in viewContext, then refreshes all observable properties on that objectObject-level
Batch update / delete, refresh, rollback, and other lifecycle changesMay only have object ID, or may not even have object IDPerforms a conservative refresh when object ID is available; makes no instance-level guarantee when object ID is unavailableObject-level or no guarantee
Re-merge echo from local save via CloudKit / Persistent History TrackingMay be the same save merging back laterFilters identifiable echoes to avoid amplifying a precise refresh into a full-property refreshPreserves original precision

This design intentionally centralizes Observation publishing on the MainActor. A background context does not publish Observation changes directly; only when it uses a save wrapper such as saveObservedChanges does it provide field-level metadata before saving. The actual consumption and UI response still happen after the viewContext merge.

Implementation Challenges

The real difficulty is not inserting a piece of Observation registration code into the getter, but turning Core Data’s change signals into refresh signals that SwiftUI can trust.

Challenge 1: The Change Snapshot Must Be Generated Before Merge Consumption

Conceptually, a change snapshot is just a combination of object IDs, changed fields, and keyPath mappings. The hard part is timing.

Core Data’s changedValues is not a long-term ledger that can be read at any time. Once an object completes a save, merge, refresh, or fault state change, field-level information may no longer be complete. Therefore, CDE cannot wait until it “needs to publish an Observation change” before asking the object what changed. It must record the information in advance while it is still reliable.

For a local save in viewContext, this problem is relatively manageable: change capture, save confirmation, and Observation publishing all happen along the same main-context path, so the ordering is fairly clear.

The real complexity lies in background contexts. When a background context saves, field information exists in the background writing path, while the moment when the UI actually needs to respond usually occurs after the viewContext merge. This means CDE must pass a change snapshot between these two paths and ensure it reaches the Domain before the main context consumes the merge.

If this relies on an uncontrolled asynchronous hop, the ordering can be disrupted: viewContext may have already completed the merge while the field-level metadata is not ready yet. At that point, the Domain can only see object-level changes, so property-level reactivity degrades into object-level refresh, and in some edge cases, changes may even be missed.

Therefore, the key question is not “whether changedValues can be read,” but whether field-level metadata can be captured, transferred, and consumed at the right time:

  1. field information must be captured within the reliable window around saving;
  2. the change snapshot produced by the background context must be safely handed to the Domain;
  3. when viewContext merges, the Domain must already have the corresponding field-level metadata;
  4. if ordering cannot be guaranteed, the system must honestly degrade rather than pretend it can still provide property-level reactivity.

This is why CDE introduces saveObservedChanges() / saveObservedChanges(in:) for background write paths. They are not meant to change Core Data’s save flow, but to give CDE a clear point at which to generate a change snapshot before the field information disappears.

Challenge 2: Notification Echoes Can Dilute Property-Level Observation

Core Data notifications do not always follow a simple “one business change, one notification” model.

When Persistent History Tracking, CloudKit, or certain parent/child context flows are enabled, a precise local save may later return to viewContext again in the form of a merge or refresh. Without additional handling, CDE may have already precisely refreshed title, and then later receive an objectID-only notification for the same object. That would degrade into “refresh all observable properties.”

The result is that a view that only read summary could also be awakened by a change to title. The value of property-level observation would gradually be diluted by these echoes.

But noise filtering must not be too aggressive. A previous issue exposed a very representative risk: if “this objectID was just handled precisely” is interpreted as an overly broad suppression condition, it may swallow a later full-property fallback that is actually legitimate.

In other words, filtering noise is not as simple as ignoring repeated objectIDs. We need to determine whether it is the same notification round, the same save cycle, or the same re-merge echo. The final direction is to make suppression as short-lived and localized as possible: only filter echoes that can be confirmed, and never swallow real future changes along with them.

Challenge 3: Relationship Chains Must Not Degrade into Object Graph Scanning

SwiftUI can read:

Swift
memo.itemData.item.note.color

But what appears in Core Data notifications is usually just a specific object and a specific persistent field. CDE needs to map Core Data fields back to Swift property paths, while also handling relationship properties, derived properties such as to-many count, and macro-generated structures such as composition.

The easiest way for this to become uncontrollable is to try to infer every possible relationship path.

For example, when a certain Note.color changes, there may theoretically be many Memo views indirectly depending on it. But if CDE tried to scan the entire object graph and infer all potential relationship paths, it would quickly become a global relationship dependency system. That would make both cost and semantics increasingly difficult to control.

Therefore, CDE’s principle is bounded fan-out: route only around affected objects and known mappings, without scanning the entire context, traversing the complete relationship graph, or attempting to infer all potential dependency paths. What it solves is the reactivity problem of SwiftUI relationship-chain reads, not the construction of a new object graph reactivity system on top of Core Data.

Challenge 4: Degradation Must Be Honest

The prerequisite for property-level reactivity is truly knowing which field changed.

If CDE can only obtain an objectID, it cannot package that as property-level reactivity. If it cannot even confirm an objectID, it cannot pretend to know which instance should refresh. Therefore, the challenge here is not to list several degradation levels, but to consistently preserve their semantics in the implementation: objectID-only changes must not be disguised as keyPath changes; lifecycle events without instance information must not be wrapped as precise refreshes; conservative fallback should also be limited as much as possible to objects that can be confirmed.

This strategy may look conservative, but it lets developers know exactly what kind of guarantee they are getting. For an observation system, overpromising is more dangerous than conservative degradation. The former makes bugs difficult to reproduce, while the latter at least keeps the boundaries understandable and reasoned about.

These challenges show that the core of this capability is not to make Core Data “more active,” but to make it “more restrained.” Only by properly handling save timing, notification echoes, and degradation boundaries can SwiftUI truly benefit from the improved developer experience brought by Observation.

Here’s the English translation:

Cost: Why It Won’t Degrade with Scale

Performance was never the primary goal. The real promise of this approach — letting developers trust the framework with view updates — only holds if the cost remains bounded as the project grows. CDE deliberately avoids any path that could expand without bound:

  • The getter adds a single Observation registration and a lightweight Domain association.
  • The setter performs no immediate publication.
  • The merge path routes only around the object IDs involved in the current transaction.
  • It never scans the entire context, never traverses the full relationship graph, and never replays historical changes.

As a result, routing cost depends solely on how much the current transaction actually changed, not on the number of registered objects, the size of the relationship graph, or the volume of accumulated history. Scale won’t push these costs toward linear — let alone steeper — curves.

In the worst case, CDE falls back to object-level invalidation of all observed properties. That is exactly the ceiling already provided by traditional ObservableObject, so it’s no worse than what developers handle manually today. The only line that must be held is this: no routing path is allowed to grow linearly with the number of registered objects, the relationship graph size, or historical pending information. As long as that holds, property-level observation remains a genuine cognitive relief rather than silently turning into a new systemic burden at scale.

Evolution and Expectations

Looking back at the evolution of CDE, from @NSModelActor to @PersistentModel, from TypedPath to today’s MainActor Observation, all of these features reflect the modern development paradigm brought by SwiftData.

This is not imitation for imitation’s sake. After repeated practice, one conclusion becomes difficult to avoid: SwiftData really does align very well with the design intuition of Swift 6 and SwiftUI. It creates a more natural connection between persistent models, concurrency isolation, state observation, and declarative UI.

However, due to performance bottlenecks and the absence of some advanced capabilities, many developers still have to rely on Core Data to support complex business logic today. Core Data is mature, stable, controllable, and has rich low-level capabilities. But it was born in a different era, and in the context of SwiftUI and Observation, it inevitably feels somewhat heavy.

CDE’s goal is not to make Core Data pretend to be SwiftData, but to provide a bridge for existing projects that better fits the modern SwiftUI mental model before SwiftData can fully cover complex Core Data scenarios.

I sincerely hope SwiftData continues to mature quickly, preserving its excellent developer experience while providing stability and efficiency on par with Core Data. When that moment arrives, developers may finally be able to set down many historical burdens and fully embrace the new persistence ecosystem.

Until then, however, making Core Data integrate with Observation in a more natural, more restrained, and more modern way remains a very meaningful effort.

Subscribe to Fatbobman

Weekly Swift & SwiftUI highlights. Join developers.

Subscribe Now