SwiftData + DataThespian: Thread-Safe Persistence
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
- Thread-safe by default: All database operations isolated in an actor
- Type-safe queries: Compile-time validated predicates and selectors
- SwiftUI integration: Environment-based database access via
@Environment(\.database) - CloudKit support: Works seamlessly with iCloud sync
- Clean API: Simple
fetch,insert, andsavemethods
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
- Model objects aren't Sendable—use proxies for reads
- Writes need context—pass PersistentIdentifier, fetch model, mutate
- Don't mix @Query with manual fetches—choose one pattern
- Refresh after mutations—no automatic observation across actors
- Test with in-memory containers—fast, isolated, deterministic
DataThespian makes SwiftData safe under strict concurrency.
Resources
- DataThespian on GitHub - Source code and documentation
- BrightDigit - Leo Dion's company, creators of DataThespian
- Swift Package Index - Package documentation
- Apple's Swift Concurrency Documentation - Understanding actors and sendability