Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 

Repository files navigation

SwiftDataManager

A production-ready, thread-safe SwiftData singleton for iOS 17+ / macOS 14+.
Zero third-party dependencies. Full CRUD surface with async/await, Combine, batch operations, background tasks, and transactions.


Requirements

Requirement Version
iOS / iPadOS 17.0+
macOS 14.0+
Xcode 15.0+
Swift 5.9+

Installation

Copy SwiftDataManager.swift into your Xcode project. No Package.swift or SPM entry needed.


Quick Start

1 — Define your SwiftData model

import SwiftData

@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()
    }
}

2 — Configure at app launch

import SwiftUI
import SwiftData

@main
struct MyApp: App {
    init() {
        let schema = Schema([Note.self])
        try! SwiftDataManager.shared.configure(schema: schema)
        // For in-memory (unit tests / Previews):
        // try! SwiftDataManager.shared.configure(
        //     schema: schema,
        //     configuration: ModelConfiguration(isStoredInMemoryOnly: true)
        // )
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

3 — Use from anywhere

let manager = SwiftDataManager.shared

// Create
let note = Note(title: "Hello", body: "World")
try manager.insert(note)

// Read all
let notes: [Note] = try manager.fetch(Note.self)

// Read with filter + sort
let filtered = try manager.fetch(
    Note.self,
    predicate: #Predicate { $0.title.contains("Hello") },
    sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)

// Update
try manager.update(note) { $0.title = "Updated title" }

// Delete
try manager.delete(note)

API Reference

Configuration

// Persistent (default)
func configure(schema: Schema, configuration: ModelConfiguration = ModelConfiguration()) throws

// In-memory (Previews / Tests)
try manager.configure(
    schema: Schema([MyModel.self]),
    configuration: ModelConfiguration(isStoredInMemoryOnly: true)
)

Create

// Single insert
func insert<T: PersistentModel>(_ model: T) throws
func insertAsync<T: PersistentModel>(_ model: T) async throws

// Batch insert — one save round-trip
func insertBatch<T: PersistentModel>(_ models: [T]) throws

Read

// All records (optional filter + sort)
func fetch<T: PersistentModel>(
    _ type: T.Type,
    predicate: Predicate<T>? = nil,
    sortBy: [SortDescriptor<T>] = []
) throws -> [T]

func fetchAsync<T: PersistentModel>(...) async throws -> [T]

// Paged fetch
func fetchPaged<T: PersistentModel>(
    _ type: T.Type,
    predicate: Predicate<T>? = nil,
    sortBy: [SortDescriptor<T>] = [],
    offset: Int = 0,
    limit: Int = 20
) throws -> [T]

// Count (no objects loaded)
func count<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>? = nil) throws -> Int

// First match
func fetchFirst<T: PersistentModel>(
    _ type: T.Type,
    predicate: Predicate<T>? = nil,
    sortBy: [SortDescriptor<T>] = []
) throws -> T?

Update

func update<T: PersistentModel>(_ model: T, changes: (T) -> Void) throws
func updateAsync<T: PersistentModel>(_ model: T, changes: (T) -> Void) async throws

Delete

func delete<T: PersistentModel>(_ model: T) throws
func deleteAsync<T: PersistentModel>(_ model: T) async throws

// Bulk delete — returns count deleted
@discardableResult
func deleteAll<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>? = nil) throws -> Int

Save & Rollback

// Manual save (CRUD methods call this automatically)
func save() throws

// Discard unsaved changes
func rollback() throws

// Check for pending changes
var hasUnsavedChanges: Bool { get }

Background Tasks

SwiftDataManager itself is @MainActor, so its own methods always run on the main thread — that's correct for @Query-driven UI, but wrong for heavy imports. For genuine off-main-thread work, performBackgroundTask hands your closure to SwiftDataBackgroundActor, a separate actor (built on Apple's @ModelActor macro) with its own isolated ModelContext:

