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)
- Browser → Next.js Server Component
User hits
/contracts/123/edit. Server Component runs on the API host or Vercel edge. - Server Component → Hono API (typed via
hc) Cookie carries session id. Server Component callsapi.contracts[':id'].$get({ param: { id: '123' } }). - better-auth middleware
Validates session cookie, loads user + org (PropertyOwner) into
c.var.session. - RLS middleware
Opens transaction, runs
set_config('app.user_id', ...),set_config('app.owner_id', ...),set_config('app.role', ...). Storestxin context. - Route handler Zod validates query/body. Drizzle query on the transaction. RLS policies filter to current owner.
- Response Zod schema serializes. Transaction commits, GUCs reset automatically.
- User submits edit
POST → same path. Mutation in transaction. If the mutation triggers a side effect (e.g. eBarimt push), insert into
outboxin the same transaction. BullMQ worker drains it independently.
Mobile request lifecycle
- Flutter app stores session token + JWT in
flutter_secure_storage. - Dio sends
Authorization: Bearer <session-token>on every request. - On 401, single-flight refresh via
POST /auth/token, retry original request. - API treats web cookie session and mobile bearer session identically (both flow through better-auth handler).
- Push token registration on login:
POST /v2/devices { fcm_token, platform: "android" }stores in Postgres for FCM dispatch.
Hard constraints (non-negotiable)
- RLS via
SET LOCALin a transaction. Never plainSET— it leaks across pooled connections. See auth/RLS page. - Money as
BigIntminor units (mungo for MNT). No floats, no==. Usecompare()/isFullyPaid()from@spacehub/shared/money. - Side effects go through outbox. No fire-and-forget HTTP from inside a request handler. Insert outbox row in the same tx → worker drains.
- Single status enum per entity. If you need state transitions, write a
transitionTo(invoice, newStatus)function with an allowed-transition table. - Single API for web + mobile. Don't put domain logic in Next.js Server Actions. Server Actions can wrap the API call but the real implementation lives in
apps/api. - UUIDs as primary keys (UUID v7 for sort locality). No composite keys, no
BaseObjdance.
Conventions
Naming
- Tables:
snake_case, plural (contracts,property_owners). - TS types:
PascalCasesingular (Contract,PropertyOwner). - Routes:
/v2/contracts,/v2/property-owners/{id}/properties. Always/v2prefix; never header-versioned. - Files: kebab-case. Schemas one-per-file:
contracts.ts,contract-items.ts.
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.