Back to blog

SwiftData + DataThespian: Thread-Safe Persistence

·4 min read

SwiftData is Apple's modern persistence framework. But with Swift 6's strict concurrency, using it safely requires care. DataThespian, created by Leo Dion at BrightDigit, provides a clean solution.

DataThespian is an open-source library, not my creation. This post explains how I've used it in my projects.

The Concurrency Problem

SwiftData model objects aren't Sendable. You can't pass them between actors:

// This won't compile in Swift 6
func fetchTrack() async -> Track {
    let context = modelContainer.mainContext
    let track = try! context.fetch(FetchDescriptor<Track>()).first!
    return track  // Error: Track is not Sendable
}

Why DataThespian?

DataThespian wraps SwiftData's ModelActor pattern in a developer-friendly API:

Key Benefits

  1. Thread-safe by default: All database operations isolated in an actor
  2. Type-safe queries: Compile-time validated predicates and selectors
  3. SwiftUI integration: Environment-based database access via @Environment(\.database)
  4. CloudKit support: Works seamlessly with iCloud sync
  5. Clean API: Simple fetch, insert, and save methods

Requirements

  • Swift 6.0+
  • iOS 17+, macOS 14+, watchOS 10+, tvOS 17+
  • Xcode 16.0+

Installation

// Package.swift
.package(url: "https://github.com/brightdigit/DataThespian", from: "1.0.0")

The DataThespian Pattern

DataThespian wraps ModelActor to provide thread-safe database access:

import DataThespian
 
@ModelActor
actor DatabaseService {
    func fetchTracks() -> [TrackProxy] {
        let tracks = try! modelExecutor.modelContext.fetch(
            FetchDescriptor<Track>()
        )
        return tracks.map { TrackProxy(from: $0) }
    }
}

Proxy Objects

Create Sendable proxies that cross actor boundaries:

struct TrackProxy: Sendable {
    let id: PersistentIdentifier
    let name: String
    let distance: Double
    let duration: TimeInterval
 
    init(from track: Track) {
        self.id = track.persistentModelID
        self.name = track.name
        self.distance = track.distance
        self.duration = track.duration
    }
}

Proxies contain only primitive, Sendable types. Use them for reads; go back to the model for writes.

Read Pattern

// ViewModel
@Observable @MainActor
class TrackListViewModel {
    private let database: DatabaseService
    var tracks: [TrackProxy] = []
 
    func loadTracks() async {
        tracks = await database.fetchTracks()
    }
}

Write Pattern

Writes need the original model context:

extension DatabaseService {
    func updateTrack(id: PersistentIdentifier, name: String) {
        let context = modelExecutor.modelContext
        guard let track = context.model(for: id) as? Track else { return }
        track.name = name
        try? context.save()
    }
}

Avoiding @Query Conflicts

SwiftUI's @Query and manual fetches can conflict. Choose one approach:

// Option 1: Pure @Query (simple cases)
struct TrackListView: View {
    @Query var tracks: [Track]
 
    var body: some View {
        List(tracks) { track in
            Text(track.name)
        }
    }
}
 
// Option 2: Pure ViewModel (complex cases)
struct TrackListView: View {
    @State var viewModel: TrackListViewModel
 
    var body: some View {
        List(viewModel.tracks) { proxy in
            Text(proxy.name)
        }
        .task { await viewModel.loadTracks() }
    }
}

Don't mix @Query with manual context operations. They can see different data states.

Manual Refresh

When you mutate data, refresh manually:

class TrackListViewModel {
    @Published var tracks: [TrackProxy] = []
 
    func deleteTrack(_ proxy: TrackProxy) async {
        await database.deleteTrack(id: proxy.id)
        await loadTracks()  // Refresh after mutation
    }
}

Testing with In-Memory Containers

// TestContainer.swift
struct TestContainer {
    static func create() -> ModelContainer {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        return try! ModelContainer(
            for: Track.self, Waypoint.self,
            configurations: config
        )
    }
 
    static func populated() -> ModelContainer {
        let container = create()
        let context = container.mainContext
 
        // Add test data
        context.insert(Track(name: "Test Track", distance: 1000))
        try! context.save()
 
        return container
    }
}

Full Architecture

┌─────────────────┐     ┌──────────────────┐
│   SwiftUI View  │────▶│    ViewModel     │
│   (@State VM)   │     │   (@Observable)  │
└─────────────────┘     └────────┬─────────┘
                                 │
                                 │ async/await
                                 ▼
                        ┌──────────────────┐
                        │  DatabaseService │
                        │   (@ModelActor)  │
                        └────────┬─────────┘
                                 │
                                 │ ModelContext
                                 ▼
                        ┌──────────────────┐
                        │   SwiftData      │
                        │   (SQLite)       │
                        └──────────────────┘

Key Takeaways

  1. Model objects aren't Sendable—use proxies for reads
  2. Writes need context—pass PersistentIdentifier, fetch model, mutate
  3. Don't mix @Query with manual fetches—choose one pattern
  4. Refresh after mutations—no automatic observation across actors
  5. Test with in-memory containers—fast, isolated, deterministic

DataThespian makes SwiftData safe under strict concurrency.

Resources

Share:

Related Posts

·4 min read

How to define iOS projects as Swift code, with reusable templates and consistent configuration.

Quick tips for setting up comprehensive snapshot testing across devices and color schemes.

How to use the Page Object pattern to write UI tests that don't break with every UI change.