Skip to main content

2 posts tagged with "SwiftData"

View All Tags

Mastering SwiftData: Migrating Core Data Apps in SwiftUI

Published: · 15 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

SwiftData was introduced with iOS 17 as Apple's Swift-native approach to persistence. Since then, it has continued to evolve, and iOS 26 adds important capabilities such as model inheritance, while the broader SwiftData API has continued to improve around schema migration, querying, relationships, indexing, and persistent history.

For teams maintaining a production application built with Core Data, moving to SwiftData doesn't mean throwing away the existing persistence layer and starting from scratch. The migration can be approached incrementally, allowing you to preserve existing data while gradually adopting SwiftData's APIs.

This guide explains how to migrate a legacy Core Data application to SwiftData in a modern SwiftUI codebase. We'll cover model conversion, ModelContainer, @Query, schema versioning, SchemaMigrationPlan, lightweight migrations, and the iOS 26 model inheritance capabilities.

The goal isn't simply to replace Core Data APIs. It's to modernize the persistence layer without putting existing user data at risk.

Prerequisites

For the examples in this article, you'll need:

  • Xcode 26 or later
  • iOS 26 SDK
  • Swift and SwiftUI from the current Xcode toolchain
  • An existing application using Core Data
  • A persistent Core Data store, typically backed by SQLite

If your application supports earlier iOS versions, make sure iOS 26-specific APIs are protected with the appropriate availability checks.

Why Migrate from Core Data to SwiftData?

Core Data remains a capable persistence framework, so migration isn't something you should do simply because SwiftData is newer.

The strongest reasons to migrate are usually related to the development experience.

SwiftData provides:

  • Swift-native model definitions
  • Declarative queries through @Query
  • Direct SwiftUI integration
  • Type-safe model relationships
  • Versioned schemas
  • Migration plans
  • Modern Swift language features
  • Less framework-specific model boilerplate

Instead of defining an NSManagedObject subclass and configuring fetch requests around it, you can define a persistent model using the @Model macro and work with it directly from Swift code.

However, if your application has a large and stable Core Data implementation, a full rewrite may introduce unnecessary risk. An incremental migration can be a better approach.

Core Data vs. SwiftData

The APIs look different even though both frameworks solve the same fundamental problem: managing persistent application data.

Core DataSwiftData
NSManagedObject@Model
NSPersistentContainerModelContainer
NSManagedObjectContextModelContext
NSFetchRequestFetchDescriptor
@FetchRequest@Query
Mapping models/custom migrationVersionedSchema + SchemaMigrationPlan

SwiftData provides a Swift-native persistence API and integrates closely with SwiftUI. ModelContainer manages the schema and persistent storage, while ModelContext provides the environment for fetching, inserting, deleting, and saving models.

The migration therefore isn't just an API replacement. It is also a change in how your application defines and interacts with its persistence model.

Should You Migrate Everything at Once?

Usually, no.

For a small application, a complete conversion may be manageable. For a production application with years of stored data and many Core Data entities, an incremental approach is generally easier to validate.

A practical migration can look like this:

Existing Core Data App


Audit existing model


Create SwiftData models


Configure ModelContainer


Migrate individual SwiftUI screens


Replace @FetchRequest with @Query


Introduce schema versioning


Remove legacy Core Data code

Apple's current SwiftData guidance also demonstrates adopting SwiftData in existing applications and evolving schemas over multiple releases rather than treating persistence migration as a single isolated operation.

Step 1: Audit Your Existing Core Data Model

Before creating your first SwiftData model, document the Core Data schema you're migrating.

Check:

  • Entity names
  • Attribute names
  • Attribute types
  • Optionality
  • Relationships
  • Delete rules
  • Unique constraints
  • Indexes
  • Transformable attributes
  • Custom value transformers
  • Existing migration versions

Don't immediately rename everything to make it look more Swift-like.

During the first migration, keeping entity and attribute names stable can make the transition easier to reason about.

For example, if your Core Data model contains:

Task
├── id: UUID
├── title: String
├── createdAt: Date
└── isDone: Bool

your first SwiftData model should closely represent that structure.

Step 2: Convert Core Data Entities to @Model

SwiftData uses the @Model macro to define persistent model types.

The Core Data entity above can become:

import SwiftData

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var createdAt: Date
var isDone: Bool

init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
}
}

The important part here is mapping the existing model, not redesigning it.

A typical mapping looks like:

Core DataSwiftData
StringString
Integer 16Int16
Integer 32Int32
Integer 64Int64
BooleanBool
DoubleDouble
DateDate
Binary DataData
TransformableAppropriate Swift/Codable type

