
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.




