Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
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?
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.
SwiftData outside SwiftUI
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.
The Model
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
}
Creating our Database
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.
CRUD Operations
Let’s begin implementing some standard CRUD operations on our UserDatabase
.
Create
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()
}
Creating the ModelContext
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
!)
Why use a Context?
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.
Generating Users
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)
}
Update
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.
Read
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.
We 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.
Delete
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()
.
Refactoring
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!
Database protocol
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
}
SwiftDatabase protocol
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
.
Protocol extensions
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 ...
}
Creating a Database
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)
}
}
Conclusion
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!
Thanks for this example. I'm trying - and failing - to extend it to work with multiple models and datasources. For instance, Users accessible from a UserDataSource and UserGroups accessible from a UserGroupDataSource. Help appreciated.