SwiftData also supports indexes and unique constraints through Swift macros, allowing the model definition to express constraints that previously lived in Core Data's model configuration.

Step 3: Configure the ModelContainer

Once the model has been created, SwiftData needs a ModelContainer to manage the persistent store.

A basic configuration looks like this:

import SwiftData

@MainActor
enum PersistenceController {
static let shared: ModelContainer = {
do {
let storeURL = try FileManager.default
.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("MyStore.sqlite")

let configuration = ModelConfiguration(
url: storeURL
)

return try ModelContainer(
for: Task.self,
configurations: configuration
)
} catch {
fatalError(
"Failed to create ModelContainer: \(error)"
)
}
}()
}

Then provide the container to your SwiftUI application:

import SwiftUI
import SwiftData

@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(PersistenceController.shared)
}
}

ModelContainer is responsible for managing the application's persistent storage and schema. For an existing production application, the store configuration and migration path should be tested carefully before release.

Do not test an in-place migration only against a newly created database. Use copies of representative existing stores so that you know how real user data behaves.

When rolling out persistent store migrations in production, monitoring startup times, memory spikes, and data-related crashes is critical. You can track migration stability and catch migration failures in real-time with Appxiom.

Step 4: Replace @FetchRequest with @Query

One of the most noticeable changes when migrating a SwiftUI application is replacing Core Data's @FetchRequest.

A Core Data implementation might look like:

@FetchRequest(
sortDescriptors: [
NSSortDescriptor(
keyPath: \Task.createdAt,
ascending: false
)
]
)
private var tasks: FetchedResults<Task>

The SwiftData version is:

@Query(
sort: \Task.createdAt,
order: .reverse
)
private var tasks: [Task]

A complete SwiftUI view can then use the model directly:

import SwiftUI
import SwiftData

struct ContentView: View {
@Environment(\.modelContext)
private var modelContext

@Query(
sort: \Task.createdAt,
order: .reverse
)
private var tasks: [Task]

var body: some View {
NavigationStack {
List {
ForEach(tasks) { task in
HStack {
Text(task.title)

Spacer()

if task.isDone {
Image(
systemName:
"checkmark.circle.fill"
)
}
}
}
.onDelete(perform: delete)
}
.navigationTitle("Tasks")
.toolbar {
Button("Add") {
addTask()
}
}
}
}

private func addTask() {
let task = Task(title: "New Task")

modelContext.insert(task)

do {
try modelContext.save()
} catch {
print("Failed to save task: \(error)")
}
}

private func delete(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(tasks[index])
}

do {
try modelContext.save()
} catch {
print("Failed to delete task: \(error)")
}
}
}

This is one of the main advantages of SwiftData for SwiftUI applications: persistent data can be queried and consumed using Swift-native types without the same amount of Core Data-specific boilerplate.

Step 5: Version Your SwiftData Schema

The initial migration is only one part of the problem.

Your model will continue changing after you move to SwiftData.

For example, suppose version 1 contains:

import SwiftData

enum AppSchemaV1: VersionedSchema {
static var versionIdentifier: Schema.Version {
Schema.Version(1, 0, 0)
}

static var models: [any PersistentModel.Type] {
[Task.self]
}

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var createdAt: Date
var isDone: Bool

init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
}
}
}

Notice that versionIdentifier uses Schema.Version:

Schema.Version(1, 0, 0)

A later release can introduce a new property:

import SwiftData

enum AppSchemaV2: VersionedSchema {
static var versionIdentifier: Schema.Version {
Schema.Version(2, 0, 0)
}

static var models: [any PersistentModel.Type] {
[Task.self]
}

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var createdAt: Date
var isDone: Bool
var notes: String?

init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false,
notes: String? = nil
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
self.notes = notes
}
}
}

Keep historical schema definitions available when they are required by your migration path. Existing users may have data created under an older schema even though new installations start with the latest one.

Step 6: Create a SchemaMigrationPlan

Once your application has multiple schema versions, define how it moves between them.

For a simple change such as adding an optional property, a lightweight migration may be sufficient:

import SwiftData

enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[
AppSchemaV1.self,
AppSchemaV2.self
]
}

static var stages: [MigrationStage] {
[
.lightweight(
fromVersion: AppSchemaV1.self,
toVersion: AppSchemaV2.self
)
]
}
}

Then create the container with the migration plan:

import SwiftData

@MainActor
enum PersistenceController {
static let shared: ModelContainer = {
do {
let storeURL = try FileManager.default
.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("MyStore.sqlite")

let configuration = ModelConfiguration(
url: storeURL
)

return try ModelContainer(
for: AppSchemaV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: configuration
)
} catch {
fatalError(
"Failed to create ModelContainer: \(error)"
)
}
}()
}

