Architecture
Client-side MVVM, Firestore as single source of truth, Cloud Functions for all server-side logic.
Layers
The app is split into three layers with clear responsibilities:
- iOS client (SwiftUI, MVVM) — presentation and user input. Writes to Firestore, never talks directly to Gemini, Algolia or notification services.
- Firestore — single store for application state. Every piece of UI is a listener on a query: no polling, no manual cache to invalidate.
- Cloud Functions Gen 2 — anything that requires secrets, trust or orchestration: AI moderation, push notifications, leaderboard scoring, prompt generation, Algolia sync.
This separation makes it possible to swap the UI or add platforms (e.g. a web version) without touching domain rules, which live in the functions.
MVVM in practice
MVVM = Model, View, ViewModel. It’s the idiomatic pattern for SwiftUI: the View observes ViewModel state, the ViewModel talks to the Model, and none of the three has a bidirectional dependency on the others.
- View — declarative SwiftUI, no direct Firestore access. Reads
@Publishedproperties from the ViewModel and calls its methods. - ViewModel —
ObservableObjectthat owns Firestore listeners, holds typed state and exposes it via@Published. Every feed (global, following, profile, drafts, private, liked) has its own cursor pagination. - Model —
Codablestructs with two initializers: one from a FirestoreDocumentSnapshot, one from Algolia JSON. The same type flows through both sources.
Client writes are limited to “intent” operations (publish, like, follow). Everything derived from state (scores, follower feeds, search indexing) is propagated by the functions.
Data model
Main collections:
users/{uid}— profile, premium flag, following list, FCM device tokens.poems/{poemId}— published poems (post-moderation).pendingPoems/{poemId}— pre-moderation staging; clients write here, not directly topoems.poems/{poemId}/comments/{commentId}— comments subcollection.dailyPrompts/{YYYY-MM-DD}— historical daily prompts;dailyPrompts/currentis the pointer read by the UI.leaderboards/{YYYY-MM}/entries/{uid}— monthly scores per author, keyed by month.reports/{reportId}— user reports, consumed by moderators.
Firestore rules close writes on anything derived (poems, leaderboards, dailyPrompts) — only the functions service account can modify them.
Realtime without polling
Every ViewModel opens a addSnapshotListener on the query it needs and keeps it alive while the view is on screen. When a function writes (e.g. approves a poem, updates a score), the UI updates on its own. In practice: no mandatory pull-to-refresh, no race between clients that think they’re in different states.
Listeners are explicitly closed in deinit or when the screen is dismissed — an orphan listener is a read cost you pay forever.
Search
Algolia is the secondary index. syncAlgoliaUserRecord and related functions keep users and poems in sync; the client queries Algolia only for full-text search and falls back to Firestore for details.
Environments
Production and staging are two distinct Firebase projects with identical code. The client uses a dedicated Xcode scheme with a separate Bundle ID, so the two builds coexist on the same device without notification or deep-link conflicts.