// Genuinely runs off the main thread, auto-saves, then notifies changePublisher
func performBackgroundTask(_ work: @escaping @Sendable (ModelContext) throws -> Void) async throws

// Example: large import
try await manager.performBackgroundTask { context in
    for item in hugeDataSet {
        context.insert(MyModel(from: item))
    }
    // save happens automatically after the closure returns
}

Rules for the closure:

  • It must be @Sendable — don't capture PersistentModel instances fetched from another context (they aren't Sendable). Pass primitive values or DTOs in instead.
  • Insert/fetch/delete using the ModelContext argument you're given, not manager.mainContext.
  • If you need the resulting objects back on the main actor, re-fetch them there by identifier (PersistentIdentifier) after the task completes, or rely on changePublisher to trigger a fresh @Query/fetch.

Transactions

Groups multiple operations into a single atomic save. On any error the context is rolled back automatically.

func transaction(_ work: (ModelContext) throws -> Void) throws

// Example
try manager.transaction { ctx in
    ctx.insert(Note(title: "A", body: ""))
    ctx.insert(Note(title: "B", body: ""))
    // Both committed, or neither on throw
}

Combine Publisher

Subscribe to changePublisher to receive a notification after every successful write:

manager.changePublisher
    .receive(on: DispatchQueue.main)
    .sink { [weak self] _ in self?.refreshUI() }
    .store(in: &cancellables)

Direct Context Access

// Main-actor context (for @Query / SwiftUI bindings)
let ctx: ModelContext? = manager.mainContext

// A second, independent context sharing the same container.
// Note: still handed back on the main actor — useful as a scratch context,
// but NOT true off-main-thread isolation. Use performBackgroundTask for that.
let bgCtx: ModelContext = try manager.newBackgroundContext()

Previews & Unit Tests

Use an in-memory store to avoid polluting the device database:

// 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: "Body"))
    return NoteListView()
}

// XCTest
override func setUp() {
    super.setUp()
    let schema = Schema([Note.self])
    try! SwiftDataManager.shared.configure(
        schema: schema,
        configuration: ModelConfiguration(isStoredInMemoryOnly: true)
    )
}

Debug Helpers (DEBUG builds only)

// Print record count to console
manager.debugPrintCount(for: Note.self)
// → SwiftDataManager [DEBUG] — Note count: 42

// Wipe all records of a type
try manager.nukeAll(Note.self)
// → SwiftDataManager [DEBUG] — Nuked 42 Note records.

Error Handling

All methods throw SwiftDataManagerError:

Case When
.notConfigured configure(schema:) was not called before use
.containerCreationFailed(Error) ModelContainer init failed (bad schema, permissions, etc.)
.saveFailed(Error) Context save failed (insert, update, delete, save, transaction)
.fetchFailed(Error) A fetch-family call failed (fetch, fetchPaged, fetchFirst, count)
do {
    try manager.insert(note)
} catch SwiftDataManagerError.notConfigured {
    // remind caller to run configure(schema:) at launch
} catch {
    // underlying SwiftData / Core Data error
    print(error.localizedDescription)
}

Architecture Notes

Aspect Decision
Threading @MainActor — all SwiftDataManager methods run on the main thread by design
Background performBackgroundTask delegates to SwiftDataBackgroundActor, a separate @ModelActor-backed actor with its own isolated context — genuinely off the main thread, not just a context created from @MainActor
No singletons per model One manager owns one container; all models share it
Combine Thin PassthroughSubject — zero overhead when no subscribers
Logging os.Logger with subsystem = Bundle.main.bundleIdentifier — visible in Console.app
Debug helpers Gated behind #if DEBUG — zero production impact
Fetch error wrapping All fetch-family methods (fetch, fetchPaged, fetchFirst, count) catch and rewrap underlying errors as .fetchFailed, matching how writes are wrapped as .saveFailed

License

MIT — free to use, modify, and distribute.

About

A production-ready, thread-safe SwiftData singleton for iOS 17+ and macOS 14+ providing full CRUD support, async/await operations, Combine publishers, and background tasks.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages