Files
recipe/.planning/research/ARCHITECTURE.md
2026-04-24 12:48:12 +02:00

17 KiB
Raw Blame History

Architecture Research

Domain: Offline-first meal-planning app (KMP + Ktor, household-shared) Researched: 2026-04-23 Confidence: HIGH (locked stack; standard patterns within it)

System Overview

┌──────────────────────────────────────────────────────────────────┐
│  composeApp/ (Android · iOS · Desktop · Wasm)                    │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │  UI: Compose screens + NavHost (Jetpack Nav CMP)          │   │
│  │  ViewModel (StateFlow) ──► Repository (reactive Flow)     │   │
│  │         │                         │                        │   │
│  │         ▼                         ▼                        │   │
│  │  SyncEngine (singleton) ◄──► SQLDelight (local) + Outbox  │   │
│  │         │                                                  │   │
│  │         │   AuthSession (AppAuth / ASWebAuth)              │   │
│  │         ▼                                                  │   │
│  │  Ktor Client (JWT bearer) ─────────────────┐               │   │
│  └────────────────────────────────────────────┼───────────────┘   │
└──────────────────────────────────────────────┼───────────────────┘
                                               │  HTTPS
              ┌────────────────────────────────┼───────────┐
              │  Authentik (OIDC IdP, homelab) │  JWKS     │
              └────────────────────────────────┴───────────┘
                                               │
