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
Example Code
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
.
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:
- A shared instance
- Methods for CRUD operations
- Methods to convert between the Model and Entity
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.
- In the
TodoEntity > Relationships
interface, add atags
field and set its destination toTagEntity
. Open the right panel, set the relationship type toTo Many
. - In the
TagEntity > Relationships
interface, add atodos
field and set its destination toTodoEntity
. Open the right panel, set the relationship type toTo Many
. - Select
TagEntity > Relationships > Inverse
and set it totodos
.
Including Tags in Todo
- First, add the
tags
field toTodoModel
. - In the
TodoDAO.updateOne
method, add a method to linkTodoEntity
toTagEntity
. - In the
TodoDAO.entityToModel
method, add a method to convertTagEntity
toTagModel
. - When creating a new
Todo
, first callTodoDAO.createOne
to create aTodoEntity
, then callTodoDAO.updateOne
to link theTodoEntity
toTagEntity
.
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?
}
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
//
// 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
- In the
TodoEntity > Relationships
interface, add alocation
field and set its destination toLocationEntity
. - In the
LocationEntity > Relationships
interface, add atodos
field and set its destination toTodoEntity
. Open the right panel, set the relationship type toTo Many
.
Including Location in Todo
Writing to the database:
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:
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. TheDAO
is responsible for reading and writing to the local SQLite database. - Define a
Service
for each backend service. TheService
is responsible for communication with remote databases. - Define a
Repository
for each data type. TheRepository
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 theDAO
orService
.
Below is an example file structure:
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.
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: