How to use SwiftData outside SwiftUI
How to use SwiftData outside SwiftUI
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.SwiftData.The hip, all-new persistence framework, that totally isn’t just a wrapper on CoreData (shh!)Resplendent with property wrappers such as @Environment\.modelContext) and @Query, SwiftData is convenient for accessing and modifying persistent data directly from your SwiftUI views!This is great for the Gen Zs who grew up with MV architecture, but what about us boomers still clutching onto MVVM, who insist on unit testing our crufty old code?I can’t just delete these failing tests? Ok, boomer.For a company where 79% of its binaries don’t contain a line of SwiftUI, Apple is very insistent they know better than us about architecting SwiftUI apps.I’ve ranted before about Apple’s resurrection of the massive view controller problem. This is a continuation of the issues introduced by the navigation APIs — they encourage devs to tightly couple navigation & persistence logic to UI code.Directly manipulating data from SwiftUI views is a recipe for linguine. An udonic plague. A pasta disaster. In a non-trivial project, factoring out your data access is critical for maintainability.Fortunately, it’s actually quite simple to use SwiftData outside the context of SwiftUI views.Feel free to check out my open-source repo, NoSwiftDataNoUI, now.It’s… it’s like a Bob Marley joke.The app is dead basic, simply a list of stored Users. If there are no users stored, the app randomly generates 10,000 users.What you see is what you get with our @Model object. There are no complex relationships here.@Model
final class User {
    @Attribute(.unique) let id: UUID
    let firstName: String
    let surname: String
    let age: Int
}Our basic database service can be created with just a few lines:final class UserDatabase {
    
    let container: ModelContainer
    
    init() throws {
        container = try ModelContainer(for: User.self)
    }
}This ModelContainer manages the underlying storage — by default, this is a SQLite file (default.store), but you can configure it to use an in-memory store when you’re running unit tests:init(useInMemoryStore: Bool = false) throws {
    let configuration = ModelConfiguration(
        for: User.self, 
        isStoredInMemoryOnly: useInMemoryStore
    )
    container = try ModelContainer(
        for: User.self, 
        configurations: configuration
    )
}We can add this service to our view models via dependency injection; however in this really basic example I initialised it myself:@Observable
final class ContentViewModel {
  
    var users: [User] = []
    private let database: UserDB

