One of the most shocking experiences I encountered as an iOS developer was working with Core Data, now known as Swift Data. While there are many architectural patterns for software development, I have rarely seen an approach that combines view logic with database access code. Separating data access from the rest of the application has several advantages: it centralizes all data operations through a single access point, facilitates better testing, and ensures that changes to the data access API do not impact the rest of the codebase—only the data access layer needs adaptation.

Additionally, most applications I’ve worked on require some level of data processing before presenting information to users. While Apple excels in many areas, their examples of how to use Core Data or Swift Data do not align well with my daily development needs. This is why I decided to write a post demonstrating how to reorganize some components to better suit these requirements.

In this post, we will refactor a standard Swift Data implementation by decoupling Swift Data from the View components.

Custom Swift Data

The Starting Point sample app is an application that manages a persisted task list using Swift Data.
import SwiftUI
import SwiftData

struct TaskListView: View {
    @Environment(\.modelContext) private var modelContext
    @Query var tasks: [TaskDB]

    @State private var showAddTaskView = false

    var body: some View {
        NavigationView {
            List {
                ForEach(tasks) { task in
                    HStack {
                        Text(task.title)
                            .strikethrough(task.isCompleted, color: .gray)
                        Spacer()
                        Button(action: {
                            task.isCompleted.toggle()
                            try? modelContext.save()
                        }) {
                            Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                        }
                        .buttonStyle(BorderlessButtonStyle())
                    }
                }
                .onDelete(perform: deleteTasks)
            }
            .navigationTitle("Tasks")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: { showAddTaskView = true }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showAddTaskView) {
                AddTaskView()
            }
        }
    }

    private func deleteTasks(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(tasks[index])
        }
        try? modelContext.save()
    }
}

As observed in the code above, the view code is intertwined with data access logic (Swift Data). While the code is functioning correctly, there are several concerns:

  1. Framework Dependency: If the framework changes, will all the views using this framework need to be updated?
  2. Unit Testing: Are there existing unit tests to validate CRUD operations on the database?
  3. Debugging Complexity: If I need to debug when a record is added, do I need to set breakpoints across all views to identify which one is performing this task?
  4. Code Organization: Is database-related logic spread across multiple project views?

Due to these reasons, I have decided to refactor the code.

Refactoring the app

This is a sample app, so I will not perform a strict refactor. Instead, I will duplicate the views to include both approaches within the same app, allowing cross-CRUD operations between the two views.

@main
struct AgnosticSwiftDataApp: App {
    var body: some Scene {
        WindowGroup {
            TabView {
                TaskListView()
                    .tabItem {
                    Label("SwiftData", systemImage: "list.dash")
                }
                    .modelContainer(for: [TaskDB.self])
                AgnosticTaskListView()
                    .tabItem {
                    Label("Agnostic", systemImage: "list.dash")
                }
            }
        }
    }
}

First of all we are going to create a component that handles all DB operations:

import SwiftData
import Foundation

@MainActor
protocol DBManagerProtocol {
    func addTask(_ task: Task)
    func updateTask(_ task: Task)
    func removeTask(_ task: Task)
    func fetchTasks() -> [Task]
}

@MainActor
class DBManager: NSObject, ObservableObject {

    @Published var tasks: [Task] = []


    static let shared = DBManager()

    var modelContainer: ModelContainer? = nil

    var modelContext: ModelContext? {
        modelContainer?.mainContext
    }

    private init(isStoredInMemoryOnly: Bool = false) {
        let configurations = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)
        do {
            modelContainer = try ModelContainer(for: TaskDB.self, configurations: configurations)
        } catch {
            fatalError("Failed to initialize ModelContainer: \(error)")
        }
    }
}

extension DBManager: DBManagerProtocol {

    func removeTask(_ task: Task) {
        guard let modelContext,
            let taskDB = fetchTask(by: task.id) else { return }

        modelContext.delete(taskDB)

        do {
            try modelContext.save()
        } catch {
            print("Error on deleting task: \(error)")
        }
    }

    func updateTask(_ task: Task) {
        guard let modelContext,
            let taskDB = fetchTask(by: task.id) else { return }

        taskDB.title = task.title
        taskDB.isCompleted = task.isCompleted

        do {
            try modelContext.save()
        } catch {
            print("Error on updating task: \(error)")
        }
        return
    }

    private func fetchTask(by id: UUID) -> TaskDB? {
        guard let modelContext else { return nil }

        let predicate = #Predicate<TaskDB> { task in
            task.id == id
        }

        let descriptor = FetchDescriptor<TaskDB>(predicate: predicate)

        do {
            let tasks = try modelContext.fetch(descriptor)
            return tasks.first
        } catch {
            print("Error fetching task: \(error)")
            return nil
        }
    }

    func addTask(_ task: Task) {
        guard let modelContext else { return }
        let taskDB = task.toTaskDB()
        modelContext.insert(taskDB)
        do {
            try modelContext.save()
            tasks = fetchTasks()
        } catch {
            print("Error addig tasks: \(error.localizedDescription)")
        }
    }

    func fetchTasks() -> [Task] {
        guard let modelContext else { return [] }

        let fetchRequest = FetchDescriptor<TaskDB>()

        do {
            let tasksDB = try modelContext.fetch(fetchRequest)
            tasks = tasksDB.map { .init(taskDB: $0) }
            return tasks 
        } catch {
            print("Error fetching tasks: \(error.localizedDescription)")
            return []
        }
    }

    func deleteAllData() {
        guard let modelContext else { return }
        do {
            try modelContext.delete(model: TaskDB.self)
        } catch {
            print("Error on removing all data: \(error)")
        }
        tasks = fetchTasks()
    }
}

In a single, dedicated file, all database operations are centralized. This approach offers several benefits:

  • If the framework changes, only the function responsible for performing the database operations needs to be updated.
  • Debugging is simplified. To track when a database operation occurs, you only need to set a single breakpoint in the corresponding function.
  • Unit testing is more effective. Each database operation can now be tested in isolation.
import Foundation
import Testing
import SwiftData
@testable import AgnosticSwiftData

extension DBManager {
    func setMemoryStorage(isStoredInMemoryOnly: Bool) {
        let configurations = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)
        do {
            modelContainer = try ModelContainer(for: TaskDB.self, configurations: configurations)
        } catch {
            fatalError("Failed to initialize ModelContainer: \(error)")
        }
    }
}

@Suite("DBManagerTests", .serialized)
struct DBManagerTests {
    
    func getSUT() async throws -> DBManager {
        let dbManager = await DBManager.shared
        await dbManager.setMemoryStorage(isStoredInMemoryOnly: true)
        await dbManager.deleteAllData()
        return dbManager
    }
    
    @Test("Add Task")
    func testAddTask() async throws {
        let dbManager = try await getSUT()
        let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
        
        await dbManager.addTask(task)
        
        let fetchedTasks = await dbManager.fetchTasks()
        #expect(fetchedTasks.count == 1)
        #expect(fetchedTasks.first?.title == "Test Task")
        
        await #expect(dbManager.tasks.count == 1)
        await #expect(dbManager.tasks[0].title == "Test Task")
        await #expect(dbManager.tasks[0].isCompleted == false)
    }
    
    @Test("Update Task")
    func testUpateTask() async throws {
        let dbManager = try await getSUT()
        let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
        await dbManager.addTask(task)
        
        let newTask = Task(id: task.id, title: "Updated Task", isCompleted: true)
        await dbManager.updateTask(newTask)
        
        let fetchedTasks = await dbManager.fetchTasks()
        #expect(fetchedTasks.count == 1)
        #expect(fetchedTasks.first?.title == "Updated Task")
        #expect(fetchedTasks.first?.isCompleted == true)
        
        await #expect(dbManager.tasks.count == 1)
        await #expect(dbManager.tasks[0].title == "Updated Task")
        await #expect(dbManager.tasks[0].isCompleted == true)
    }
    
    @Test("Delete Task")
    func testDeleteTask() async throws {
        let dbManager = try await getSUT()
        let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
        await dbManager.addTask(task)
        
        await dbManager.removeTask(task)
        
        let fetchedTasks = await dbManager.fetchTasks()
        #expect(fetchedTasks.isEmpty)
        
        await #expect(dbManager.tasks.isEmpty)
    }
    
    

