Architecture

System shape, request flow, and the role of every box.

System diagram

                            ┌────────────────────────┐
                            │  Web (Next.js 15)      │
                            │  apps/web              │
                            │  React 19 + Tailwind   │
                            │  hosts: Vercel / Coolify│
                            └────────┬───────────────┘
                                     │ fetch + Hono `hc` (server-only)
                                     │ httpOnly cookie session
                                     ▼
       ┌──────────────────┐ ┌───────────────────────────┐ ┌──────────────────┐
       │ Flutter mobile   │ │  API (Hono 4)             │ │ Workers          │
       │ Dio + Freezed    ├─►  apps/api                 ◄─┤ apps/workers     │
       │ JWT bearer       │ │  @hono/zod-openapi        │ │ BullMQ           │
       │ swagger_parser   │ │  better-auth              │ │ outbox drain,    │
       │ codegen          │ │  Scalar /docs             │ │ cron, eBarimt,   │
       └──────────────────┘ │  hosts: Hetzner / Fly     │ │ SMS, statements  │
                            └─────┬─────────────────┬───┘ └──────┬──────────┘
                                  │                 │            │
                                  ▼                 ▼            ▼
                       ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐
                       │ Postgres 16     │ │ Redis 7          │ │ Object store │
                       │ Drizzle + RLS   │ │ BullMQ + OTP     │ │ R2 / MinIO   │
                       │ outbox + audit  │ │ + rate limits    │ │ photos, PDFs │
                       └─────────────────┘ └──────────────────┘ └──────────────┘
                                  ▲
                                  │ CDC (Debezium) ← optional, see Strangler
                                  │
                       ┌─────────────────────────┐
                       │ SQL Server (Spacehub23) │
                       │ XAF Blazor + XPO        │
                       │ runs until last domain  │
                       │ is ported               │
                       └─────────────────────────┘

External integrations (TS clients, all in apps/api or packages/integrations):
  • eBarimt 3.0 REST       • QPay (Mongolia QR)    • SocialPay
  • Khan / Golomt / TDB    • SMS gateway 131344    • Firebase Admin (FCM)
  • Gemini 2.5 (Vercel AI SDK)
      

Request lifecycle (web user editing a contract)

  1. Browser → Next.js Server Component User hits /contracts/123/edit. Server Component runs on the API host or Vercel edge.
  2. Server Component → Hono API (typed via hc) Cookie carries session id. Server Component calls api.contracts[':id'].$get({ param: { id: '123' } }).
  3. better-auth middleware Validates session cookie, loads user + org (PropertyOwner) into c.var.session.
  4. RLS middleware Opens transaction, runs set_config('app.user_id', ...), set_config('app.owner_id', ...), set_config('app.role', ...). Stores tx in context.
  5. Route handler Zod validates query/body. Drizzle query on the transaction. RLS policies filter to current owner.
  6. Response Zod schema serializes. Transaction commits, GUCs reset automatically.
  7. User submits edit POST → same path. Mutation in transaction. If the mutation triggers a side effect (e.g. eBarimt push), insert into outbox in the same transaction. BullMQ worker drains it independently.

Mobile request lifecycle

  1. Flutter app stores session token + JWT in flutter_secure_storage.
  2. Dio sends Authorization: Bearer <session-token> on every request.
  3. On 401, single-flight refresh via POST /auth/token, retry original request.
  4. API treats web cookie session and mobile bearer session identically (both flow through better-auth handler).
  5. Push token registration on login: POST /v2/devices { fcm_token, platform: "android" } stores in Postgres for FCM dispatch.

Hard constraints (non-negotiable)

Conventions

Naming

Errors

Consistent envelope:

{
  "error": {
    "code": "VALIDATION_ERROR" | "NOT_FOUND" | "FORBIDDEN" | "CONFLICT" | "INTERNAL_ERROR",
    "message": "human-readable, Mongolian or English per Accept-Language",
    "details": { ...optional, field-level }
  }
}

Pagination (mobile-friendly)

Cursor-based on every list endpoint. Never offset.

GET /v2/contracts?limit=50&cursor=eyJpZCI6Ii4uLiJ9
→ { items: [...], nextCursor: "eyJpZCI6Ii4uLiJ9" | null }

Idempotency

POSTs that create money-affecting state accept Idempotency-Key header. Stored in idempotency_keys table with (key, route) unique. Same key + same body = same response.