CoreData is an essential tool for iOS development. It is the primary persistence framework, necessary for persisting complex data types. It also sucks to work with.
It was written way back in the Objective-C days and it shows. It doesn’t take advantage of many of the Swift features that we have come to know and love.
SwiftData is a very welcome and anticipated alternative to CoreData. The syntax is much more Swifty, and it’s very easy to set up and use in a SwiftUI project. But there are significant limitations that are causing most developers to stick with CoreData for a little while longer.
For one, it was designed to be used directly from the view layer. That’s not ideal if you want to write unit tests for anything related to persisted data. There are workarounds for this, but they aren’t exactly straightforward.
Another issue I have with SwiftData is that you can’t use it to save enum types. That’s a major problem. Apple will probably add this functionality in future iterations, but that already puts SwiftData at a disadvantage as most developers won’t be able to use it for some time because of the need to support older versions of iOS.
And possibly the biggest problem is this. If you do manage to workaround the fact that SwiftData is coupled to the view layer – and use SwiftData in an MVVM architecture – you lose the ability to observe changes in your data store. You have to manually manage changes in the data and update the UI. That’s unacceptable for me in the reactive world of SwiftUI.
So this will be a three part series on making an observable CoreData wrapper that is easy to use with modern syntax. I want to tackle all four components of CRUD, but that will take some time. In this post we will work on creating entries and reading them. The second post will tackle sorting and filtering data. And I’ll save updating and deleting for the third post.
Let’s get started.
Error handling
One of the first things you’re going to notice is that most CoreData operations can potentially throw errors. Before we go too far, let’s put together an elegant way to display errors with minimal code. We’ll create a class called ErrorManager, and at the root view of the app we will observe it to show an alert when needed.
import Observation
@Observable
class ErrorManager {
var message: String? = nil
var showError: Bool = false
func show(_ error: String) {
self.message = error
self.showError = true
}
}
@main
struct BKSwiftyApp: App {
let persistenceManager = PersistenceManager()
@State var errorManager = ErrorManager()
var body: some Scene {
WindowGroup {
PeopleCoordinator()
.environment(persistenceManager)
.environment(errorManager)
.alert(isPresented: $errorManager.showError) {
Alert(title: Text(errorManager.message ?? "Error"))
}
}
}
}
That looks good. So now when we encounter an error in any view, we can simply call show(error.localizedMessage) on the ErrorManager in the environment.
Entity Setup
Next up, we’ll create an entity in our data store. We’ll open the Data.xcdatamodeld file and click on “Add Entity”. We’ll name our entity "Person" and give it a non-optional property of type String called "name".
CRUD
Now that that’s out of the way, Let’s get started on our CRUD operations.
Create
In order to add a new Person to our database, we need to first create an object. After the object is created, we need to assign a name to it, and then we need to save it.
Let’s add two functions to our PersistenceManager: one to create the object and another to save it. We’ll be sure to make everything generic so it is reusable for more than just Person objects.
import CoreData
import Observation
@Observable
class PersistenceManager {
let container: NSPersistentContainer
init() {
container = PersistenceManager.createContainer()
}
func newObject<T: NSManagedObject>(ofType type: T.Type) throws -> T {
let entityName = String(describing: type)
guard let entity = NSEntityDescription.entity(forEntityName: entityName,
in: container.viewContext) else {
throw "Unknown entity name: \(entityName)"
}
let newObject = T(entity: entity,
insertInto: container.viewContext)
return newObject
}
func save() throws {
try container.viewContext.save()
}
}
Now, let’s make a new scene with a TextField. We’ll use it to create our Person object and insert it into our database. Let’s call the scene "NewPerson".
Remember: we can create a scene on the terminal with just one line of code.
sh generate_scene.sh NewPerson
The coordinator will look like this. We only need access to the PersistenceManager, so it’s pretty simple.
struct NewPersonCoordinator: View {
@Environment(PersistenceManager.self)
var persistenceManager
var body: some View {
NewPersonView(viewModel: createViewModel())
}
func createViewModel() -> NewPersonView.ViewModel {
.init(persistenceManager: self.persistenceManager)
}
}
And our view will look like this. (Checkout the ErrorManager in action!)
struct NewPersonView: View {
@Environment(\.dismiss)
var dismiss
@Environment(ErrorManager.self)
var errorManager
@State var viewModel: ViewModel
@State var text: String = ""
var body: some View {
TextField(text: $text) {
Text("New person")
}
.padding()
.onSubmit {
do {
try viewModel.savePerson(text)
dismiss()
} catch {
errorManager.show(error.localizedDescription)
}
}
}
}
Finally, the view model will look like this. Notice how func savePerson throws. As we just saw in the view, it is the view’s responsibility to handle the error since the view has access to the ErrorManager via the environment.
import Observation
extension NewPersonView {
@Observable
class ViewModel {
let persistenceManager: PersistenceManager
init(persistenceManager: PersistenceManager) {
self.persistenceManager = persistenceManager
}
func savePerson(_ name: String) throws {
guard !name.isEmpty else {
throw "Enter a name"
}
// Create person object
let person = try persistenceManager.newObject(ofType: Person.self)
// Update the person with a name
person.name = name
// Save the updated person to the database
try persistenceManager.save()
}
}
}
Awesome, we’ve got the C in CRUD. Let’s move on to the R.
Read
To read the items in our database, we’re going to create a class called Query. I want it to look and behave similarly to SwiftData’s Query. So it will be observable and it will (eventually) be able to sort and filter items in the database. I would also like it to fetch data and start listening for changes simply by being declared.
import CoreData
import Observation
@Observable
final class Query<T: NSManagedObject>: NSObject, NSFetchedResultsControllerDelegate {
private(set) var items: [T] = []
private let fetchController: NSFetchedResultsController<T>
init(persistenceManager: PersistenceManager) {
guard let request = T.fetchRequest() as? NSFetchRequest<T> else {
fatalError("Failed to create fetch request for \(T.self)")
}
request.sortDescriptors = []
fetchController = NSFetchedResultsController<T>(fetchRequest: request,
managedObjectContext: persistenceManager.container.viewContext,
sectionNameKeyPath: nil,
cacheName: nil)
super.init()
fetchController.delegate = self
try? fetch()
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<any NSFetchRequestResult>) {
items = fetchController.fetchedObjects ?? []
}
func fetch() throws {
try fetchController.performFetch()
items = fetchController.fetchedObjects ?? []
}
}
Let’s break that down.
This code will fetch all items of a specific type. There is no sorting or filtering quite yet.
NSFetchedResultsController is a very handy mechanism that informs us whenever anything in the database changes. So we are leveraging that to update an array called items. The class is observable, so our view can read from this items array to populate the UI. And just like that, we have an observable CoreData wrapper that we can use to add and retrieve items.
Let’s update our People scene to read from our database. We should also add a way to navigate to the NewPerson scene. We will do that with the help of a toolbar button and a sheet in the coordinator. We’ll also use the coordinator to create a query.
struct PeopleCoordinator: View {
@Environment(PersistenceManager.self)
var persistenceManager
@State var addingPerson: Bool = false
var body: some View {
NavigationStack {
PeopleView(viewModel: createViewModel())
.toolbar {
ToolbarItem {
Button(action: toggleSheet) {
Label("Add Person", systemImage: "plus")
}
}
}
}
.sheet(isPresented: $addingPerson) {
NewPersonCoordinator()
}
}
func createViewModel() -> PeopleView.ViewModel {
.init(persistenceManager: persistenceManager,
query: persistenceManager.query())
}
}
private extension PeopleCoordinator {
func toggleSheet() {
addingPerson.toggle()
}
}
And the view model:
extension PeopleView {
@Observable
class ViewModel {
var persistenceManager: PersistenceManager
var query: Query<Person>
init(persistenceManager: PersistenceManager,
query: Query<Person>) {
self.persistenceManager = persistenceManager
self.query = query
}
}
And finally, the updates we need for the view:
struct PeopleView: View {
@State var viewModel: ViewModel
var body: some View {
List {
ForEach(viewModel.query.items, id: \.objectID) { person in
NavigationLink {
PersonCoordinator(person: person)
} label: {
Text(person.name ?? "--")
}
}
}
}
}
And that’s it! Declaring the query is all you need to do in order to execute the fetch and start getting live updates. Very satisfying.
I’ll continue building on this in my next post. I’ll add sort and filter functionality. And then in the final post of the CoreData series we’ll tackle the U and D in CRUD: updating and deleting items.
I’ll share the updated code when the CoreData series has completed. Everything is still in motion at the moment.
Leave a Reply