┌──────────────────────────────────────────────▼───────────────────┐
│  server/ (Ktor 3.x, same homelab)                                │
│  Auth (ktor-server-auth-jwt) ──► Routes /api/v1/*                │
│             │                          │                          │
│             ▼                          ▼                          │
│  PrincipalResolver ──► Services ──► Exposed DSL ──► Postgres     │
│                                                    (Flyway)       │
└──────────────────────────────────────────────────────────────────┘

shared/commonMain: domain models + API DTOs (client + server both depend)

Component Responsibilities

Component Responsibility Typical Implementation
Screen (@Composable) Render state, forward intents. No I/O. PlannerScreen(state, onAddMeal); consumes collectAsStateWithLifecycle()
ViewModel Expose StateFlow; coordinate repo calls; zero Compose imports Extends ViewModel, scoped via koinViewModel(), method-per-action
Repository Single source of truth for one aggregate; hide local/remote split Exposes Flow<Domain> from SQLDelight; write path goes through local DB + outbox
SyncEngine Own outbox drain, pull loop, backoff, auth failure handling App-scoped Koin singleton; one CoroutineScope(SupervisorJob); started after auth
DataSource (local) Thin SQLDelight wrapper, mapping rows ↔ domain Per-table Queries injected; suspend + asFlow().mapToList()
DataSource (remote) Typed Ktor calls for /sync/push, /sync/pull, catalog endpoints HttpClient with Auth { bearer { ... } } + ContentNegotiation(Json)
AuthSession Own tokens, refresh, sign-in/out; expose StateFlow<AuthState> Platform-specific actual class (AppAuth / ASWebAuth) behind expect
Koin Module Wire graph per layer (appModule, dataModule, syncModule, authModule) Declared in commonMain; startKoin in App() + MainViewController
Ktor route HTTP surface; validate DTO; call service; never touch DB directly Route.planRoutes() under authenticate("auth-jwt") { route("/api/v1") { ... } }
Exposed table Schema definition + column types; DSL queries via transaction {} object PlanEntries : Table("plan_entries") — no DAO
Outbox Durable queue of unsynced local writes keyed by aggregate+id sync_outbox table in SQLDelight; (op, table, pk, payload_json, attempts)
composeApp/src/commonMain/kotlin/app/recipe/
├── app/                           # App() composable, root nav, Koin bootstrap
├── navigation/                    # @Serializable route classes + NavGraphBuilder extensions
├── ui/
│   ├── theme/                     # Color, typography, Haze style tokens
│   ├── components/                # Reusable (GlassCard, MealSlotChip, ...)
│   └── screens/
│       ├── recipes/               # RecipeListScreen, RecipeDetailScreen, *ViewModel
│       ├── planner/               # PlannerScreen, DayColumn, *ViewModel
│       ├── pantry/
│       └── shopping/
├── data/
│   ├── local/                     # SQLDelight driver factory (expect/actual), Queries wrappers
│   ├── remote/                    # HttpClient factory, DTOs mirroring shared/, auth interceptor
│   ├── sync/                      # SyncEngine, Outbox, pull scheduler, conflict policy
│   └── repository/                # PlanRepository, PantryRepository, CatalogRepository, ...
├── domain/                        # Value types, enums (MealSlot), pure computations (shortfall, aggregation)
├── auth/                          # AuthSession interface, token store, OIDC config
└── di/                            # appModule, dataModule, syncModule, authModule

server/src/main/kotlin/app/recipe/server/
├── Application.kt                 # embeddedServer, install plugins, call moduleMain()
├── plugins/                       # Auth, ContentNegotiation, CallLogging, StatusPages, CORS
├── auth/                          # JWKS config, PrincipalResolver (sub → user → household)
├── routes/
│   ├── sync/                      # push.kt, pull.kt
│   ├── catalog/                   # recipes, ingredients, products (read-mostly)
│   ├── households/                # memberships, invites
│   └── health/
├── services/                      # PlanService, SyncService — orchestrate transactions
├── db/
│   ├── tables/                    # Exposed Table objects (no DAO)
│   ├── Mappers.kt                 # ResultRow → shared DTO
│   └── Database.kt                # HikariCP + Flyway.migrate()
└── util/                          # Clock (injectable), IdGen, Json

server/src/main/resources/db/migration/     # V1__init.sql, V2__plan_entries.sql, ...
shared/src/commonMain/kotlin/app/recipe/shared/   # Domain + DTOs (@Serializable) — no I/O deps

Rationale: groups by UI concern then data layer, matching the locked decision in PROJECT.md. data/sync/ is a first-class folder because sync is the spine of the app. domain/ holds pure logic so it can be unit-tested without Android/iOS runtime. Server mirrors the client's layered split (routes → services → db) so reasoning transfers.

Architectural Patterns

Pattern 1: Repository → reactive Flow → StateFlow in ViewModel

Repositories expose Flow<Domain> built from SQLDelight's asFlow().mapToList(). The ViewModel lifts that into a cold-hot StateFlow using stateIn with WhileSubscribed(5_000). Writes go through the repo, which writes to SQLDelight; the reactive query re-emits automatically. Never pre-fetch state with a suspend call in init {} — that races with collection.

class PlannerViewModel(private val repo: PlanRepository) : ViewModel() {
  val state: StateFlow<PlannerState> = repo.observeWeek(currentWeek)
    .map(PlannerState::fromEntries)
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PlannerState.Loading)

  fun onAddMeal(day: LocalDate, slot: MealSlot, recipeId: Uuid) =
    viewModelScope.launch { repo.add(day, slot, recipeId) }
}

Pattern 2: Sync engine as a Koin singleton owning outbox + poll cycles

One long-lived SyncEngine bound in syncModule with a SupervisorJob-backed scope. It exposes pushNow(), pullNow(), status: StateFlow<SyncStatus>. Two loops: a push loop that drains sync_outbox with exponential backoff on 5xx/network errors, and a pull loop that calls GET /sync/pull?since={lastCursor} every 2030s while foregrounded. Repositories never talk to HTTP directly for household data — they enqueue outbox rows and trust the engine.

class SyncEngine(private val api: SyncApi, private val local: LocalDb, private val clock: Clock) {
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
  fun start() { scope.launch { pushLoop() }; scope.launch { pullLoop() } }
  suspend fun nudge() = pushSignal.emit(Unit)
}

Trade-off: single point of failure if the engine deadlocks, so all its work must be cancellable and idempotent (server-side push is keyed by client_op_id).

Pattern 3: Household-scope enforcement at three layers

Defence in depth: (a) Client query filter — every SQLDelight query for household-scoped tables includes WHERE household_id = :hh, sourced from AuthSession.activeHouseholdId; (b) Server principal resolver — a PrincipalResolver turns the JWT sub claim into (userId, householdId) via a cached lookup against memberships; routes receive an AuthPrincipal already carrying householdId; (c) DB row ownership — every household-scoped table has household_id uuid NOT NULL with an index, and every UPDATE/DELETE includes AND household_id = ?.

fun Route.planRoutes(svc: PlanService) = authenticate("auth-jwt") {
  post("/api/v1/sync/push") {
    val p = call.principal<AuthPrincipal>()!!         // householdId baked in
    val batch = call.receive<PushBatch>()
    call.respond(svc.applyBatch(p.householdId, batch))
  }
}

Never trust a householdId field inside a client payload — overwrite with the principal's.

Pattern 4: Catalog (read-mostly) vs Household (read-write, synced) split

Two cache + sync policies in one app. Catalog (recipes, ingredients, products) is pre-seeded server-side, pulled via versioned ETag (GET /api/v1/catalog?etag=...), cached in SQLDelight with a simple "replace all or diff by updated_at" refresh on app start + manual refresh. No outbox. Household (plan entries, pantry, shopping items) is LWW-synced with server-assigned updated_at, uses the outbox, and is reactively observed. Keep these in separate repositories and separate Koin modules so their refresh semantics don't leak into each other.

Data Flow — Hero Write Path (Add Meal to Plan)

User taps "add meal"
    │
    ▼
PlannerScreen invokes onAddMeal(day, slot, recipeId)
    │
    ▼
PlannerViewModel.onAddMeal → viewModelScope.launch { repo.add(...) }
    │
    ▼
PlanRepository.add():
  ├─ SQLDelight transaction:
  │     INSERT plan_entry (id=localUuid, household_id, day, slot, recipe_id,
  │                        updated_at=NULL /* server will stamp */, pending=1)
  │     INSERT sync_outbox (op='upsert', table='plan_entry', pk=id,
  │                         payload_json, client_op_id, attempts=0)
  └─ Flow<PlanEntries> re-emits → PlannerViewModel.state recomputes → UI updates
    │                              (optimistic; pending=1 may render a subtle marker)
    ▼
SyncEngine.nudge() — push loop wakes
    │
    ▼
Ktor Client POST /api/v1/sync/push  (Authorization: Bearer <jwt>)
    │
    ▼
Ktor Server: install(Authentication) { jwt("auth-jwt") { verifier(jwkProvider) } }
    │  JWT validated against Authentik JWKS (cached, rotating)
    ▼
PrincipalResolver: sub → userId → householdId (cached)
    │
    ▼
sync/push.kt → SyncService.applyBatch(householdId, batch)
    │
    ▼
Exposed transaction {
    PlanEntries.upsert { it[id]=...; it[householdId]=...; it[updatedAt]=Clock.now() }
    // server clock is authoritative
}
    │
    ▼
Response { applied: [{ id, client_op_id, updated_at: <server ts> }] }
    │
    ▼
Client: local tx {
  UPDATE plan_entry SET updated_at = <server ts>, pending = 0 WHERE id = ?
  DELETE FROM sync_outbox WHERE client_op_id = ?
}
    │
    ▼
Flow re-emits → pending marker vanishes
    │
    ~~~  (later) partner's device  ~~~
    ▼
Pull loop: GET /api/v1/sync/pull?since=<lastCursor>
    │
    ▼
Server returns rows with updated_at > since, scoped to householdId
    │
    ▼
Client upserts rows in a single SQLDelight tx; advances cursor
    │
    ▼
Partner's PlannerViewModel StateFlow emits new state → their UI updates

Anti-Patterns

Anti-Pattern 1: Suspend fetch in init {} feeding a MutableStateFlow

// WRONG
init { viewModelScope.launch { _state.value = repo.getOnce() } }

Races with UI collection; loses SQLDelight's reactive updates; forces manual refresh after every write. Instead: build the StateFlow declaratively from repo.observeX().stateIn(...).

Anti-Pattern 2: Using Exposed DAO (active record) for new tables

Exposed's DAO API (IntEntity, EntityClass) looks convenient but leaks lazy-loading through transactions and fights JSONB/composite types. PROJECT.md already forbids it. Instead: use the DSL (Table objects + transaction { Table.select { ... } } + explicit ResultRow → DTO mappers). Predictable SQL, no session/transaction surprises.

Anti-Pattern 3: Sharing SQLDelight transactions across coroutine contexts on iOS

SQLDelight's iOS driver (native-sqlite) uses thread-confined connections. Launching nested withContext(Dispatchers.IO) inside a transaction { } can throw IllegalStateException or silently serialize incorrectly. Instead: keep the entire transaction inside one coroutine, use SQLDelight's transactionWithResult { }, and do network/CPU work outside the tx. On iOS, the driver's own dispatcher handles threading.

Anti-Pattern 4: Using device clock for updated_at

Phones have drifting clocks and timezone shenanigans; a device whose clock is 10 minutes fast will always "win" LWW. Instead: server stamps updated_at inside the push transaction (Clock.System.now() on the server, or now() in SQL). The client only stores what the server returns. Local-only edits carry pending=1 until acknowledged.

Anti-Pattern 5: Putting UI, HTTP, or DB types in shared/commonMain

PROJECT.md scopes shared/ to domain models + DTOs. Dragging Ktor or SQLDelight into shared/ pulls platform-specific deps into the server build graph and vice versa. Instead: client-only concerns live in composeApp/, server-only in server/, and shared/ stays a pure-Kotlin library with kotlinx.serialization + kotlinx.datetime as its only non-stdlib deps.

Build Order Implication

The layer that must exist first is auth + a working Ktor skeleton that echoes an authenticated principal, because every subsequent layer depends on having a real householdId to scope against. After that the unblock order is: (1) sync engine foundation — outbox table, empty push/pull endpoints, cursor persistence — so every feature slots into an already-synced path instead of being retrofitted; (2) catalog read path — lets the UI render recipes without any write-path complexity, proving HTTP + SQLDelight + Coil end-to-end on a trivial aggregate; (3) household write path — the planner as the first real outbox-backed aggregate, which flushes out LWW edge cases; (4) UI chrome — Haze-backed glass, navigation polish, theming — last, because decorating a working app is cheap while architecting around decoration is expensive. Skipping step 1 or 2 and jumping to the planner looks faster for a week and costs a month.

Sources

  • /Users/rwilk/dev/repo/recipe/.planning/PROJECT.md (authoritative stack + constraints)
  • Training knowledge: Compose Multiplatform 1.7+, Jetpack Nav CMP port 2.9.x, SQLDelight 2.x coroutine extensions, Ktor 3.x auth-jwt + JWKS, Exposed DSL transaction semantics, Authentik OIDC discovery
  • No web searches needed — patterns are standard within the locked stack

Architecture research for: KMP + Ktor household meal planner Researched: 2026-04-23