The migration path now looks like:

Schema V1

│ Lightweight migration

Schema V2

As the application evolves, you can extend the chain:

Schema V1


Schema V2


Schema V3


Schema V4

Apple's WWDC25 SwiftData migration example follows this same principle: historical schemas are retained and connected through migration stages, allowing an application to evolve its model while preserving existing data.

Step 7: Understand Lightweight vs. Custom Migration

Not every schema change needs custom migration code.

Use lightweight migration when:

  • Adding an optional property
  • Making supported schema changes that SwiftData can infer
  • Adding compatible relationships
  • Applying supported schema changes without transforming existing values

Use custom migration when:

  • Existing values need to be transformed
  • Multiple properties need to be combined
  • One entity becomes multiple entities
  • Multiple entities become one
  • Required values need to be generated
  • Business logic determines how old records map to the new schema

For example, if an old model contains:

firstName
lastName

and the new model requires:

fullName

the application needs to determine how existing values should be combined. That's a data transformation, not simply a new optional attribute.

The important rule is:

Don't force a complex data transformation into a lightweight migration just because the code is shorter.

A migration that technically succeeds but produces incorrect data is still a failed migration.

Step 8: Take Advantage of iOS 26 Model Inheritance

One of the most important SwiftData additions for iOS 26 is model inheritance.

Apple's WWDC25 SwiftData session demonstrates using inheritance to model different types of trips while sharing common properties.

For example:

import SwiftData

@Model
class Trip {
var destination: String
var startDate: Date
var endDate: Date

init(
destination: String,
startDate: Date,
endDate: Date
) {
self.destination = destination
self.startDate = startDate
self.endDate = endDate
}
}

A specialized model can inherit from it:

@available(iOS 26, *)
@Model
final class BusinessTrip: Trip {
var companyName: String

init(
destination: String,
startDate: Date,
endDate: Date,
companyName: String
) {
self.companyName = companyName

super.init(
destination: destination,
startDate: startDate,
endDate: endDate
)
}
}

The resulting hierarchy is:

Trip
├── BusinessTrip
└── PersonalTrip

Inheritance is useful when there is a genuine "is-a" relationship.

A BusinessTrip is a Trip.

If two models simply share a few properties without forming a natural hierarchy, composition or protocol-based design may be more appropriate. Apple recommends using inheritance deliberately rather than treating it as the default solution for shared properties.

Step 9: Migrate an Existing Schema to Inheritance

Introducing inheritance changes the schema, so existing users still need a migration path.

Apple's WWDC25 SampleTrips example evolved through multiple schema versions and introduced inheritance in its iOS 26 schema. The migration plan then connects the previous schema to the new inheritance-based schema.

The important lesson isn't to copy the exact SampleTrips implementation.

It's to treat the inheritance change as another versioned schema transition:

Schema V2


Schema V3


Schema V4

└── New inheritance hierarchy

This keeps the migration understandable and gives you a clear way to test users upgrading from older releases.

Step 10: Use Modern SwiftData Query Features

iOS 26 also adds sectioning support to SwiftData queries.

Suppose your model contains a category:

@Model
final class Task {
@Attribute(.unique)
var id: UUID

var title: String
var category: String
var createdAt: Date

init(
id: UUID = UUID(),
title: String,
category: String,
createdAt: Date = .now
) {
self.id = id
self.title = title
self.category = category
self.createdAt = createdAt
}
}

You can group query results using sectionBy:

@Query(
sort: \Task.createdAt,
order: .reverse,
sectionBy: \Task.category
)
private var tasks: [Task]

You can then access the sections through the projected query:

List {
ForEach(_tasks.sections) { section in
Section(section.id) {
ForEach(section) { task in
Text(task.title)
}
}
}
}

Apple added sectioning to SwiftData's query APIs as part of its newer SwiftData updates.

This can simplify SwiftUI screens that previously fetched Core Data records and manually grouped them after the fetch.

Step 11: Handle Legacy Transformable Data Carefully

Core Data applications often contain Transformable attributes.

During migration, don't automatically replace every Transformable attribute with a new SwiftData model.

First ask:

  • Is this data a natural Swift value type?
  • Does it need to be queried?
  • Does it need sorting or filtering?
  • Is it a type controlled by the application?
  • Is it a third-party type?

For types that SwiftData can model natively, using a regular SwiftData model or supported value type is usually preferable.

For external Codable types that SwiftData cannot inspect directly, newer SwiftData releases provide Codable-based persistence through schema attributes. Apple describes this as useful for types you don't directly control.

The important distinction is that Codable persistence is not a replacement for modeling data that you actually need SwiftData to query and index.

Testing the Migration

A migration should never be tested only with a fresh installation.

Create test stores representing different historical states:

Core Data V1
Core Data V2
SwiftData V1
SwiftData V2
SwiftData V3
Current SwiftData schema

Test upgrades such as:

V1 → Current
V2 → Current
V3 → Current

Also test:

  • Empty stores
  • Small stores
  • Large stores
  • Missing optional values
  • Existing relationships
  • Duplicate records
  • Unique constraints
  • Renamed properties
  • Newly introduced fields
  • Migration failures
  • Memory usage
  • Migration duration

A migration that works with 100 development records may behave very differently with a production store containing years of data.

The most important test isn't simply:

"Did the app launch?"

It is:

"Did the user's existing data remain correct?"

Common Migration Problems

The store schema doesn't match

If SwiftData cannot reconcile the persisted store with the current schema or migration plan, the container may fail to open the store.

Check:

  • Model names
  • Attribute names
  • Attribute types
  • Schema versions
  • Migration stages
  • Persistent store configuration

Renamed properties don't migrate correctly

Renaming a property can cause problems if the migration doesn't have enough information to associate the old and new representations.

When possible, keep names stable during the initial Core Data to SwiftData migration.

Large migrations cause memory pressure

Avoid fetching the entire database into memory.

Process records in batches instead.

The UI freezes

Don't perform large data transformations synchronously on the main UI path.

Use an appropriate model context and process large datasets in manageable batches.

When Should You Use a Side-by-Side Migration?

In-place migration is attractive when your Core Data schema maps closely to the SwiftData model.

A side-by-side migration can make more sense when the new schema is substantially different.

Consider it when:

  • Most entities are being redesigned
  • Relationships are changing significantly
  • Multiple entities are being merged
  • One entity becomes multiple entities
  • Legacy data needs substantial transformation

The architecture looks like:

Existing Core Data Store


Read old records


Transform data


Write SwiftData


Validate


Switch to new store

This requires more implementation work, but it gives you complete control over how old records are transformed.

Production Migration Checklist

Before shipping your Core Data to SwiftData migration:

  • Audit every Core Data entity
  • Document relationships and delete rules
  • Document existing schema versions
  • Create the initial @Model representations
  • Keep names stable where possible
  • Configure ModelContainer
  • Test against real representative stores
  • Replace @FetchRequest with @Query
  • Introduce VersionedSchema
  • Create a SchemaMigrationPlan
  • Test lightweight migrations
  • Test custom migrations where necessary
  • Test users upgrading across multiple releases
  • Test large stores
  • Test migration failures
  • Verify data integrity after migration
  • Test iOS 26-specific model changes separately
  • Monitor migration behavior after release

Final Takeaway

Migrating from Core Data to SwiftData isn't about replacing one persistence API with another overnight.

The safer approach is to treat it as a controlled schema migration.

Start by mapping your existing Core Data entities to SwiftData @Model types. Keep the initial schema as close as practical to the existing data model, configure the ModelContainer, and move SwiftUI screens from @FetchRequest to @Query incrementally.

As the application evolves, use VersionedSchema and SchemaMigrationPlan to describe how your persistent model changes between releases.

And if you're targeting iOS 26, SwiftData now provides additional modeling capabilities such as class inheritance, allowing applications to represent genuine model hierarchies while continuing to evolve their schemas.

The key principle remains simple:

A migration isn't successful because the app launches. It's successful when existing users retain correct data and the application can continue evolving safely.

Safeguard Your SwiftData Migration with Appxiom

Migrating your persistence layer shouldn't mean flying blind. Appxiom gives mobile teams full visibility into production performance, crash telemetry, and release health as you update your SwiftUI stack.

Mastering SwiftData: Architectural Best Practices for Scalable SwiftUI Apps

Published: · 13 min read
Don Peter
Cofounder and CTO, Appxiom

SwiftData finally gives SwiftUI a first‑class persistence story, but “just make it work” demos don’t answer the questions you hit in production: How do I design a clean SwiftData architecture in SwiftUI? Where should ModelContainer live? How do I do background work safely? How do I test it? This guide distills production‑oriented patterns and SwiftData best practices so your “hello world” doesn’t become a maintenance nightmare. If you’re searching for “swiftdata architecture swiftui,” this post walks you end‑to‑end with code you can ship.

Prerequisites

  • Xcode 15.4+ (Swift 5.9+)
  • iOS 17+ (SwiftData is iOS 17+; some fixes landed in 17.2/17.4, so target the latest when possible)
  • SwiftUI + Swift Concurrency
  • Optional: Observation framework (@Observable) for MVVM

The production problem SwiftData solves (and what it doesn’t)

SwiftData gives you:

  • Declarative models with @Model
  • A simple persistence stack (ModelContainer + ModelContext)
  • Tight SwiftUI integration via @Query and environment(.modelContext)
  • Type‑safe fetches via FetchDescriptor and #Predicate

What it doesn’t solve by itself:

  • Data layer boundaries and testability
  • Background work and thread safety beyond the main actor
  • Separation of concerns for scalable codebases (feature modules, repositories, DI)

The rest of this article shows how to build those missing pieces.

SwiftData architecture in SwiftUI: a production‑ready blueprint

This is the baseline “swiftui data layer architecture” I recommend for most apps:

  • App layer

    • Owns a single ModelContainer configured at startup
    • Injects the container into SwiftUI via .modelContainer(…)
    • Wires up repositories and feature services (via DI)
  • Data layer (SwiftData)

    • @Model types encapsulating persistence schema
    • Repositories that speak domain language and use ModelContext internally
    • Background actors for heavy or long‑running work
  • Domain layer

    • Plain Swift types for business logic and ViewModel state
    • Optional mappers to/from @Model to avoid leaking persistence concerns
  • Presentation layer (SwiftUI)

    • MVVM with @Observable or ObservableObject
    • Simple screens can use @Query for lists
    • Mutations go through ViewModels -> repositories/services

This keeps SwiftUI reactive and simple while making your data layer testable and scalable.

Defining SwiftData models with relationships

We’ll build a small “Projects & Tasks” feature end‑to‑end.

import SwiftData

@Model
final class Project {
var id: UUID
var name: String
var createdAt: Date

@Relationship(deleteRule: .cascade, inverse: \Task.project)
var tasks: [Task]

init(id: UUID = UUID(), name: String, createdAt: Date = .now, tasks: [Task] = []) {
self.id = id
self.name = name
self.createdAt = createdAt
self.tasks = tasks
}
}

@Model
final class Task {
var title: String
var isDone: Bool
var dueDate: Date?

// Inverse is declared on Project.tasks
var project: Project?

init(title: String, isDone: Bool = false, dueDate: Date? = nil, project: Project? = nil) {
self.title = title
self.isDone = isDone
self.dueDate = dueDate
self.project = project
}
}

Notes and best practices:

  • Prefer stable, domain‑meaningful IDs (UUID) alongside SwiftData’s internal identifier (persistentModelID) if you need cross‑layer mapping or external sync.
  • Set delete rules on relationships intentionally. Here we cascade‑delete tasks when a project is deleted.
  • Keep models lean - avoid computed properties with heavy logic; put logic in services or ViewModels.

ModelContainer setup and dependency injection

Create a single ModelContainer for your app and inject it into SwiftUI. This allows @Query and @Environment(.modelContext) to work out of the box.

import SwiftUI
import SwiftData

@main
struct ProjectsApp: App {
var body: some Scene {
WindowGroup {
ProjectListScreen()
}
.modelContainer(for: [Project.self, Task.self])
}
}

If you need custom configuration (e.g., in‑memory for tests, multiple stores), build it manually and pass it into .modelContainer(_:)

@main
struct ProjectsApp: App {
private let container: ModelContainer = {
let config = ModelConfiguration() // customize if needed
return try! ModelContainer(for: [Project.self, Task.self], configurations: config)
}()

var body: some Scene {
WindowGroup {
ProjectListScreen()
}
.modelContainer(container)
}
}

Tip: Keep ModelContainer at the app boundary and inject it into repositories and background actors.

Clean repositories on top of SwiftData (swiftdata clean architecture)

Define domain DTOs to decouple UI/business logic from persistence details.

// Domain-facing DTOs
struct ProjectDTO: Identifiable, Equatable {
let id: UUID
let name: String
let createdAt: Date
}

struct TaskDTO: Identifiable, Equatable {
let id: UUID
let title: String
let isDone: Bool
let dueDate: Date?
let projectId: UUID
}

Repository protocol:

protocol ProjectsRepository {
func allProjects() throws -> [ProjectDTO]
func createProject(name: String) throws -> ProjectDTO
func addTask(to projectId: UUID, title: String, dueDate: Date?) throws -> TaskDTO
func toggleAllTasksOfProject(_ projectId: UUID, isDone: Bool) throws
func deleteProjects(_ ids: [UUID]) throws
}

SwiftData implementation:

import SwiftData

struct SwiftDataProjectsRepository: ProjectsRepository {
private let container: ModelContainer

init(container: ModelContainer) { self.container = container }

// Map functions
private func map(_ p: Project) -> ProjectDTO {
ProjectDTO(id: p.id, name: p.name, createdAt: p.createdAt)
}
private func map(_ t: Task, projectId: UUID) -> TaskDTO {
TaskDTO(id: t.project?.id == projectId ? t.project!.id : (t.project?.id ?? UUID()),
title: t.title,
isDone: t.isDone,
dueDate: t.dueDate,
projectId: t.project?.id ?? projectId)
}

func allProjects() throws -> [ProjectDTO] {
let context = ModelContext(container)
var descriptor = FetchDescriptor<Project>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 200 // production: page if needed
let projects = try context.fetch(descriptor)
return projects.map(map)
}

func createProject(name: String) throws -> ProjectDTO {
let context = ModelContext(container)
let model = Project(name: name)
context.insert(model)
try context.save()
return map(model)
}

func addTask(to projectId: UUID, title: String, dueDate: Date?) throws -> TaskDTO {
let context = ModelContext(container)

let pDesc = FetchDescriptor<Project>(
predicate: #Predicate { $0.id == projectId },
fetchLimit: 1
)
guard let project = try context.fetch(pDesc).first else {
throw NSError(domain: "ProjectsRepository", code: 404, userInfo: [NSLocalizedDescriptionKey: "Project not found"])
}

let task = Task(title: title, dueDate: dueDate, project: project)
context.insert(task)
try context.save()

return TaskDTO(id: task.title.hashValue.uuid, // or better: add UUID on Task model
title: task.title,
isDone: task.isDone,
dueDate: task.dueDate,
projectId: project.id)
}

func toggleAllTasksOfProject(_ projectId: UUID, isDone: Bool) throws {
let context = ModelContext(container)
let tDesc = FetchDescriptor<Task>(
predicate: #Predicate { ($0.project?.id) == projectId } // Example: toggle all tasks in a project
)
let tasks = try context.fetch(tDesc)
for t in tasks { t.isDone = isDone }
try context.save()
}

func deleteProjects(_ ids: [UUID]) throws {
let context = ModelContext(container)
let pDesc = FetchDescriptor<Project>(
predicate: #Predicate { ids.contains($0.id) }
)
for p in try context.fetch(pDesc) {
context.delete(p) // cascades to tasks
}
try context.save()
}
}

// Utility to convert Int hash to UUID for demo purposes only
private extension Int { var uuid: UUID { UUID(uuidString: String(format: "%08X-0000-0000-0000-%012X", self, self)) ?? UUID() } }

Notes:

  • For production, add a real UUID to Task as well; don’t derive one from a hash (shown here to keep the example small).
  • Each repository method creates its own ModelContext (lightweight) and saves explicitly. Avoid sharing ModelContext across threads/actors.

Background work with SwiftData ModelActor concurrency

Heavy imports, sync, and cleanup should not block the main actor. In SwiftData, create a dedicated actor for isolation. Using a custom actor is fine; Apple also provides ModelActor, which streamlines access to a ModelContainer and ModelContext. If you prefer not to rely on macros, a plain actor works well:

actor ProjectsBackgroundWorker {
private let container: ModelContainer

init(container: ModelContainer) { self.container = container }

// Example: Import projects from a CSV on a background actor
func importProjects(fromCSV url: URL) throws -> Int {
let context = ModelContext(container)
context.undoManager = nil // reduce memory for batch work

let data = try Data(contentsOf: url)
let text = String(decoding: data, as: UTF8.self)

var count = 0
for line in text.split(separator: "\n") {
let name = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { continue }
context.insert(Project(name: name))
count += 1

// Save in batches to keep memory low
if count % 100 == 0 {
try context.save()
}
}
try context.save()
return count
}
}

Why this pattern:

  • ModelContext is not thread-safe; creating and using it inside an actor guarantees isolation.
  • Avoid passing @Model instances across actors. If you must reference a specific record, pass its persistentModelID or your own UUID, and re-fetch in the target context.

If you’re comfortable with the SwiftData macro, @ModelActor can synthesize some of this boilerplate. The underlying best practice stays the same: keep context usage confined to a single actor.

MVVM with SwiftUI and SwiftData

You can lean on @Query for simple screens and still keep mutations behind a ViewModel.

import SwiftUI
import SwiftData
import Observation

@Observable
final class ProjectListViewModel {
private let repo: ProjectsRepository

init(repo: ProjectsRepository) { self.repo = repo }

@MainActor
func createProject(name: String) {
do { _ = try repo.createProject(name: name) }
catch { print("Create failed: \(error)") }
}

@MainActor
func deleteProjects(ids: [UUID]) {
do { try repo.deleteProjects(ids) }
catch { print("Delete failed: \(error)") }
}
}

