Syncing SwiftData with Firebase in Swift 6

Stephen Dixon
· 3 min read
Send by email

A Complete Guide for Swift Concurrency and Clean Architecture

Synchronizing local and remote data sources is one of the trickiest parts of app development — especially when you want the result to be clean, reliable, and concurrency-safe.

In this guide, I’ll show you how to combine SwiftData for local persistence, Firebase for cloud syncing, and a DTO (Data Transfer Object) pattern to keep your architecture decoupled and maintainable — all powered by Swift Concurrency.


What You’ll Learn

  • How to set up SwiftData for local storage
  • How to configure Firebase for remote data
  • How to sync between the two using DTOs
  • How to use async/await for concurrency-safe networking and persistence
  • How to structure your repository and view model layers cleanly

Why Use Swift Concurrency and DTOs?

  • Swift Concurrency helps you write asynchronous code that’s clear, readable, and free from callback hell.
  • DTOs let you isolate your Firebase schema from your SwiftData models, so your local and remote data layers stay decoupled.
  • The result? A safer, cleaner, and more future-proof architecture.

Step 1: Define Your Local Model

Let’s start by defining a simple model called Book. This is the SwiftData version — the model you’ll use throughout your app.

import SwiftData

@Observable
struct Book {
    var id: String
    var title: String
    var author: String
    var genre: String
}

Now create a lightweight persistence layer to handle local saves and fetches.

import SwiftData

final class BookLocalDataStore {
    private let storage = PersistentStorage(name: "LibraryData")
    
    init() {
        storage.load { error in
            if let error = error {
                print("Error loading storage: \(error.localizedDescription)")
            }
        }
    }
    
    func saveBook(_ book: Book) async throws {
        try await storage.save(book)
    }
    
    func fetchBooks() async throws -> [Book] {
        return try await storage.fetchAll(Book.self)
    }
}

Step 2: Set Up Firebase with a DTO

Your Firebase model doesn’t have to match your app’s internal model exactly. That’s why we use a Data Transfer Object(BookDTO) to map between them.

import Foundation

struct BookDTO: Codable {
    var id: String
    var title: String
    var author: String
    var genre: String
    
    func toBook() -> Book {
        Book(id: id, title: title, author: author, genre: genre)
    }
    
    init(from book: Book) {
        self.id = book.id
        self.title = book.title
        self.author = book.author
        self.genre = book.genre
    }
}

By keeping the DTO separate, your app can evolve without tightly coupling to your backend structure.


Step 3: Sync Data Between Firebase and SwiftData

Let’s build a BookRepository to coordinate between the local and remote stores.

import FirebaseFirestore
import SwiftData

final class BookRepository {
    private let localDataStore = BookLocalDataStore()
    private let db = Firestore.firestore().collection("books")
    
    // Sync from Firebase to SwiftData
    func syncBooks() async throws {
        let snapshot = try await db.getDocuments()
        let books = snapshot.documents.compactMap { document -> BookDTO? in
            try? document.data(as: BookDTO.self)
        }
        
        for bookDTO in books {
            let book = bookDTO.toBook()
            try await localDataStore.saveBook(book)
        }
    }
    
    // Save new book to both Firebase and SwiftData
    func addBook(_ book: Book) async throws {
        let bookDTO = BookDTO(from: book)
        try await db.document(book.id).setData(from: bookDTO)
        try await localDataStore.saveBook(book)
    }
    
    // Fetch books from local storage
    func fetchBooks() async throws -> [Book] {
        try await localDataStore.fetchBooks()
    }
}

This repository acts as the source of truth for your app — coordinating between the two storage layers cleanly.


Step 4: Use the Repository in Your ViewModel

Now you can use your repository inside an observable view model that integrates nicely with SwiftUI.

@Observable
final class BookViewModel {
    private let repository = BookRepository()
    var books: [Book] = []
    
    init() {
        Task {
            await loadBooks()
        }
    }
    
    func loadBooks() async {
        do {
            books = try await repository.fetchBooks()
        } catch {
            print("Error loading books: \(error)")
        }
    }
    
    func addBook(title: String, author: String, genre: String) async {
        let newBook = Book(id: UUID().uuidString, title: title, author: author, genre: genre)
        do {
            try await repository.addBook(newBook)
            books.append(newBook)
        } catch {
            print("Error adding book: \(error)")
        }
    }
}

Your UI now has access to a single source of truth — and you can trust your data layer to handle the rest.


Handling Errors and Edge Cases

This implementation is intentionally lightweight, but in production you’ll want to improve error handling:

  • Catch specific Firebase errors (e.g. permission issues, network failures)
  • Display user-facing messages when sync fails
  • Consider retry strategies or offline caching

You may also want to sync deletes or updates in both directions — that’s easy to add once this pattern is in place.


Wrapping Up

This guide walked through building a resilient, modern data layer with:

  • SwiftData for local persistence
  • Firebase Firestore for cloud sync
  • DTOs for decoupling app and backend models
  • Swift Concurrency for async-safe flow throughout

You now have a foundation for syncing structured data across local and remote stores in a clean, testable, and scalable way.


This post explored how to sync local and cloud data using Swift Concurrency and clean architecture patterns.

I’ll be sharing more real-world examples like this from my work on AteIQ and other AI-native apps.

If you’re exploring this space too, I’d genuinely love to connect or you can drop me a line.

And if you’re just getting started, I hope this blog becomes a place you can revisit and grow alongside.

Until next time — keep your data flowing.