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_idfromx-tenant-idheader, subdomain, or request body - Load tenant config from Redis cache (5 min TTL, fallback to Postgres)
- Validate JWT access tokens — attach
req.tenantandreq.userto 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 webhooksEvery 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 DBRoute 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=500core-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 emailai-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 callsData 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.