A production-ready, thread-safe SwiftData singleton and framework for iOS 17+, macOS 14+, watchOS 10+, tvOS 17+, and visionOS 1+.
Zero third-party dependencies. Full CRUD surface with type inference, atomic transactions, dedicated @ModelActor background tasks with cancellation propagation, store-level batch operations, and real-time AsyncStream / Combine observation.
- π Zero Dependencies β Pure Swift standard library and Apple's native SwiftData framework.
- π§΅ Swift 6 Strict Concurrency Ready β Thread-safe
@MainActorsingleton paired with a dedicated@ModelActor(SwiftDataBackgroundActor) constructed and executed off the main thread. - π¦ Effortless CRUD with Type Inference β Concise APIs like
let items: [Item] = try manager.fetch()with predicate and sort descriptor support. - π PersistentIdentifier Lookup β Safely resolve models across actor boundaries by identifier (
manager.model(for: id)). - β‘ Store-Level Batch Deletes β High-performance
deleteAllexecuted directly at the store layer without materializing objects in memory. - π Atomic Transactions & Safe Rollbacks β Multi-step mutations with automatic context rollback on error and dirty-context protection.
- π‘ Reactive Observation β Subscribe to manager-performed saves via bounded
AsyncStream<Void>(.bufferingNewest(1)) or CombineAnyPublisher<Void, Never>. - π§ͺ Previews & Testing First β Seamless in-memory configurations for XCTest and SwiftUI
#Preview.
| Requirement | Minimum Version |
|---|---|
| iOS / iPadOS | 17.0+ |
| macOS | 14.0+ |
| watchOS | 10.0+ |
| tvOS | 17.0+ |
| visionOS | 1.0+ |
| Xcode | 15.0+ |
| Swift | 5.9+ |
- Open your Xcode project.
- Go to File > Add Package Dependencies...
- Paste the repository URL:
https://github.com/bhargavkukadiya/SwiftDataManager.git - Select the version rule and add
SwiftDataManagerto your target.
Add the dependency to your Package.swift:
dependencies: [
.package(url: "https://github.com/bhargavkukadiya/SwiftDataManager.git", from: "1.0.0")
]Simply drag and drop Sources/SwiftDataManager/SwiftDataManager.swift into your Xcode project.
import SwiftData
import Foundation
@Model
final class Note {
var id: UUID
var title: String
var body: String
var createdAt: Date
init(title: String, body: String) {
self.id = UUID()
self.title = title
self.body = body
self.createdAt = Date()
}
}Configure SwiftDataManager once at startup (e.g. in your @main App init()) and pass manager.container! to your SwiftUI view hierarchy:
import SwiftUI
import SwiftData
import SwiftDataManager
@main
struct MyApp: App {
init() {
let schema = Schema([Note.self])
try! SwiftDataManager.shared.configure(schema: schema)
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(SwiftDataManager.shared.container!)
}
}let manager = SwiftDataManager.shared
// Create
let note = Note(title: "SwiftData", body: "Made simple.")
try manager.insert(note)
// Read (type inferred)
let notes: [Note] = try manager.fetch()
// Filtered & Sorted
let recent: [Note] = try manager.fetch(
predicate: #Predicate { $0.title.contains("SwiftData") },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
// Lookup by Identifier
if let existing: Note = try manager.model(for: note.persistentModelID) {
print("Found:", existing.title)
}
// Update (throwing closure with automatic rollback on failure)
try manager.update(note) {
$0.title = "Updated Title"
}
// Delete
try manager.delete(note)// Default Persistent Store
try manager.configure(schema: Schema([Note.self]))
// Custom Schema Migration Plan & Multiple Store Configurations
try manager.configure(
schema: Schema([Note.self]),
migrationPlan: AppMigrationPlan.self,
configurations: [ModelConfiguration(isStoredInMemoryOnly: false)]
)
// In-Memory Store (Unit Tests & SwiftUI Previews)
try manager.configure(
schema: Schema([Note.self]),
configuration: ModelConfiguration(isStoredInMemoryOnly: true)
)// Single Insert (saves automatically)
try manager.insert(note)
// Batch Insert (single round-trip save)
try manager.insertBatch([note1, note2, note3])// Fetch all with optional filter and sort
let notes: [Note] = try manager.fetch(
predicate: #Predicate { $0.title != "" },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
// Fetch by PersistentIdentifier
let note: Note? = try manager.model(for: persistentId)
// Paged Fetch (LIMIT and OFFSET)
let page: [Note] = try manager.fetchPaged(
sortBy: [SortDescriptor(\.createdAt)],
offset: 20,
limit: 10
)
// Count (store-level aggregation without memory allocation)
let total = try manager.count(Note.self, predicate: #Predicate { $0.title != "" })
// Fetch First Match
let first: Note? = try manager.fetchFirst(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)// Mutate properties inside a throwing closure and save automatically.
// Requires a clean context to guarantee that rollback is strictly scoped to this mutation.
try manager.update(note) { target in
target.title = "New Title"
target.body = "Updated Body"
}// Delete single instance
try manager.delete(note)
// Store-Level Batch Delete (returns pre-delete snapshot count, discardable)
let deletedCount = try manager.deleteAll(
Note.self,
predicate: #Predicate { $0.title.contains("Draft") }
)SwiftDataManager is isolated to @MainActor. For heavy workloads (such as JSON syncing or large imports), performBackgroundTask constructs and executes a detached @ModelActor on a private background executor:
// Executes off the main thread, inherits cooperative task cancellation,
// saves changes automatically if dirty, and returns a Sendable result
let totalImported = try await manager.performBackgroundTask { context in
for item in remoteFeed {
try Task.checkCancellation()
context.insert(Note(title: item.title, body: item.body))
}
return remoteFeed.count
}Rules for Background Tasks:
- The closure is
@Sendable. Do not capturePersistentModelinstances from other threads/contexts.- Work directly with the provided
ModelContext.- If you need to access models on the main actor after saving, re-fetch them by
PersistentIdentifieror viamanager.fetch().
Execute multiple operations atomically. If any error is thrown within the transaction block, all pending changes are discarded via context rollback:
let result = try manager.transaction { ctx -> Int in
ctx.insert(Note(title: "Step 1", body: "..."))
ctx.insert(Note(title: "Step 2", body: "..."))
return 2
}Observe write operations and saves performed through SwiftDataManager in real-time:
Task {
for await _ in manager.changes {
await reloadData()
}
}manager.changePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.reloadData()
}
.store(in: &cancellables)Observation Scope Note:
changesandchangePublisheremit notifications whenever a write operation is saved throughSwiftDataManager(e.g.insert,update,delete,deleteAll,transaction, or a mutatingperformBackgroundTask). Saves performed directly on independent scratch contexts (newScratchContext()) or custom externalModelContextinstances are not emitted through this stream.changesuses a.bufferingNewest(1)buffering policy to prevent unbounded memory growth.
Create an independent, isolated scratch context for short-lived UI drafts or manual transaction flows:
let scratchContext: ModelContext = try manager.newScratchContext()Configure an in-memory store in your setUp() or SwiftUI #Preview to prevent disk writes:
// SwiftUI #Preview
#Preview {
let schema = Schema([Note.self])
try! SwiftDataManager.shared.configure(
schema: schema,
configuration: ModelConfiguration(isStoredInMemoryOnly: true)
)
try! SwiftDataManager.shared.insert(Note(title: "Preview Note", body: "Preview Body"))
return NoteListView()
}
// XCTest
override func setUp() async throws {
try await super.setUp()
let schema = Schema([Note.self])
try SwiftDataManager.shared.configure(
schema: schema,
configuration: ModelConfiguration(isStoredInMemoryOnly: true)
)
}Failures originating within SwiftDataManager (configuration, fetch, save, or dirty-context collisions) are thrown as typed, Sendable SwiftDataManagerError instances. Errors originating inside caller closures passed to update, transaction, and performBackgroundTask (such as CancellationError or domain validation errors) are preserved and propagated unchanged.
| Error Case | Reason |
|---|---|
.notConfigured |
Attempted an operation before calling configure(schema:) |
.containerCreationFailed(Error) |
Failed to initialize underlying ModelContainer |
.saveFailed(Error) |
Context save failure (insert, delete, deleteAll, or transaction save) |
.fetchFailed(Error) |
Fetch or count query failure |
.unsavedChangesPending |
Attempted an update while mainContext already has pending unsaved changes |
do {
try manager.insert(note)
} catch SwiftDataManagerError.notConfigured {
print("Please call SwiftDataManager.shared.configure(...) at app launch.")
} catch {
print("Database error: \(error.localizedDescription)")
}| Feature | Design Strategy |
|---|---|
| Main Actor Isolation | All primary manager APIs are @MainActor bound for safe, direct interaction with SwiftUI views. |
| Off-Main-Thread Execution | performBackgroundTask constructs and runs SwiftDataBackgroundActor off the main actor with cancellation bridging. |
| Memory Preservation | deleteAll executes store-level deletions without materializing object graphs into memory. |
| Strict Concurrency | Fully compliant with Swift 6 Concurrency and Sendable checking. |
| Scoped Observation | Stream and publisher notify exclusively on manager-committed saves with bounded .bufferingNewest(1) buffering. |
Contributions are always welcome!
- Fork the repository.
- Create a new branch (
git checkout -b feature/amazing-feature). - Commit your changes (
git commit -m 'Add amazing feature'). - Push to the branch (
git push origin feature/amazing-feature). - Open a Pull Request.
Please ensure all unit tests pass before submitting a PR:
swift testDistributed under the MIT License. See LICENSE for details.