Let’s build a type-safe Core Data stack that’s both powerful and pleasant to use. We’ll add features incrementally, starting with fetching, and adding declarative queries and domain separation.

This will be the result:

let users: [UserEntity] = container.fetch()   // fetch entities
let users: [User] = container.fetch(.id("1")) // fetch domain objects

// declarative queries with fluent API
let query = EntityQuery<UserEntity>
        .all
        .ascending("lastName")
        .ascending("firstName")
        .and(NSPredicate(format: "age > %d", 18))
        .and(NSPredicate(format: "isActive == YES"))
        .limit(20)
let users = fetch(query)

Fetching Entities

This is the simplest API possible without macros or property wrappers. Can you guess the implementation?

let users: [UserEntity] = container.fetch()

fetch() implementation

This is how you fetch a specific entity in Core Data:

let fetchRequest = NSFetchRequest<Person>(entityName: "Person")
let persons = try persistentContainer.viewContext.fetch(fetchRequest)

It works for any type so we can make it generic. The name should match the class name.

extension NSManagedObject {
    class func entityName() -> String {
        String("\(self)")
    }
}

extension NSPersistentContainer {
    func fetch<T: NSManagedObject>() throws -> [T] {
        try viewContext.fetch(
            NSFetchRequest<T>(entityName: T.entityName())
        )
    }
}

And we arrive at our goal.

let users: [UserEntity] = container.fetch()

A query can also have a predicate, sort, and a limit. A problem with Core Data is that it lacks a declarative API, let’s fix that.

struct EntityQuery<T: NSManagedObject> {
    let predicate: NSPredicate?
    let sortDescriptors: [NSSortDescriptor]
    let fetchLimit: Int?
}

Integrating this query on our fetch method doesn’t change its signature, but enables query modification with a fluent API.


extension EntityQuery {
    var fetchRequest: NSFetchRequest<T> {
        // ...
    }
    
    static var all: EntityQuery<T> {
        EntityQuery<T>()
    }
}

func fetch<T: ManagedObject>(_ query: EntityQuery<T> = .all) throws -> [T] {
    viewContext.fetch(query.fetchRequest)
}

let users: [UserEntity] = container.fetch()
extension EntityQuery {
    func limit(_ count: Int) -> EntityQuery<T> {
        EntityQuery(predicate: predicate,
                    sortDescriptors: sortDescriptors,
                    fetchLimit: count)
    }
}
let users: [UserEntity] = container.fetch().limit(10)

Convenience queries are also possible with minimal additions to the API surface.

container.fetch(.minors)      // fetch by age
container.fetch(.id(user.id)) // fetch by ID

Implementation

extension EntityQuery
    static func minors() -> EntityQuery<UserEntity> {
        EntityQuery(predicate: NSPredicate(format: "age < 18"))
    }
}

extension EntityQuery where T: NSManagedObject & Identifiable, T.ID == UUID? {
    static func id(_ id: UUID) -> EntityQuery<T> {
        EntityQuery(predicate: NSPredicate(format: "id == %@", id as NSUUID))
    }
}

Data First

Let’s reflect on the benefits of a declarative approach:

// Procedural
let request = NSFetchRequest<User>(entityName: "User")
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [
    NSPredicate(format: "age > %d", 18),
    NSPredicate(format: "isActive == YES")
])
request.sortDescriptors = [
    NSSortDescriptor(key: "lastName", ascending: true),
    NSSortDescriptor(key: "firstName", ascending: true)
]
request.fetchLimit = 20
// Declarative
let query = EntityQuery<UserEntity>.all
    .ascending("lastName")
    .ascending("firstName")
    .and(NSPredicate(format: "age > %d", 18))
    .and(NSPredicate(format: "isActive == YES"))
    .limit(20)

Gone are the underlying mechanics of NSFetchRequest. Clients are now closer to what they want to achieve, not how. Additional benefits:

  • Queries become composable and reusable
  • The structure prevents invalid states
  • Declarative queries are easier to inspect and test, compared to procedural code
  • Separation of concerns between how and what to fetch
  • Fetch mechanics are centralize in a single point that interprets data, decreasing the code and its possible mistakes

Through the brief history of computing, many have noted the convenience and power of shifting procedural complexity to declarative data.


Show me your flowchart and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowchart; it'll be obvious. –The Mythical Man-Month (1975)
Rule of Representation: Fold knowledge into data, so program logic can be stupid and robust. Even the simplest procedural logic is hard for humans to verify, but quite complex data structures are fairly easy to model and reason about. –Basics of the Unix Philosophy
Bad programmers worry about the code. Good programmers worry about data structures and their relationships. –Linus Torvalds
Smart data structures and dumb code works a lot better than the other way around. –Eric S. Raymond, The Cathedral and The Bazaar.
Or a similar argument before computers existed:
Have the argument clear in your mind, the words will follow naturally. –Cato the Elder

Fetching Domain

This would be nice to have:

let users: [UserEntity] = container.fetch()
let users: [User] = container.fetch()

To implement this we know

  • managed objects need to map to domain,
  • both the fetch and the mapping needs to be tied to the generic type, which leads to the following solution:
public protocol DomainConvertible {
    associatedtype DomainModel
    func toDomain() throws -> DomainModel
}

extension UserEntity: DomainConvertible {
    public func toDomain() throws -> User {
        User(id: id, name: name)
    }
}

func fetch<T: ManagedObject & DomainConvertible>(_ query: EntityQuery<T>) throws -> [T.DomainModel] {
    let objects: [T] = try fetch(query)
    return try objects.map { try $0.toDomain() }
}

Persisting Domain

A similar protocol is used to map back from model to database:

public protocol EntityConvertible {
    associatedtype MO: NSManagedObject
    func toEntity(container: EntityContainer) throws -> MO?
}

struct User: EntityConvertible { ... }

For updates create a User.Update that has the same properties, but all except the ID are optional. When we submit an update, it looks for the object by ID and updates any non-nil property. For objects with relations this requires some effort, since we have to upsert the objects.

An interesting case is bidirectional relationships, for instance,

Dog.owner <--n 1--> Person.dogs

There are two solutions to avoid an infinite loop:

  • change dog.owner from person to UUID
  • or remove the dog.owner property

Both are valid. Prefer removing the dog.owner if you primarily access dogs through their owners.

Benefits

This architecture provides several advantages:

  • Type-safe queries prevent runtime errors
  • Mapping between domain and persistence
  • Testable code through protocol abstractions
  • Bidirectional conversion between domain models and entities
  • Easier thread-safety thanks to value objects

Thread-Safety in Core Data

These are the rules:

  • For light tasks use main thread.
  • For heavy tasks use performBackgroundTask.
  • For complex operations child contexts with type NSPrivateQueueConcurrencyType that merges changes back to the main context. This also lets you discard the child context to rollback changes.
  • To send objects across threads use domain structs or NSManagedObjectID, never managed objects.

The cost is minimal - just a thin layer over Core Data that makes it significantly more robust and maintainable. The source code for the article is in GitHub.