    init() {
        self.database = try! UserDB()
    }
}Now that we’ve proven you can use SwiftData outside SwiftUI, I’ve succeeded in my goal. While I sip on this self-congratulatory Peroni, you can take 5. While you’re waiting, follow me on Twitter if you like.Let’s reconvene back here when you’d like to learn how we can make use of our shiny new database.Let’s begin implementing some standard CRUD operations on our UserDatabase.The create method is pretty simple, but demonstrates a few important concepts: func create(_ user: T) throws {
    let context = ModelContext(container)
    context.insert(user)
    try context.save()
}We use the container to initialise a ModelContext. We want to create this ModelContext inline in the function, because it’s not inherently thread-safe. Using a context as a stored property will incur lots of actor coordination overhead (just see how the compiler complains when using container.mainContext!) This ModelContext behaves similarly to the ManagedObjectContext in Core Data: it tracks the changes made to data objects in memory. These changes are committed — that is, saved — to the container’s underlying data store with context.save(). You might ask, what’s the point of this extra step?Consider this function, which behaves similarly, but creates a collection of User objects:func create(_ users: [T]) throws {
    let context = ModelContext(container)
    for user in users {
        context.insert(user)
    }
    try context.save()
}If you’re an avid reader of Jacob’s Tech Tavern, you’ll know that there is a hierarchy of memory and storage which gets larger and slower with each layer down. Registers are the smallest, fastest, then there are successive levels of CPU cache that might be a few kB to several MB. Ultimately, you get to main memory (RAM) and persistent storage on disk, which is the slowest of all.If you wanted to insert 10,000 items, then performing blocking synchronous I/O with the disk for each item would be extremely slow. When we have a context, these 10,000 items inserted to the context may all be stored in the L2 cache, millimetres away from the CPU, before the commit step treks across the motherboard to store everything on disk.If there is far too much data in a transaction to store in the CPU caches, resource usage may spike and performance could be slow. Paul Hudson explains how to batch data when inserting lots at once, to send sensibly-sized chunks to disk at a time.I put a little extra work into explaining this because the people that kept explaining the ManagedObjectContext as an “intelligent scratchpad” set me back about 2 years.Now we’ve got that out of the way, here’s how I inserted 10,000 items (I created a convenience initialiser in the User that randomised all its properties).private func generateUsers() throws {
    let users = (0..<10_000).compactMap { _ in try User() }
    try database.create(users)
}Good news! In SwiftData, insert is actually an upsert, so we can use the same method to update a user — if the ID matches an existing ID, it updates the existing model object, otherwise it creates a new object.To read our data, we can invoke context.fetch() on our ModelContext. To call this, we need a FetchDescriptor.This is a little more complex than chucking a model into our context — we need to think about what we want to fetch. Really, as API designers, we should to think about what the consumers of our service might want to fetch.func read(predicate: Predicate<User>?,
          sortDescriptors: SortDescriptor<User>...) throws -> [User] {
    let context = ModelContext(container)
    let fetchDescriptor = FetchDescriptor<User>(
        predicate: predicate,
        sortBy: sortDescriptors
    )
    return try context.fetch(fetchDescriptor)
}We can pass in a predicate and sort descriptors to specify exactly what data we want, and how to sort this data. The call site will be able to include this information. This is a great place for a variadic parameter.private func fetchUsers() {
    users = try? userDB.read(
        predicate: #Predicate { $0.firstName == "Jane" },
        sortDescriptors: SortDescriptor<User>(\.surname), 
                         SortDescriptor<User>(\.firstName)
    )
}#Predicate is a macro which allows you to choose which data to filter our from your database — returning all objects for which the closure expression evaluates to true. SortDescriptors determine how to sort the results — in this instance, ordering by the surname property of each object, followed by firstName as a secondary sort within each surname bracket. Reading all the users named “Jane” from the UserDB, then sorted by surnameWe can also invoke fetch(_: batchSize:) to chunk up our fetch and set a maximum number of model objects fetched from each round-trip disk I/O. You’ll want to profile your own code to find whether this is needed, and determine the optimal batch size.If we want to delete anything, we can roll this bad boy: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()
}SwiftData model objects, like their CoreData counterparts, are not inherently thread-safe. Therefore, to avoid any sendability snafus when invoking delete across concurrency domains, we should use the @Sendable property persistentModelID which exists on every SwiftData model object.We can use a #Predicate and ask the context to delete the model matching the specific ID, before committing the changes to disk with context.save().I took it upon myself to perform some Swift-fu to refactor the code using some protocols, extensions, and generics— you won’t see this in a pure-SwiftUI app!Firstly, for our top-level protocol, we don’t necessarily want any SwiftData entities to be specified — this is a simple CRUD database, so can be implementation-agnostic. This means we can conform to the same protocol regardless of using Realm, SQLite, Core Data, SwiftData, or any of their friends.protocol Database<T> {
    associatedtype T
    func create(_ item: T) throws
    func create(_ items: [T]) throws
    func read(predicate: Predicate<T>?, 
              sortDescriptors: SortDescriptor<T>...) throws -> [T]
    func update(_ item: T) throws
    func delete(_ item: T) throws
} Now we can go one level down and produce a SwiftData-specific SwiftDatabase protocol, where the model type is a SwiftData PersistentModel type.protocol SwiftDatabase<T>: Database {
    associatedtype T = PersistentModel
    var container: ModelContainer { get }
}Now, the database protocol is generic over any kind of PersistentModel. This means that we can convert all our above SwiftData operations into generic methods on a protocol extension.extension SwiftDatabase {
    
    func create<T: PersistentModel>(_ item: T) throws {
        let context = ModelContext(container)
        context.insert(item)
        try context.save()
    }

    // do the same for all other operations ...
}Now we have these in place, creating our UserDatabase — or any SwiftData database — takes a cool 5 lines of code.final class UserDB: SwiftDatabase {
    
    typealias T = User
    
    let container: ModelContainer
    
    init() throws {
        container = try ModelContainer(for: User.self)
    }
}I wrote this article after being inspired by someone on, I think it was either Twitter or LinkedIn. They were complaining how Apple only created SwiftData APIs for use in SwiftUI views, and I helpfully pointed out that it was absolutely possible to create them outside — Apple just doesn’t like to make it obvious. You’re welcome, random stranger on the internet!Don’t feel like to subscribing yet? Follow me on Twitter!