Building a multi-tenant ERP for Indian schools
Architecture decisions behind Campus — tenant isolation, board-specific compliance, and the parent-app trade-offs.
The first school we onboarded to Campus had 1,847 students across pre-primary, primary and secondary, two CBSE-affiliated branches in Hyderabad, and a fee structure with 23 distinct heads. The second school had 612 students, was ICSE, and ran a single branch in Vijayawada with a completely different academic calendar. Both expected to log into the same product on Monday morning. That tension — one codebase, infinite local realities — is the whole problem of a multi-tenant Indian school ERP.
Tenant isolation is the only thing you cannot get wrong later
Every other architectural decision can be revisited. Tenant boundaries cannot. We picked
shared-database, shared-schema with tenant_id on every row and PostgreSQL row-level
security as the enforcement boundary. Application code sets the tenant context on the
session at the start of each request, and RLS policies do the rest:
CREATE POLICY tenant_isolation ON students
USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE students FORCE ROW LEVEL SECURITY;
The FORCE matters. Without it, the table owner bypasses RLS, and a single misconfigured
migration job can leak data across institutions. We learned that during a staging dry-run,
not in production, which is the only acceptable place to learn it.
The other thing that has saved us repeatedly: every background job carries the tenant_id
as a first-class argument, not pulled from some implicit context. Cron-driven fee reminders,
report generation, WhatsApp dispatch — they all take tenant_id explicitly. A worker that
forgets which tenant it's working for is a worker that can do real harm.
Board compliance is not a feature, it's a configuration surface
CBSE, ICSE, IB, IGCSE, and the various state boards each have their own grading scales, report card formats, attendance rules and promotion logic. Early on we tried to fork the report card module per board. That was a mistake. We now treat the board as a configuration profile that drives:
- Grading scheme — letter grades vs marks, scale boundaries, honours bands.
- Report card layout — section ordering, scholastic vs co-scholastic split, term structure (CBSE's two-term system vs ICSE's three-term).
- Attendance rules — minimum 75% promotion threshold for CBSE, branch-specific exceptions for medical leave.
- Subject codes — mapped to board-issued codes for board-exam students (Class 10/12).
GST is the other compliance axis. Tuition fees are exempt under SAC 9992, but transport, hostel and uniform sales aren't. The fee structure tags each head with its GST treatment, and the invoice generator splits a single payment into the right invoice lines automatically. When the GST Council issued the September 2025 circular reclassifying certain composite supplies, we updated tag definitions and reissued affected invoices in a single migration.
Per-tenant signing keys and the WhatsApp problem
The most underrated decision we made: every institution gets its own asymmetric signing key, generated at provisioning, stored in our KMS, and used to sign report cards, fee receipts and ID-card barcodes. When a school exits the platform, we revoke the key and the old PDFs become forensically traceable but cryptographically dead.
A signed PDF that a parent received in 2024 should still verify in 2030, even if the school left us in 2026. Per-tenant keys are the only way that works.
The same key infrastructure powers the WhatsApp template approval flow. Each school's
WhatsApp Business Account is registered separately with Meta, with its own template
catalogue. Our orchestrator picks the right BSP credentials based on tenant_id before
dispatching the message — a school in Bengaluru and a school in Patna share zero rate-limit
headroom, which is exactly what you want.
The parent app is its own product
We built the parent app twice. The first version reused the staff web app's data model — parents saw a stripped-down view of the same screens. It was technically elegant and a product disaster. Parents don't want to navigate a school's information architecture. They want three things: did my child attend today, what's the next fee due, and what did the class teacher post.
The rebuild treats the parent app as a feed-driven product. Every event the school
generates — attendance mark, fee post, homework assignment, exam result, gate-pass scan —
is fanned out into a per-parent timeline. The data still comes from the same tenant_id
partitioned tables, but the read path is denormalised and aggressively cached. Parent app
opens during morning attendance window peak at 4,200 RPS across the platform, and the read
side handles it from a per-tenant materialised feed without ever hitting the OLTP tables.
What we'd build differently
If we started today, we'd put the tenant boundary even earlier in the request lifecycle — at the load balancer, via subdomain routing, with the application layer rejecting any request where the URL tenant doesn't match the JWT tenant. We bolted that on later. Doing it at day one would have made several edge cases disappear.
We'd also invest sooner in tenant-scoped feature flags. Every school wants something slightly different, and the right answer is rarely a code change — it's a flag the support team can toggle, scoped to that tenant alone.
If you run a school group and your current ERP looks like a 2014 PHP project, talk to us at /campus or write to admin@airanexus.in. Migration is week one of a seven-day go-live, and we've done it enough times to make it boring.