Syncing SwiftData with Firebase in Swift 6
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/awaitfor 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.