BookFlow — System Architecture

Multi-tenant booking & approvals platform. Monorepo with two deployables plus a worker, backed by one PostgreSQL primary. Tier: small (<1k concurrent) — no Redis/replicas; the SSE channel and outbox worker are correctness/availability requirements, not scaling ones.

Synchronous REST SSE stream (live) Async / outbox External callback Tenant trust boundary
Browser — Next.js 16 web app App Router · TanStack Query · Tailwind · design-token UI kit marketing landing + admin console (15 screens) REST /v1 · Bearer JWT SSE /v1/stream tenant trust boundary · org isolation NestJS 11 API REST · /v1 versioned · RFC 7807 errors request pipeline → Throttler JwtAuthGuard RolesGuard TenantGuard feature modules auth orgs members resources bookings approvals workflows notifications webhooks audit reports sse health cross-cutting • TenantContext (CLS) + ScopedRepository — row-level org_id scoping (INV-1 / INV-14) • RFC 7807 problem+json filter (INV-7) • Audit interceptor → append-only audit_log (INV-8) • Throttler + Helmet + structured JSON logging (pino) • bcrypt · JWT access/refresh TypeORM enqueue in same tx PostgreSQL 16 extension: btree_gist · daily backup + PITR bookings — EXCLUDE USING gist (tstzrange &&) → no double-booking audit_log — append-only, 7-year retention outbox — pending → sent · retry/backoff → dead (DLQ) webhook_events — idempotency ledger (unique svix-id) organizations · users · memberships resources · approvals · workflow_rules · refresh_tokens Outbox Worker separate process Drains the outbox; the only component that sends. Polls FOR UPDATE SKIP LOCKED (multi-instance safe). Exponential backoff → DLQ after 5 attempts. drain Resend Email send API · Svix-signed delivery webhooks (stub transport when no key) send email inbound webhook (Svix) → verify sig → idempotency → process (INV-3/4)

Booking write path (the core flow)

  1. POST /v1/bookings → Throttler → JwtAuthGuard → RolesGuard → TenantGuard (binds org_id into CLS).
  2. One DB transaction: insert the booking. The EXCLUDE constraint atomically rejects overlaps → mapped to 409 booking_conflict.
  3. Workflow rules evaluate → status confirmed or pending_approval (+ approval steps).
  4. In the same tx, a notification row is written to outbox (dual-write solved).
  5. Commit → SSE publishes booking.created; the audit interceptor records the change.
  6. The worker later drains the outbox and sends via Resend (retry → DLQ on failure).

Why this shape

  • No double-booking is a database guarantee (exclusion constraint), not an app check — race-proof across every code path.
  • Tenant isolation is enforced once, in ScopedRepository; cross-tenant reads return NOT_FOUND, never leak existence.
  • Dual-write (email / outbound webhooks) goes through a transactional outbox + worker — never a synchronous send in the request path.
  • Inbound webhooks verify the Svix signature on the raw body before any read, then dedupe on an idempotency ledger.
  • Realtime uses SSE (one-way push) — sufficient at <1k concurrent; a WebSocket upgrade path is documented.
← Gallery