Unlocking SwiftData: Using It Beyond SwiftUI Framework
Written on
Introduction to SwiftData
SwiftData is the latest persistence framework that offers a modern approach to data management. It includes convenient features like property wrappers, such as @Environment.modelContext and @Query, which simplify how developers access and manipulate persistent data directly from SwiftUI views. While this is particularly appealing to younger developers accustomed to MV architecture, those of us who still favor MVVM and prioritize unit testing often find ourselves at a crossroads.
Apple's insistence on promoting SwiftUI, even in contexts where only 21% of their binaries utilize it, raises questions about the architectural direction they advocate for app development. The navigation APIs have introduced challenges by encouraging developers to merge navigation and persistence logic directly into UI code. This can lead to complex and unmanageable code structures—an issue that becomes especially problematic in larger projects.
Fortunately, utilizing SwiftData outside of SwiftUI views is straightforward.
Exploring SwiftData Beyond SwiftUI
For those interested, my open-source repository, NoSwiftDataNoUI, is available for reference. This project features a simple application that manages a list of stored users. In the absence of existing user data, the app can generate 10,000 random users.
The Model
Our @Model object is uncomplicated, focusing solely on user attributes without intricate relationships:
@Model
final class User {
@Attribute(.unique) let id: UUID
let firstName: String
let surname: String
let age: Int
}
Setting Up the Database
Creating a basic database service only takes a few lines of code:
final class UserDatabase {
let container: ModelContainer
init() throws {
container = try ModelContainer(for: User.self)}
}
The ModelContainer manages the storage, defaulting to a SQLite file, but it can also be configured to use an in-memory store for testing purposes:
init(useInMemoryStore: Bool = false) throws {
let configuration = ModelConfiguration(for: User.self, isStoredInMemoryOnly: useInMemoryStore)
container = try ModelContainer(for: User.self, configurations: configuration)
}
To use this service within our view models, we can inject it as a dependency. In this simplistic example, I initialized it directly:
@Observable
final class ContentViewModel {
var users: [User] = []
private let database: UserDatabase
init() {
self.database = try! UserDatabase()}
}
Now that we've established SwiftData's utility outside of SwiftUI, let's delve into CRUD operations.
The first video provides an insightful tutorial on how to persist data in SwiftUI using SwiftData, perfect for developers looking to streamline their data management.
CRUD Operations
Let's implement standard CRUD operations for our UserDatabase.
Create
The create method is straightforward and demonstrates essential concepts:
func create(_ user: T) throws {
let context = ModelContext(container)
context.insert(user)
try context.save()
}
Creating the ModelContext occurs within the function since it's not inherently thread-safe. Storing a context as a property could lead to performance overhead.
Why Use a Context?
The ModelContext functions similarly to ManagedObjectContext in Core Data, tracking changes made to data objects in memory. These changes are committed to the underlying data store through context.save().
For example, if you want to create multiple users at once:
func create(_ users: [T]) throws {
let context = ModelContext(container)
for user in users {
context.insert(user)}
try context.save()
}
Inserting 10,000 items in one go can significantly enhance performance by minimizing direct disk I/O operations.
Generating Users
To generate a large number of users, I implemented a convenience initializer in the User model that randomizes its properties:
private func generateUsers() {
let users = (0..<10_000).compactMap { _ in try? User() }
try? database.create(users)
}
Update
SwiftData simplifies updates since its insert method functions as an upsert—if the ID exists, it updates the corresponding model object; otherwise, it creates a new one.
Read
To retrieve data, we can invoke context.fetch() using a FetchDescriptor, which allows us to specify what we want to fetch and how to sort the results:
func read(predicate: Predicate?, sortDescriptors: SortDescriptor...) throws -> [User] {
let context = ModelContext(container)
let fetchDescriptor = FetchDescriptor(
predicate: predicate,
sortBy: sortDescriptors
)
return try context.fetch(fetchDescriptor)
}
We can refine our data retrieval further by using predicates and sort descriptors.
Delete
Deleting records is similarly straightforward:
func delete(_ user: User) throws {
let context = ModelContext(container)
let idToDelete = user.persistentModelID
try context.delete(model: User.self, where: #Predicate { user in
user.persistentModelID == idToDelete})
try context.save()
}
This ensures that we maintain thread safety by utilizing the @Sendable property persistentModelID.
Refactoring for Efficiency
To optimize our code, I introduced protocols, extensions, and generics—elements that are not usually present in a pure SwiftUI application.
Database Protocol
Our overarching protocol does not require any specific SwiftData entities, making it flexible across various implementations:
protocol Database {
associatedtype T
func create(_ item: T) throws
func create(_ items: [T]) throws
func read(predicate: Predicate?, sortDescriptors: SortDescriptor...) throws -> [T]
func update(_ item: T) throws
func delete(_ item: T) throws
}
SwiftDatabase Protocol
Next, we define a SwiftDatabase protocol that is specific to SwiftData:
protocol SwiftDatabase: Database {
associatedtype T = PersistentModel
var container: ModelContainer { get }
}
Creating a Database
With this structure in place, creating our UserDatabase becomes concise:
final class UserDB: SwiftDatabase {
typealias T = User
let container: ModelContainer
init() throws {
container = try ModelContainer(for: User.self)}
}
Conclusion
This article was inspired by discussions about the limitations of SwiftData APIs, specifically their focus on SwiftUI. While Apple may not make it obvious, using SwiftData outside of SwiftUI is entirely possible, and I hope this guide clarifies how to implement it effectively.
The second video discusses the process of converting a Core Data implementation to SwiftData, providing insights into the transition.