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 @Published properties from the ViewModel and calls its methods.
  • ViewModelObservableObject that owns Firestore listeners, holds typed state and exposes it via @Published. Every feed (global, following, profile, drafts, private, liked) has its own cursor pagination.
  • ModelCodable structs with two initializers: one from a Firestore DocumentSnapshot, 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 to poems.
  • poems/{poemId}/comments/{commentId} — comments subcollection.
  • dailyPrompts/{YYYY-MM-DD} — historical daily prompts; dailyPrompts/current is 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.

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.