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.