    @Test("Fetch Tasks")
    func testFetchTasks() async throws {
        let dbManager = try await getSUT()
        let task1 = Task(id: UUID(), title: "Task 1", isCompleted: false)
        let task2 = Task(id: UUID(), title: "Task 2", isCompleted: true)
        
        await dbManager.addTask(task1)
        await dbManager.addTask(task2)
        
        let fetchedTasks = await dbManager.fetchTasks()
        #expect(fetchedTasks.count == 2)
        #expect(fetchedTasks.contains { $0.title == "Task 1" })
        #expect(fetchedTasks.contains { $0.title == "Task 2" })
        
        await #expect(dbManager.tasks.count == 2)
        await #expect(dbManager.tasks[0].title == "Task 1")
        await #expect(dbManager.tasks[0].isCompleted == false)
        await #expect(dbManager.tasks[1].title == "Task 2")
        await #expect(dbManager.tasks[1].isCompleted == true)
    }

    @Test("Delete All Data")
    func testDeleteAllData() async throws {
        let dbManager = try await getSUT()
        let task = Task(id: UUID(), title: "Test Task", isCompleted: false)
        
        await dbManager.addTask(task)
        await dbManager.deleteAllData()
        
        let fetchedTasks = await dbManager.fetchTasks()
        #expect(fetchedTasks.isEmpty)
        
        await #expect(dbManager.tasks.isEmpty)
    }
    
    @Test("Model Context Nil")
    @MainActor
    func testModelContextNil() async throws {
        let dbManager = try await getSUT()
        dbManager.modelContainer = nil
        
        dbManager.addTask(Task(id: UUID(), title: "Test", isCompleted: false))
        #expect(try dbManager.fetchTasks().isEmpty)
        
        #expect(dbManager.tasks.count == 0)
    }
    
}

And this is the view:

import SwiftUI

struct AgnosticTaskListView: View {
    @StateObject private var viewModel: AgnosticTaskLlistViewModel = .init()
    
    @State private var showAddTaskView = false
    
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.tasks) { task in
                    HStack {
                        Text(task.title)
                            .strikethrough(task.isCompleted, color: .gray)
                        Spacer()
                        Button(action: {
                            viewModel.toogleTask(task: task)
                        }) {
                            Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                        }
                        .buttonStyle(BorderlessButtonStyle())
                    }
                }
                .onDelete(perform: deleteTasks)
            }
            .navigationTitle("Tasks")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: { showAddTaskView = true }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showAddTaskView) {
                AddTaskViewA()
                    .environmentObject(viewModel)
            }
        }.onAppear {
            viewModel.fetchTasks()
        }
    }
    
    private func deleteTasks(at offsets: IndexSet) {
        viewModel.removeTask(at: offsets)
    }
}

SwiftData is not imported, nor is the SwiftData API used in the view. To refactor the view, I adopted the MVVM approach. Here is the ViewModel:

import Foundation

@MainActor
protocol AgnosticTaskLlistViewModelProtocol {
    func addTask(title: String)
    func removeTask(at offsets: IndexSet)
    func toogleTask(task: Task)
}

@MainActor
final class AgnosticTaskLlistViewModel: ObservableObject {
    @Published var tasks: [Task] = []
    
    let dbManager = appSingletons.dbManager
    
    init() {
        dbManager.$tasks.assign(to: &$tasks)
    }
}

extension AgnosticTaskLlistViewModel: AgnosticTaskLlistViewModelProtocol {
    func addTask(title: String) {
        let task = Task(title: title)
        dbManager.addTask(task)
    }
    
    func removeTask(at offsets: IndexSet) {
        for index in offsets {
            dbManager.removeTask(tasks[index])
        }
    }
    
    func toogleTask(task: Task) {
        let task = Task(id: task.id, title: task.title, isCompleted: !task.isCompleted)
        dbManager.updateTask(task)
    }
    
    func fetchTasks() {
        _ = dbManager.fetchTasks()
    }
}

The ViewModel facilitates database operations to be consumed by the view. It includes a tasks list, a published attribute that is directly linked to the @Published DBManager.tasks attribute.

Finally the resulting sample project looks like:

Conclusions

In this post, I present an alternative approach to handling databases with Swift Data, different from what Apple offers. Let me clarify: Apple creates excellent tools, but this framework does not fully meet my day-to-day requirements for local data persistence in a database.

You can find source code used for writing this post in following repository.

References

Copyright © 2024-2025 JaviOS. All rights reserved