struct ProjectListScreen: View {
@Environment(\.modelContext) private var context
// Use @Query for reactive UI; map to display models as needed
@Query(sort: \Project.createdAt, order: .reverse, animation: .snappy)
private var projects: [Project]

// DI: resolve from environment or container
private let viewModel: ProjectListViewModel

init(container: ModelContainer? = nil) {
// In a real app, use a DI container. Here we bootstrap from environment when available.
if let container {
self.viewModel = ProjectListViewModel(repo: SwiftDataProjectsRepository(container: container))
} else {
// Will be replaced in .onAppear when environment container is known
self.viewModel = ProjectListViewModel(repo: SwiftDataProjectsRepository(container: try! ModelContainer(for: [Project.self, Task.self])))
}
}

@State private var newName = ""

var body: some View {
NavigationStack {
List {
ForEach(projects, id: \.persistentModelID) { project in
NavigationLink(project.name) {
TaskListScreen(project: project)
}
}
.onDelete(perform: delete)
}
.navigationTitle("Projects")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
let name = newName.isEmpty ? "Untitled" : newName
viewModel.createProject(name: name)
newName = ""
} label: {
Image(systemName: "plus")
}
}
}
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New project name", text: $newName)
.textFieldStyle(.roundedBorder)
Button("Add") {
let name = newName.isEmpty ? "Untitled" : newName
viewModel.createProject(name: name)
newName = ""
}
}
.padding()
.background(.bar)
}
}
}

private func delete(offsets: IndexSet) {
let ids = offsets.compactMap { projects[$0].id } // our domain UUID on Project
viewModel.deleteProjects(ids: ids)
}
}

struct TaskListScreen: View {
@Environment(\.modelContext) private var context
let project: Project

@Query private var tasks: [Task]
@State private var title: String = ""

init(project: Project) {
self.project = project
// Filter tasks for this project by its UUID
_tasks = Query(filter: #Predicate<Task> { $0.project?.id == project.id },
sort: [SortDescriptor(\.title)])
}

var body: some View {
List {
ForEach(tasks, id: \.persistentModelID) { task in
HStack {
Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
.onTapGesture {
task.isDone.toggle()
try? context.save()
}
Text(task.title)
}
}
.onDelete(perform: delete)
}
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New task", text: $title)
.textFieldStyle(.roundedBorder)
Button("Add") {
guard !title.isEmpty else { return }
let t = Task(title: title, project: project)
context.insert(t)
try? context.save()
title = ""
}
}
.padding()
.background(.bar)
}
.navigationTitle(project.name)
}

private func delete(offsets: IndexSet) {
for i in offsets { context.delete(tasks[i]) }
try? context.save()
}
}

Key takeaways:

  • Use @Query for lists to get automatic UI updates and batched animations.
  • Write operations should call context.save() explicitly (don’t rely solely on autosave behavior).
  • ViewModels orchestrate intent and call repositories; views remain thin.

Unit testing SwiftData with ModelContainer

Question: “How do you unit test SwiftData models in SwiftUI?”

Use an in‑memory ModelContainer for fast, isolated tests. Inject it into your repository or feature service.

import XCTest
import SwiftData
@testable import ProjectsApp

final class SwiftDataProjectsRepositoryTests: XCTestCase {
var container: ModelContainer!
var repo: ProjectsRepository!

override func setUpWithError() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(for: [Project.self, Task.self], configurations: config)
repo = SwiftDataProjectsRepository(container: container)
}

override func tearDownWithError() throws {
container = nil
repo = nil
}

func testCreateAndFetchProjects() throws {
_ = try repo.createProject(name: "Alpha")
_ = try repo.createProject(name: "Beta")

let projects = try repo.allProjects()
XCTAssertEqual(projects.count, 2)
XCTAssertEqual(projects.map(\.name).sorted(), ["Alpha", "Beta"])
}

func testAddTask() throws {
let p = try repo.createProject(name: "Alpha")
let t = try repo.addTask(to: p.id, title: "Do work", dueDate: nil)
XCTAssertEqual(t.projectId, p.id)
}
}

Notes:

  • Keep ModelContainer local to the test to avoid cross‑test coupling.
  • Use repositories in tests to validate mapping and persistence logic.
  • For UI tests, inject a pre‑seeded, in‑memory container into previews or test app targets.

SwiftData preview container setup in SwiftUI

Question: “swiftdata preview container setup swiftui”

Create a pre‑populated, in‑memory container for previews.

struct ProjectListScreen_Previews: PreviewProvider {
static var previewContainer: ModelContainer = {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: [Project.self, Task.self], configurations: config)
let context = ModelContext(container)

let demo = Project(name: "Demo")
demo.tasks = [Task(title: "Explore SwiftData", project: demo),
Task(title: "Write blog", isDone: true, project: demo)]
context.insert(demo)
try? context.save()

return container
}()

static var previews: some View {
ProjectListScreen(container: previewContainer)
.modelContainer(previewContainer)
}
}

This keeps previews fast and realistic.

SwiftData best practices for scalability and performance

  • Context isolation

    • Never share a ModelContext across threads or actors.
    • Create a fresh ModelContext per unit of work. It’s cheap and safer.
  • Writes and transactions

    • Call save() explicitly after mutations.
    • Batch large imports and save periodically to bound memory.
  • Don’t pass @Model across concurrency domains

    • Pass a UUID or persistentModelID between threads/actors and re‑fetch in the target context.
    • If you need to reload by id: use a predicate on your domain UUID or use the context’s model(for:) with the persistent ID.
  • Fetching and memory

    • Use predicates and sort descriptors to limit result sets.
    • Consider fetch limits or paging for very large tables.
  • Undo and background work

    • Disable undoManager on background/batch contexts to reduce memory.
  • Delete rules

    • Choose cascade/nullify intentionally to avoid orphan records or accidental data loss.
  • Schema evolution

    • Plan additive, backward‑compatible changes when possible.
    • Test migrations on real data before shipping. Keep DTOs decoupled to reduce ripple effects.
  • DI and modularization

    • Inject ModelContainer and repositories. Keep features in separate modules for long‑term maintainability.

Troubleshooting and common pitfalls

  • Concurrency violations

    • Symptom: runtime warnings/crashes about accessing a model from the wrong actor.
    • Fix: confine all ModelContext usage to a single actor/thread. Re‑fetch models in the target context using domain IDs.
  • “No model found” or fetch returns empty

    • Ensure your @Model types are listed in the ModelContainer configuration.
    • Verify you saved the context after inserts.
  • UI not updating after writes

    • Make sure you’re saving the same store the view reads from (i.e., same ModelContainer).
    • If not using @Query, manually refresh state after background updates (e.g., re‑fetch in ViewModel).
  • Crashes on delete

    • Check relationship deleteRule and inverse. Missing or incorrect inverse can cause inconsistencies.
  • Preview data doesn’t show

    • Ensure the preview view uses .modelContainer(previewContainer), not the app’s default container.
    • Seed data before returning the container.

FAQ

How to structure SwiftData in a clean architecture?

  • Keep @Model types in the data layer.
  • Expose repositories with domain DTOs and business‑friendly APIs.
  • Use dependency injection to give ViewModels the repository(s).
  • Use @Query for simple, reactive lists; push all writes through repositories or services.
  • Isolate background work in a dedicated actor (or a ModelActor) and never share ModelContext across actors.

How to perform background tasks in SwiftData using ModelActor?

  • Create an actor that owns a ModelContainer and instantiates a ModelContext per job.
  • Perform inserts/updates within the actor and call save() in batches.
  • Pass IDs between actors, not @Model instances. Re‑fetch on the background actor’s context.
  • If you prefer, use Apple’s ModelActor to synthesize context access; the concurrency rules stay the same.

How do you unit test SwiftData models in SwiftUI?

  • Use ModelContainer with ModelConfiguration(isStoredInMemoryOnly: true) inside XCTest.
  • Inject the container into repositories/services under test.
  • Seed data in a test context and verify fetches/mutations.
  • For preview/testing UI, pass a pre‑populated in‑memory container via .modelContainer(_:)

Putting it together: why this swiftdata architecture works with SwiftUI

This “swiftdata architecture swiftui” approach scales because:

  • SwiftUI stays reactive and simple with @Query.
  • Repositories make your data layer testable and keep persistence concerns contained.
  • Background actors provide safe, predictable concurrency for heavy work.
  • DI and DTOs keep modules independent and migrations manageable.

Key takeaways and next steps

  • Use a single ModelContainer at the app boundary; inject it.
  • Confine ModelContext to a single actor; create one per unit of work.
  • Prefer repositories and DTOs for a clean architecture; keep @Model out of business logic.
  • Use @Query for read‑heavy views; save explicitly on writes.
  • Test with an in‑memory ModelContainer; set up a seeded preview container.

Next steps:

  • Add a UUID to Task to fully decouple DTOs from SwiftData internals.
  • Introduce a background import service using your actor and wire progress to the UI.
  • If your app grows, split features into modules and keep repositories per feature.

By following these SwiftData best practices, you get a scalable, testable, and performant SwiftUI data layer you can maintain for years - without sacrificing the developer ergonomics SwiftData brings to the table.