MahalliMahalli Handbook

Architecture

Modular monolith on supastarter-nextjs — the real monorepo map, core technical decisions, multi-tenancy, custom domains, the outbox event bus, the module contract, security, and environments.

02 — Technical architecture

Principle: a modular monolith on the supastarter-nextjs foundation (Next.js 16 App Router, Node runtime), one central Postgres, and modules as packages rather than services. Microservices only later, if the need is proven (ADR-0001, ADR-0008). Cloudflare remains the edge layer (DNS, Cloudflare for SaaS, R2, Workers for the planned storefront and jobs); the earlier Cloudflare-first runtime decision (ADR-0002) is superseded.

1. Top-level systems

                        ┌──────────────────────────────────────────────────────┐
  Shop owner ─────────► │ apps/saas  (app.mahalli.sa)                          │
  (mobile / web)        │ Next.js 16 App Router · Node runtime · Better Auth   │
                        │ oRPC handlers from packages/api mounted as a route   │
                        └───────────────┬──────────────────────────────────────┘
                                        │ typed oRPC (TanStack Query on the client)
                        ┌───────────────▼──────────────────────────────────────┐
                        │ packages/api  (oRPC + Hono)                          │
                        │ procedures · webhooks · OAuth callbacks              │
                        └───┬──────────────────┬──────────────────┬───────────┘
                            │                  │                  │
                            ▼                  ▼                  ▼
                   ┌────────────────┐  ┌───────────────┐  ┌────────────────────┐
                   │ Postgres       │  │ R2 (S3 API)   │  │ outbox table       │
                   │ Prisma schema  │  │ avatars/logos │  │ → apps/jobs        │
                   │ Drizzle queries│  │ /media        │  │   (planned)        │
                   └────────────────┘  └───────────────┘  └─────────┬──────────┘
                                                                    │
  Shop customer ───────► apps/storefront (planned)                  │
  (merchant domain)      Cloudflare for SaaS · Host → tenant        │
                                                                    ▼
                        ┌──────────────────────────────────────────────────────┐
                        │ packages/integrations (planned): PSP · Tabby ·       │
                        │ Tamara · Google (GBP / Ads) · Snap · TikTok · Meta · │
                        │ ZATCA · Wathq · WhatsApp · SMS (@repo/sms)           │
                        └──────────────────────────────────────────────────────┘

2. Monorepo map (as it exists today)

apps/
├── saas/            # Merchant dashboard (authenticated product) — app.mahalli.sa
├── marketing/       # Public site: home, pricing, blog, legal
├── docs/            # Mahalli Handbook — Fumadocs rendering this docs/ folder
├── mail-preview/    # Email template preview
├── storefront/      # PLANNED — tenant public sites (Cloudflare Worker behind Cloudflare for SaaS)
└── jobs/            # PLANNED — outbox publisher, queue consumers, cron
packages/
├── api/             # oRPC procedures + Hono (webhooks, OAuth callbacks), mounted inside apps/saas
├── auth/            # Better Auth: phone OTP, email + password, magic link, Google, passkeys, 2FA, organizations
├── database/        # Prisma owns the schema and migrations; Drizzle runs the queries (Postgres only)
├── core/            # @repo/core — ModuleManifest, domain events, Sector Packs, TenantContext
├── i18n/            # next-intl config and translations (ar default, en)
├── mail/            # React Email templates and providers (RTL-aware wrapper)
├── notifications/   # In-app notifications catalog
├── payments/        # Plans and subscriptions in SAR; providers: stripe (reference), moyasar (skeleton)
├── permissions/     # Permix definitions and rule builder
├── sms/             # @repo/sms — OTP and notification SMS (console, unifonic, msegat)
├── storage/         # S3-compatible client (R2): buckets avatars, logos, media
├── ai/              # Vercel AI SDK with Anthropic (Claude) as the text model
├── ui/              # Base UI components + the glass design system
├── utils/           # Shared helpers
└── logs/            # Logger
modules/
└── registry/        # @repo/module-registry — the only source of what gets loaded
tooling/
├── tailwind/        # theme.css tokens and glass utilities
├── typescript/      # Shared tsconfig bases
└── scripts/

packages/integrations/* (one adapter per external platform, with per-tenant rate guards) is the planned home for outbound integrations; it does not exist yet.

3. Core technical decisions

AreaDecisionRejected alternative and why
Foundationsupastarter-nextjs adopted as upstream (upstream remote); demo content stripped; upstream merges remain possible (ADR-0008)Continue the bespoke Cloudflare-first seed: months of scaffolding that supastarter already provides (auth, billing, i18n, organizations).
RuntimeNode.js Next.js 16 App Router. Deployment target undecided: Vercel, Docker, or Cloudflare via OpenNext (Q12)Cloudflare Workers-only (ADR-0002): supastarter's packages assume a Node runtime; keeping the option open costs nothing now.
APIoRPC procedures in packages/api/modules/* (publicProcedure, protectedProcedure, adminProcedure), served through Hono inside the Next app; typed client with TanStack QueryA separate apps/api Worker: a second deployable and a second auth boundary for no gain at this stage.
DatabasePostgres. Prisma owns the schema and migrations (packages/database/prisma/schema.prisma); Drizzle implements queries. Managed provider and region are an open decision (Q5)D1: size and transaction limits are insufficient for payments and invoices. Hyperdrive is only relevant if the Cloudflare target is chosen.
IsolationorganizationId (the tenant) on every tenant-scoped table, a mandatory request context, RLS as the runtime backstopA database per tenant: unjustified operating cost now.
Filespackages/storage (S3 API) pointed at Cloudflare R2; image transformations via Cloudflare ImagesAnother object store: outside the existing account.
JobsAn outbox table written in the same transaction; a planned apps/jobs consumer (Cloudflare Queues / Durable Objects / Cron if on Cloudflare, or a Node worker)Redis / BullMQ: adds a stateful service before it is needed.
Heavy tasksContainers (ZATCA signing, PDF / post-image generation, FFmpeg)Everything inside the web runtime: CPU and bundle limits.
DomainsCloudflare for SaaS (Custom Hostnames) + a host → tenant cache, served by the planned apps/storefront (ADR-0004)Manual Nginx / Caddy: no automatic SSL at scale.
AuthBetter Auth in packages/auth: phone OTP (SMS via @repo/sms), email + password, magic link, Google, passkeys, 2FA. Organizations plugin with requireOrganization: true; the organization is the merchant. Nafath laterA hand-rolled OTP session system: reinvents what Better Auth ships and audits.
Paymentspackages/payments: plans starter 99, growth 249, pro 499 SAR / month (yearly at ×10), billing attached to the organization. PAYMENTS_PROVIDER selects stripe (reference implementation) or moyasar (skeleton until the recurring model is decided — Q13)LemonSqueezy / Polar / Creem / Dodo: removed; none serve the Saudi market.
AIpackages/ai on the Vercel AI SDK with @ai-sdk/anthropic; text model Claude Sonnet 5. Cloudflare AI Gateway (cache, limits, log) when the Cloudflare target is chosenLocal open models: lower Arabic quality today.
ObservabilitySentry + structured logs (packages/logs); Workers Logs / Analytics Engine if on Cloudflare—

4. Multi-tenancy

  • Every request carries a TenantContext (packages/core/src/domain/tenant-context.ts): tenantId (the active Better Auth organization), optional locationId, userId, role, and entitlements. It is built once in oRPC middleware and passed to every layer.
  • The Drizzle query layer rejects any query on a tenant-scoped table that lacks the tenant filter (lint at build time, RLS at runtime).
  • Per-tenant secrets (OAuth tokens, PSP sub-merchant keys) are envelope-encrypted with a per-tenant key and stored in Postgres, never in a KV store.
  • Permissions are Permix rules (packages/permissions) evaluated against the membership role in the active organization.

5. Custom domains (ADR-0004)

  1. The merchant adds example.com from the dashboard → we create a Custom Hostname in Cloudflare for SaaS and hand back the CNAME / TXT records for verification.
  2. On activation we write hostname → { tenantId, siteId } to the edge cache (KV); the source of truth is the domains table in Postgres.
  3. The planned apps/storefront reads Host, resolves the tenant from the cache and then Postgres, and renders the site from the Sector Pack templates and the catalog.
  4. app.mahalli.sa is never served by the storefront; the dashboard is fully isolated (cookies, CSP, separate origin).
  5. The <slug>.mahalli.sa wildcard is ready from day one with no merchant setup.

6. Events (event-driven inside the monolith)

  • Every domain write also writes an event to the outbox table inside the same transaction.
  • A publisher reads the outbox and pushes to the queue; consumers live in the planned apps/jobs and are declared in module manifests.
  • Names follow <aggregate>.<verb> (payment.captured, review.received, post.published); the list lives in packages/core/src/domain/events.ts.
  • Idempotency is mandatory in every consumer (key = eventId).

7. The module contract (ADR-0005)

packages/core/src/module-contract.ts defines ModuleManifest:

  • id, version, requires (modules or core capabilities), entitlements (what unlocks it),
  • routes (dashboard + API + storefront), settingsSchema (zod),
  • events.emits / events.consumes, jobs (cron / queue), webhooks,
  • copilotTools (Claude tools the module exposes), sectorHooks (what it adds to Sector Packs).

The module registry (modules/registry, package @repo/module-registry) is the only source of what gets loaded into the dashboard, the API, and the jobs runtime.

8. Security and privacy (summary — details in 05-compliance.md)

  • PDPL: classify PII fields, encrypt at rest, delete / export per customer on request.
  • Strict CSP on the dashboard, WAF rules per hostname, Turnstile on public forms.
  • An audit log for every administrative action and every AI call.

9. Environments and deployment

  • dev: docker compose up -d postgres + pnpm dev. preview: one per pull request. prod.
  • Deployment through GitHub Actions; no manual deploys. Production deploys only with the owner's explicit approval.
  • The production target (Vercel, Docker on a VPS, or Cloudflare via OpenNext) is open question Q12; the codebase must stay deployable to any of the three until it is decided.

On this page