Architecture Overview

System summary

Multitenant B2B SaaS for campus placement. Shared-schema Postgres multitenancy. Every request is scoped to a tenant via middleware. One frontend codebase, one backend codebase — multitenancy is a data layer concern, not an app layer concern.


Services and what they do

1. api-gateway (TypeScript, Fastify)

Port: 3000
Role: The single entry point for all HTTP traffic. No business logic lives here.

Responsibilities:

  • Resolve tenant_id from x-tenant-id header, subdomain, or request body
  • Load tenant config from Redis cache (5 min TTL, fallback to Postgres)
  • Validate JWT access tokens — attach req.tenant and req.user to every request
  • Validate API keys for external integrations
  • Rate limiting per tenant (Redis-backed)
  • Route traffic to core-api
  • Mount /me/* routes (student-scoped) and /tenants/:slug/* routes (admin-scoped) — same handlers, different scoping

2. core-api (TypeScript, Fastify + Prisma)

Port: 4000
Role: All business logic. Starts as a monolith. Module boundaries are clean so individual modules can be extracted to separate services later.

Modules inside core-api:

  • auth — OAuth callback, OTP issue/verify, token issue, refresh, revoke
  • tenancy — tenant CRUD, config, feature flags, domain rules, CSV allowlist upload
  • rbac — role assignment, permission check middleware, API key CRUD
  • students — profile CRUD, custom columns, document proofs, external dossier fetch
  • placement-cycles — CRUD, enrollment toggle, segmentation-based eligibility
  • jobs — CRUD, eligibility segmentation, deadline management, application toggle
  • applications — submit, status update, bulk download, recruiter actions
  • resumes — JSON resume CRUD, verification requests, AI variant trigger
  • segmentation — query builder engine, save/load named queries, evaluate against student table
  • events — PPT/test event CRUD, QR attendance, manual attendance
  • student-intel — analytics views, column configurator, export
  • companies — master list, student-submitted additions, clustering
  • notifications — enqueue jobs to notification service (does not send directly)

3. tenant-web (TypeScript, Next.js 14)

Port: 3001
Role: The UI. Serves both student and admin views. Role-aware routing. For MVP, hardcodes x-tenant-id: ashoka on all API calls.

4. notification-service (TypeScript, Node + BullMQ worker)

Port: 4001 (health check only — no HTTP API)
Role: Consumes notification jobs from the BullMQ queue. Sends transactional email (Resend), in-app notifications (stored in DB), and outbound webhooks.

5. ai-worker (Python, FastAPI + Celery)

Port: 4002
Role: Consumes AI jobs from a separate BullMQ queue (via a Node bridge) or direct Celery queue. Handles: resume rewriting, resume improvement suggestions, analytics summaries.


Infrastructure (not apps you write)

Process Port Purpose
Postgres 16 5432 Primary database
Redis 7 6379 Sessions, cache, BullMQ backing store
(Optional later) RabbitMQ 5672 If BullMQ cross-language limits hit

Multitenancy — the exact pattern

Shared schema model

Every table has tenant_id UUID NOT NULL as a non-nullable, indexed column. No schema-per-tenant. No database-per-tenant (at this scale).

Tenant resolution (gateway middleware)

Priority order:
1. x-tenant-id request header      ← used by tenant-web (hardcoded "ashoka")
2. Subdomain: ashoka.platform.com  ← used in production multi-tenant
3. x-tenant-id query param         ← used for API key calls
4. tenant field in request body    ← fallback for webhooks

Every query is scoped

Prisma middleware in core-api automatically injects where: { tenant_id: req.tenant.id } on every query. Additionally, Postgres Row Level Security is enabled as a second layer of defence.

Tenant config shape

interface TenantConfig {
  id: string;
  slug: string;           // "ashoka"
  name: string;           // "Ashoka University"
  jwtSecret: string;      // per-tenant JWT signing secret
  featureFlags: {
    aiResume: boolean;
    publicApi: boolean;
    linkedinIntegration: boolean;
    networkGraph: boolean;
  };
  domainRules: {
    allowedDomains: string[];          // ["ashoka.edu.in"]
    matchers: Array<{
      contains?: string;               // "_ug25"
      endsWith?: string;
      role: "student" | "verifier";
    }>;
    allowlistCsvPath?: string;         // S3 path
    allowAnyFromDomain: boolean;       // if true, any @ashoka.edu.in is allowed
  };
  customColumns: {
    c1Label: string;   // "CPP attended"
    c2Label: string;   // "IPP attended"
    c3Label: string;
    c4Label: string;
    c5Label: string;
  };
  externalDossierApiUrl?: string;      // custom API for student dossier
  externalDossierApiKey?: string;
}

Request lifecycle — end to end

Browser / API client


api-gateway :3000
  ├─ resolve tenant from header/subdomain
  ├─ load tenant config (Redis cache)
  ├─ validate JWT → attach req.user + req.tenant
  ├─ check rate limit (Redis)
  └─ proxy to core-api :4000 with headers:
       x-tenant-id, x-user-id, x-user-role, x-user-permissions


core-api :4000
  ├─ Prisma middleware auto-scopes all queries to tenant_id
  ├─ RBAC middleware checks req permissions against route requirements
  ├─ Business logic executes
  ├─ For async work: enqueue BullMQ job → Redis
  └─ Returns JSON response


api-gateway
  └─ Returns response to client
 
Async path:
BullMQ job in Redis
  ├─ notification-service worker picks up → sends email/push
  └─ ai-worker picks up → calls LLM → writes result back to DB

Route mounting strategy

Same handler, two mount points:

Mount point Who uses it Scoping
/me/profile Student (JWT) studentId from JWT
/tenants/:slug/students/:id/profile Admin (JWT or API key) studentId from URL, must have students:read
/me/resumes Student own resumes only
/tenants/:slug/students/:id/resumes Admin any student's resumes
/me/applications Student own applications
/tenants/:slug/cycles/:cycleId/applications Admin all applications in cycle

The handler implementation is shared. The difference is where studentId comes from.


Environment variables (per service)

api-gateway

PORT=3000
CORE_API_URL=http://core-api:4000
REDIS_URL=redis://redis:6379
JWT_SECRET_FALLBACK=...         # used only if tenant secret missing
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=500

core-api

PORT=4000
DATABASE_URL=postgresql://user:pass@postgres:5432/placement
REDIS_URL=redis://redis:6379
S3_BUCKET=placement-assets
S3_REGION=ap-south-1
S3_ACCESS_KEY=...
S3_SECRET_KEY=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
RESEND_API_KEY=...              # for email

ai-worker

OPENAI_API_KEY=...              # or ANTHROPIC_API_KEY
REDIS_URL=redis://redis:6379
DATABASE_URL=postgresql://...
CORE_API_INTERNAL_URL=http://core-api:4000
CORE_API_INTERNAL_SECRET=...   # shared secret for internal calls

Data flow for async jobs

core-api enqueues:
  Queue: "notifications"
    Jobs: send-email, send-push, trigger-webhook
 
  Queue: "ai-jobs"
    Jobs: rewrite-resume, suggest-improvements, generate-summary
 
notification-service consumes "notifications" queue
ai-worker consumes "ai-jobs" queue (via Node bridge or direct Redis)

All job payloads include tenantId and userId so workers can scope DB writes correctly.