docs: architecture research
This commit is contained in:
259
.planning/research/ARCHITECTURE.md
Normal file
259
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# 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)` |
|
||||||
|
|
||||||
|
## Recommended Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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 20–30s while foregrounded. Repositories never talk to HTTP directly for household data — they enqueue outbox rows and trust the engine.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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 = ?`.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 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*
|
||||||
Reference in New Issue
Block a user