iTranslated by AI
SwiftUI + Firebase Architecture: Implementing @Observable with Firestore Real-time Listeners
Introduction
I am developing a personal iOS app called "Kaetao" (Towel Changer) that manages the replacement timing of towels.
The backend uses Firebase (Auth / Firestore / Storage), and the UI is built with SwiftUI. For state management, I use the Observation framework (@Observable), which was introduced in iOS 17.
While the combination of SwiftUI + Firebase is common, I feel there are still few practical examples of @Observable + Firestore real-time listeners, so I would like to introduce the architecture of this app.
Overall Architecture
┌──────────────────────────────────────────────────┐
│ Views (SwiftUI struct) │
│ ContentView → TabView │
│ ├─ TowelListView │
│ │ └─ TowelDetailView │
│ └─ SettingsView │
├──────────────────────────────────────────────────┤
│ ViewModels (@Observable final class) │
│ TowelListViewModel ← TowelListView │
│ TowelDetailViewModel ← TowelDetailView │
├──────────────────────────────────────────────────┤
│ Services (Singleton, @Observable @MainActor) │
│ AuthService.shared — Firebase Auth │
│ FirestoreService.shared — Firestore CRUD │
│ StorageService.shared — Firebase Storage │
│ GroupService.shared — Group Management │
├──────────────────────────────────────────────────┤
│ Models (Codable struct) │
│ Towel, ExchangeRecord, ConditionCheck │
└──────────────────────────────────────────────────┘
The dependency direction is unidirectional: View → ViewModel → Service → Firestore. Although there are cases where the View references the Service directly, write operations are always performed through the Service.
Why @Observable?
Before iOS 17, the combination of ObservableObject + @Published + @StateObject / @ObservedObject was the standard. There are three reasons why I use @Observable.
1. Property-level update tracking
ObservableObject notifies of changes to the entire object via objectWillChange. Since @Observable tracks changes at the property level, Views are not re-rendered due to changes in unrelated properties.
2. Reduction of boilerplate
You no longer need to attach @Published to every property, and the distinction between @StateObject and @ObservedObject is unnecessary. On the View side, you just receive it with @State.
3. Keeping Services simple
Simply attaching @Observable to a Singleton Service class allows the UI to update automatically when a property referenced from a View changes. There is no need to write Combine's sink or assign.
Model Layer: Codable struct
struct Towel: Codable, Identifiable, Hashable {
@DocumentID var id: String?
var name: String = ""
var location: String = ""
var iconName: String = "hand.raised.fill"
var exchangeIntervalDays: Int = 3
@ServerTimestamp var createdAt: Date?
@ServerTimestamp var updatedAt: Date?
var lastExchangedAt: Date?
// Not saved in Firestore — fetched from subcollections
var records: [ExchangeRecord] = []
var conditionChecks: [ConditionCheck] = []
enum CodingKeys: String, CodingKey {
case id, name, location, iconName, exchangeIntervalDays
case createdAt, updatedAt, lastExchangedAt
}
}
There are three main points:
1. @DocumentID and @ServerTimestamp
These are property wrappers provided by FirebaseFirestore that work with Codable to automatically map document IDs and server-side timestamps.
2. Exclude subcollection data from CodingKeys
records and conditionChecks are data fetched from Firestore subcollections. By not including them in CodingKeys, they are ignored during the decoding of the parent document.
3. Express business logic with computed properties
var status: TowelStatus {
let remaining = Calendar.current.dateComponents(
[.day], from: .now, to: nextExchangeDate
).day ?? 0
if remaining < 0 {
return .overdue
} else if remaining <= 1 {
return .soon
} else {
return .ok
}
}
Pure logic like status calculation is placed in the Model. This is testable code that does not depend on ViewModels or Services.
Service Layer: @Observable Singleton
The core of the app is FirestoreService. As an @Observable Singleton, it handles Firestore real-time listeners and data persistence.
@Observable
@MainActor
final class FirestoreService {
static let shared = FirestoreService()
var towels: [Towel] = []
var isLoading = true
var errorMessage: String?
private let db = Firestore.firestore()
private var towelListener: ListenerRegistration?
private var recordListeners: [String: ListenerRegistration] = [:]
private var conditionCheckListeners: [String: ListenerRegistration] = [:]
private init() {}
}
Automatic synchronization via real-time listeners
I use addSnapshotListener to subscribe to changes in Firestore. When data is updated, the towels property is rewritten, and thanks to @Observable tracking, all Views referencing it are automatically re-rendered.
func startListening() {
guard let collection = towelsCollection() else { return }
stopListening()
isLoading = true
towelListener = collection
.order(by: "createdAt", descending: true)
.addSnapshotListener { [weak self] snapshot, error in
guard let self else { return }
guard let documents = snapshot?.documents else {
self.towels = []
self.isLoading = false
return
}
var newTowels = documents.compactMap { doc -> Towel? in
try? doc.data(as: Towel.self)
}
// Carry over existing subcollection data
for i in newTowels.indices {
if let existing = self.towels.first(where: { $0.id == newTowels[i].id }) {
newTowels[i].records = existing.records
newTowels[i].conditionChecks = existing.conditionChecks
}
}
self.towels = newTowels
self.isLoading = false
}
}
What is important here is carrying over subcollection data. While Towel is regenerated every time the parent document listener triggers, subcollection data (exchange records, condition checks) is not included in the parent's snapshot. By copying existing data to the new array, I maintain consistency with the subcollection listeners.
Lazy initialization of subcollection listeners
Subcollection (exchange records, condition checks) listeners are unnecessary when displaying the towel list. They are started only when the detail view is opened and stopped when it is closed.
// When opening the detail view
func startSubcollectionListeners(towelId: String) {
subscribeToRecords(towelId: towelId)
subscribeToConditionChecks(towelId: towelId)
}
// When closing the detail view
func stopSubcollectionListeners(towelId: String) {
recordListeners[towelId]?.remove()
recordListeners.removeValue(forKey: towelId)
conditionCheckListeners[towelId]?.remove()
conditionCheckListeners.removeValue(forKey: towelId)
}
The results of subcollection listeners are written directly to the corresponding Towel in the towels array.
private func subscribeToRecords(towelId: String, limit: Int? = subcollectionPageSize) {
guard recordListeners[towelId] == nil,
let collection = towelsCollection() else { return }
var query: Query = collection.document(towelId).collection("records")
.order(by: "exchangedAt", descending: true)
if let limit {
query = query.limit(to: limit)
}
let listener = query
.addSnapshotListener { [weak self] snapshot, _ in
guard let self, let documents = snapshot?.documents else { return }
let records = documents.compactMap { try? $0.data(as: ExchangeRecord.self) }
if let index = self.towels.firstIndex(where: { $0.id == towelId }) {
self.towels[index].records = records
}
}
recordListeners[towelId] = listener
}
Simply writing towels[index].records = records allows @Observable to detect the property change, and only the Views referencing those records are re-rendered.
Listener lifecycle management
The timing for starting and stopping listeners must be clearly managed.
App Launch
↓
ContentView's .task
↓
GroupService.loadGroupForCurrentUser() ← Get Group ID
↓
FirestoreService.startListening() ← Start towel list listener
↓
[User opens towel detail screen]
↓
startSubcollectionListeners() ← Start exchange/condition listener
↓
[User closes detail screen]
↓
stopSubcollectionListeners() ← Stop exchange/condition listener
↓
[Sign out]
↓
FirestoreService.stopListening() ← Stop all listeners
I also automatically clean up listeners for deleted towels.
// Inside parent listener callback
let currentIds = Set(newTowels.compactMap(\.id))
for key in self.recordListeners.keys where !currentIds.contains(key) {
self.recordListeners[key]?.remove()
self.recordListeners.removeValue(forKey: key)
}
ViewModel Layer: Separating Logic
The ViewModel's role is to separate View logic. It directly references the Service's towels array and performs filtering or sorting.
@Observable
final class TowelListViewModel {
var searchText = ""
var errorMessage: String?
func filteredTowels(_ towels: [Towel]) -> [Towel] {
guard !searchText.isEmpty else { return towels }
return towels.filter { towel in
towel.name.localizedCaseInsensitiveContains(searchText) ||
towel.location.localizedCaseInsensitiveContains(searchText)
}
}
func sortedByStatus(_ towels: [Towel]) -> [Towel] {
towels.sorted { lhs, rhs in
let lhsOrder = statusOrder(lhs.status)
let rhsOrder = statusOrder(rhs.status)
if lhsOrder != rhsOrder {
return lhsOrder < rhsOrder
}
return lhs.daysSinceLastExchange > rhs.daysSinceLastExchange
}
}
}
Although the ViewModel is @Observable, it is not a Singleton. An instance is created for each View. The Service is responsible for data storage, while the ViewModel focuses on data processing.
View Layer: Direct Service Reference
Referencing @Observable properties of the Service directly from the View is a key feature of this architecture.
struct ContentView: View {
@State private var firestoreService = FirestoreService.shared
var body: some View {
TabView {
NavigationStack {
TowelListView()
}
// ...
}
.task {
await GroupService.shared.loadGroupForCurrentUser()
firestoreService.startListening()
}
.onChange(of: firestoreService.towels) { _, towels in
NotificationService.shared.rescheduleAllNotifications(for: towels)
}
}
}
I use @State private var firestoreService = FirestoreService.shared to hold a reference to the Singleton and use onChange to detect changes to firestoreService.towels and reschedule notifications.
Detail view ViewModel collaboration
In the detail view, the ViewModel manages the subcollection listener lifecycle.
@Observable
@MainActor
final class TowelDetailViewModel {
let towelId: String
var towel: Towel? {
FirestoreService.shared.towels.first(where: { $0.id == towelId })
}
func startListening() {
FirestoreService.shared.startSubcollectionListeners(towelId: towelId)
}
func stopListening() {
FirestoreService.shared.stopSubcollectionListeners(towelId: towelId)
}
}
The point is that towel is a computed property. Since it is re-evaluated whenever FirestoreService.shared.towels changes, real-time listener updates are automatically propagated from the ViewModel to the View.
Dual Path: Automatic Solo/Group Switching
This app has a family group sharing feature. When joining a group, the storage location for towel data switches from /users/{uid}/towels to /groups/{groupId}/towels.
private func towelsCollection() -> CollectionReference? {
guard let userId else { return nil }
if let groupId = GroupService.shared.groupId {
return db.collection("groups").document(groupId).collection("towels")
}
return db.collection("users").document(userId).collection("towels")
}
Because all CRUD operations and listeners go through this towelsCollection(), callers do not need to be aware of solo/group status. Path switching is centralized in one location.
Pros and Cons of this Architecture
Pros
-
Less code — No need for Combine's
sink/assign. No@Publishedrequired. - Reactive — Firestore changes are automatically reflected in the UI. No manual refresh needed.
-
Easier to debug — Data is consolidated in
FirestoreService.shared.towels. You can see the whole picture in one place. - No SwiftData needed — You can use Firestore as the Single Source of Truth without local persistence.
Cons
-
Forgetting to stop listeners — Failing to call
stopListening()causes memory leaks. Ensure all listeners are stopped upon sign-out. - Tight coupling of Singleton — Testability is sacrificed. I allow this for a personal project, but in team development, it should be abstracted with Protocols.
- Subcollection carry-over — If you forget to copy subcollection data when the parent listener fires, the display on the detail screen will vanish.
Summary
- The combination of
@Observable+ FirestoreaddSnapshotListenermakes reactive UIs simple to implement. - By making the Service an
@ObservableSingleton, data can be accessed from any View. - Lazy-start subcollection listeners (only when opening the detail screen) to keep costs low.
-
Dual path (solo/group) is consolidated into a single
towelsCollection()method, eliminating complexity for callers. - For the scale of a personal app, this configuration is sufficiently scalable.
I hope this is helpful to anyone struggling with the architecture of a SwiftUI app using Firebase.
The app "Kaetao" is available on the App Store.
Discussion