Skip to main content

One post tagged with "ModelActor"

View All Tags

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.