Skip to content

Using Core Data to Save Data in an iOS Application

This article explains how to separate the data layer from the view layer in an iOS application, how to use Core Data to save data, and how to save relationships between two data structures, using the process of building a to-do list app as an example.

Prerequisites

  1. Swift Async Await

Example Code

demo5-todo-list

Core Data Saving Data

Defining Data Types

My personal preference is to name the data structure used in the app TagModel, and the corresponding data structure in Core Data is TagEntity. The structure of TagModel is provided below. For TagEntity, create an .xcdatamodeld file in Xcode and add a new Entity named TagEntity.

swift
import Foundation

struct TagModel {
    let tagId: String
    let createdAt: Date
    let updatedAt: Date
    let title: String
}

Defining DAO

Even though the following example code is long, all DAOs are similar. The DAO contains several parts:

  1. A shared instance
  2. Methods for CRUD operations
  3. Methods to convert between the Model and Entity
swift
import CoreData

class TagDAO {
    enum CustomError: Error, LocalizedError {
        case notFound
    }
    
    static let shared = TagDAO(container: PersistenceController.shared.container)

    private let container: NSPersistentContainer
    
    init(container: NSPersistentContainer) {
        self.container = container
    }
    
    func createOne(tag: TagModel) async throws {
        try await container.performBackgroundTask { ctx in
            let entity = Self.findEntity(tagId: tag.tagId, ctx: ctx) ?? TagEntity(context: ctx)
            Self.modifyEntity(entity: entity, tag: tag)

            try ctx.save()
        }
    }
    
    func updateOne(tag: TagModel) async throws {
        try await container.performBackgroundTask { ctx in
            guard let entity = Self.findEntity(tagId: tag.tagId, ctx: ctx) else {
                throw CustomError.notFound
            }
            Self.modifyEntity(entity: entity, tag: tag)
            
            try ctx.save()
        }
    }
    
    func deleteOne(tagId: String) async throws {
        return try await container.performBackgroundTask { ctx in
            guard let entity = Self.findEntity(tagId: tagId, ctx: ctx) else {
                throw CustomError.notFound
            }
            ctx.delete(entity)
            try ctx.save()
        }
    }
    
    func findOne(id: String) async -> TagModel? {
        return await container.performBackgroundTask { ctx in
            guard let entity = Self.findEntity(tagId: id, ctx: ctx) else { return nil }
            return Self.entityToModel(entity: entity, ctx: ctx)
        }
    }
    
    // page: Start from 0
    func findMany(searchInput: String?, page: Int?, pageSize: Int?) async throws -> [TagModel] {
        let req = TagEntity.fetchRequest()
        
        // Search
        if let searchInput, !searchInput.isEmpty {
            // c: Case insensitive
            // d: Diacritic insensitive
            req.predicate = NSPredicate(format: "title CONTAINS[cd] %@", searchInput)
        }

        // Pagination
        if let page, let pageSize {
            req.fetchLimit = pageSize
            req.fetchOffset = page * pageSize
        }
        
        // Sort
        req.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]

        return try await container.performBackgroundTask { ctx in
            let entities = try ctx.fetch(req)
            return entities.compactMap { Self.entityToModel(entity: $0, ctx: ctx) }
        }
    }
    
    static func findEntity(tagId: String, ctx: NSManagedObjectContext) -> TagEntity? {
        do {
            let req = TagEntity.fetchRequest()
            req.predicate = NSPredicate(format: "tagId = %@", tagId)
            let entities = try ctx.fetch(req)
            return entities.isEmpty ? nil : entities[0]
        } catch {
            print(error.localizedDescription)
            return nil
        }
    }
    
    static func modifyEntity(entity: TagEntity, tag: TagModel) {
        entity.tagId = tag.tagId
        entity.createdAt = tag.createdAt
        entity.updatedAt = tag.updatedAt
        entity.title = tag.title
    }
    
    static func entityToModel(entity: TagEntity, ctx: NSManagedObjectContext) -> TagModel? {
        guard let tagId = entity.tagId,
              let createdAt = entity.createdAt,
              let updatedAt = entity.updatedAt,
              let title = entity.title
        else { return nil }
        
        return TagModel(
            tagId: tagId,
            createdAt: createdAt,
            updatedAt: updatedAt,
            title: title
        )
    }
}

Many-to-Many Relationship

Modify the .xcdatamodeld File

A common feature is that a to-do can reference multiple tags, and a tag can be assigned to multiple to-dos. The relationship between to-dos and tags is many-to-many. If we were to use SQL statements to operate the database, we would need to create an association table to describe the relationship. However, when using Core Data, this can be done using the graphical interface.

  1. In the TodoEntity > Relationships interface, add a tags field and set its destination to TagEntity. Open the right panel, set the relationship type to To Many.
  2. In the TagEntity > Relationships interface, add a todos field and set its destination to TodoEntity. Open the right panel, set the relationship type to To Many.
  3. Select TagEntity > Relationships > Inverse and set it to todos.

picture 0

Including Tags in Todo

  1. First, add the tags field to TodoModel.
  2. In the TodoDAO.updateOne method, add a method to link TodoEntity to TagEntity.
  3. In the TodoDAO.entityToModel method, add a method to convert TagEntity to TagModel.
  4. When creating a new Todo, first call TodoDAO.createOne to create a TodoEntity, then call TodoDAO.updateOne to link the TodoEntity to TagEntity.
swift
import Foundation

struct TodoModel {
    let todoId: String
    let createdAt: Date
    let updatedAt: Date
    let title: String
    let completedAt: Date?
    let tags: [TagModel]
    let location: LocationModel?
}
swift
import CoreData

class TodoDAO {
    enum CustomError: Error, LocalizedError {
        case notFound
    }
    
    static let shared = TodoDAO(container: PersistenceController.shared.container)

    private let container: NSPersistentContainer
    
    init(container: NSPersistentContainer) {
        self.container = container
    }
    
    func createOne(todo: TodoModel) async throws {
        try await container.performBackgroundTask { ctx in
            let entity = Self.findEntity(todoId: todo.todoId, ctx: ctx) ?? TodoEntity(context: ctx)
            Self.modifyEntity(entity: entity, todo: todo)

            try ctx.save()
        }
    }
    
    func updateOne(todo: TodoModel) async throws {
        try await container.performBackgroundTask { ctx in
            guard let entity = Self.findEntity(todoId: todo.todoId, ctx: ctx) else {
                throw CustomError.notFound
            }
            Self.modifyEntity(entity: entity, todo: todo)
            
            entity.removeFromTags(entity.tags ?? [])
            for tag in todo.tags {
                if let tagEntity = TagDAO.findEntity(tagId: tag.tagId, ctx: ctx) {
                    entity.addToTags(tagEntity)
                }
            }
            
            if let id = todo.location?.id {
                entity.location = LocationDAO.findEntity(id: id, ctx: ctx)
            } else {
                entity.location = nil
            }
            
            try ctx.save()
        }
    }
    
    func deleteOne(todoId: String) async throws {
        return try await container.performBackgroundTask { ctx in
            guard let entity = Self.findEntity(todoId: todoId, ctx: ctx) else {
                throw CustomError.notFound
            }
            ctx.delete(entity)
            try ctx.save()
        }
    }
    
    func findOne(todoId: String) async -> TodoModel? {
        return await container.performBackgroundTask { ctx in
            guard let entity = Self.findEntity(todoId: todoId, ctx: ctx) else { return nil }
            return Self.entityToModel(entity: entity, ctx: ctx)
        }
    }
    
    // page: Start from 0
    func findMany(searchInput: String?, tagId: String?, page: Int?, pageSize: Int?) async throws -> [TodoModel] {
        let req = TodoEntity.fetchRequest()
        
        // Search
        var predicates: [NSPredicate] = []
        
        if let searchInput, !searchInput.isEmpty {
            // c: Case insensitive
            // d: Diacritic insensitive
            let predicate = NSPredicate(format: "title CONTAINS[cd] %@", searchInput)
            predicates.append(predicate)
        }
        
        if let tagId {
            let predicate = NSPredicate(format: "ANY tags.tagId == %@", tagId)
            predicates.append(predicate)
        }

        // Pagination
        if let page, let pageSize {
            req.fetchLimit = pageSize
            req.fetchOffset = page * pageSize
        }
        
        // Sort
        req.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]

        return try await container.performBackgroundTask { ctx in
            let entities = try ctx.fetch(req)
            return entities.compactMap { Self.entityToModel(entity: $0, ctx: ctx) }
        }
    }
    
    static func findEntity(todoId: String, ctx: NSManagedObjectContext) -> TodoEntity? {
        do {
            let req = TodoEntity.fetchRequest()
            req.predicate = NSPredicate(format: "todoId = %@", todoId)
            let entities = try ctx.fetch(req)
            return entities.isEmpty ? nil : entities[0]
        } catch {
            print(error.localizedDescription)
            return nil
        }
    }
    
    static func modifyEntity(entity: TodoEntity, todo: TodoModel) {
        entity.todoId = todo.todoId
        entity.createdAt = todo.createdAt
        entity.updatedAt = todo.updatedAt
        entity.title = todo.title
        entity.completedAt = todo.completedAt
    }
    
    static func entityToModel(entity: TodoEntity, ctx: NSManagedObjectContext) -> TodoModel? {
        guard let todoId = entity.todoId,
              let createdAt = entity.createdAt,
              let updatedAt = entity.updatedAt,
              let title = entity.title
        else { return nil }
        
        let tagEntities = entity.tags?.allObjects as? [TagEntity]
        let tags: [TagModel] = (tagEntities ?? []).compactMap { entity in
            TagDAO.entityToModel(entity: entity, ctx: ctx)
        }
        
        var location: LocationModel? = nil
        if let locationEntity = entity.location {
            location = LocationDAO.entityToModel(entity: locationEntity, ctx: ctx)
        }
        
        return TodoModel(
            todoId: todoId,
            createdAt: createdAt,
            updatedAt: updatedAt,
            title: title,
            completedAt: entity.completedAt,
            tags: tags,
            location: location
        )
    }
}

Finding Todos Associated with Tags

I encountered a strange issue: if I included todos as a field in TagEntity, like how tags is included in Todo, creating the association in the app works fine. However, during data import and export, the app often crashes, and I couldn’t understand why. So, my solution is to not fetch todos from TagDAO but instead add a tagId parameter in the TodoDAO.findMany method to filter todos.

One-to-Many Relationship

Defining LocationModel

swift
//
//  LocationModel.swift
//  demo5-todo-list
//
//  Created by arno_solo on 4/8/25.
//

import Foundation

struct LocationModel {
    let id: String
    let latitude: Double
    let longitude: Double
    let altitude: Double?
    let placeName: String?
}

Modify the .xcdatamodeld File

  1. In the TodoEntity > Relationships interface, add a location field and set its destination to LocationEntity.
  2. In the LocationEntity > Relationships interface, add a todos field and set its destination to TodoEntity. Open the right panel, set the relationship type to To Many.

Including Location in Todo

Writing to the database:

swift
class TodoDAO {
    ...
    func updateOne(todo: TodoModel) async throws {
        ...
        Self.modifyEntity(entity: entity, todo: todo)
        ...
        if let id = todo.location?.id {
            entity.location = LocationDAO.findEntity(id: id, ctx: ctx)
        } else {
            entity.location = nil
        }
        
        try ctx.save()
    }
}

Reading from the database:

swift
class TodoDAO {
    static func entityToModel(entity: TodoEntity, ctx: NSManagedObjectContext) -> TodoModel? {
        ...
        var location: LocationModel? = nil
        if let locationEntity = entity.location {
            location = LocationDAO.entityToModel(entity: locationEntity, ctx: ctx)
        }

        return TodoModel(
            todoId: todoId,
            createdAt: createdAt,
            updatedAt: updatedAt,
            title: title,
            completedAt: entity.completedAt,
            tags: tags,
            location: location
        )
    }
}

Separating the Data Layer from the View Layer

My personal preference is:

  • Define a DAO (Data Access Object) for each data type. The DAO is responsible for reading and writing to the local SQLite database.
  • Define a Service for each backend service. The Service is responsible for communication with remote databases.
  • Define a Repository for each data type. The Repository is responsible for coordinating whether the data comes from the local database or a remote database.
  • In the view, try to read and write data through the Repository, rather than directly through the DAO or Service.

Below is an example file structure:

yml
Data/
    Local/
        TodoDAO.swift
        TagDAO.swift
    Network/
        FirestoreService.swift
    TodoRepository.swift
    TagRepository.swift

Here is the code for TodoRepository, which currently only handles reading and writing to the local database. If there is server request code in the future, it can also be written in this class.

swift
import Foundation

class TodoRepository {
    static let shared = TodoRepository(
        todoDAO: TodoDAO.shared,
        tagDAO: TagDAO.shared
    )
    
    private var todoDAO: TodoDAO
    private var tagDAO: TagDAO
    
    init(todoDAO: TodoDAO, tagDAO: TagDAO) {
        self.todoDAO = todoDAO
        self.tagDAO = tagDAO
    }
    
    func createTodo(todo: TodoModel) async throws {
        try await todoDAO.createOne(todo: todo)
        try await todoDAO.updateOne(todo: todo)
    }
    
    func updateTodo(todo: TodoModel) async throws {
        try await todoDAO.updateOne(todo: todo)
    }

    func deleteTodo(todo: TodoModel) async throws {
        try await todoDAO.deleteOne(todoId: todo.todoId)
    }
    
    func findTodos(searchText: String?, tagId: String?, page: Int?, pageSize: Int?) async throws -> [TodoModel] {
        return try await todoDAO.findMany(searchInput: searchText, tagId: tagId, page: page, pageSize: pageSize)
    }
}

References: