Lessons from a Multi-Tenant School Portal - What Worked, What Hurts, and What I’d Change Next Time
The first time I spun up this multi-tenant school management portal on my machine, I didn’t start with the code. I started with a question: “If I were a school admin, would I ever trust this thing with my students’ data?” Ten minutes later, I was...

The first time I spun up this multi-tenant school management portal on my machine, I didn’t start with the code.
I started with a question:
“If I were a school admin, would I ever trust this thing with my students’ data?”
Ten minutes later, I was staring at a MASTER_DATABASE_URL, a DB_NAME_PREFIX=tenant_, and a TenantGuard That refused to let anything through without a valid tenant context. That was my first hint that this wasn’t just another “add a tenantId column and pray” kind of SaaS.
What Works Surprisingly Well
1. Database-per-Tenant: Overkill That Actually Feels Right (For Now)
On paper, per-tenant databases appear to be a lot of work. In practice, the setup here is more elegant than I expected:
-
A master Postgres DB (`app_master`) keeps track of tenants and schools.
-
Each tenant gets its own database, created and wired up by
DatabaseManagerService. -
The
TenantServiceandTenantGuardTurn tenant resolution into a first-class concern instead of a leaked implementation detail.
From a developer’s perspective, this has a nice side-effect: when you’re inside a tenant-aware controller, you can reason about data as if the rest of the world doesn’t exist.
From a privacy and compliance standpoint, that’s a big win.
flowchart LR
Master["Master DB\n(app_master)"] --> Tenants["Tenants\n(metadata)"]
Master --> Schools["Schools\n(metadata)"]
Tenants -->|"databaseName"| T1["tenant_t1"]
Tenants -->|"databaseName"| T2["tenant_t2"]
Tenants -->|"databaseName"| T3["tenant_t3"]
Would I choose this again? For an education SaaS where data isolation is paramount, yes—especially in the early stages.
2. The Monorepo Feels Like One Product, Not Two Projects
Because API and Web live in a single monorepo, it feels like you’re working on one cohesive product:
-
Shared conventions for TypeScript, linting, and testing.
-
One mental model for scripts:
yarn api:dev,yarn web:dev, etc. -
Docs in the root README that acknowledge both halves of the system.
This pays off most when you’re following a user journey end-to-end:
sequenceDiagram
participant User
participant Web as Web App
participant API as API
participant DB as Databases
User->>Web: Opens invite link
Web->>API: GET /invite/teacher?token=...
API->>DB: Validate token, load invitation
DB-->>API: Invitation data
API-->>Web: Invitation details
User->>Web: Completes registration form
Web->>API: POST /invite/teacher/accept
API->>DB: Create teacher user, assign role
API-->>Web: 201 Created
Web-->>User: Welcome to the portal
When front and back are versioned and tested together, these flows are a lot less fragile.
3. Opinionated Config and Validation
Using zod to validate environment configuration (`configuration.ts`) is one of those “little” decisions that change the feel of the project:
-
You don’t guess what
DB_LOGGINGorJWT_REFRESH_EXPIRES_INshould look like. -
You don’t silently misconfigure Cloudinary—`mediaConfig` makes sure the keys are actually there.
This shows up most when you onboard another developer: they don’t have to reverse-engineer .env files; the code tells the truth.
What I’d Change or Tighten Up
1. Taming the Tenant Database Explosion
Multi-DB multi-tenancy is great—until you have lots of tenants.
Today, the code assumes:
-
Each tenant gets its own DB.
-
DatabaseManagerServicewill happily create and hold aDataSourceper tenant. -
Migrations run per tenant via tenant services.
At a small scale, that’s fine. At a larger scale, you start asking harder questions:
-
Which tenant databases are idle?
-
How many are actively connected at once?
-
Are we okay running migrations N times per tenant?
If I were evolving this, I’d introduce a tiered strategy:
-
Tier 1: Small schools share a multi-tenant database (tenantId column, strict guards).
-
Tier 2: High-value or large schools get their own DB (current pattern).
-
Tier 3: Very large schools get dedicated infrastructure (their own cluster or region).
Architecturally, that means abstracting “where tenant data lives” behind an interface, instead of assuming “one tenant, one DB” forever.
2. Making Multi-Tenancy More Visible in the Frontend
The backend is very explicit about tenants (guards, contexts, events). The frontend mostly knows about:
-
Auth (JWT, cookies).
-
Which dashboard you are in (super admin vs school admin).
-
Some onboarding and invitation flows.
What’s not as front-and-center is the tenant context itself:
-
Which tenant am I currently looking at?
-
If I’m a multi-tenant operator (super admin), can I “switch context” easily?
-
Do error messages reflect tenant boundaries, or do they feel generic?
I’d add more tenant-aware UI affordances:
-
A tenant switcher for users who can see multiple tenants or schools.
-
Explicit tenant labels in dangerous flows (deleting a tenant, modifying a school).
-
Clearer separation of “platform-level” vs “tenant-level” navigation.
3. Media Abstraction for Future Flexibility
Cloudinary is a great choice for images, but the current MediaService is relatively Cloudinary-shaped:
-
It returns, which is Cloudinary terminology.
-
It assumes
resource_type: 'image'.
If I suspect we’ll need:
-
PDFs for report cards,
-
Heavy video content for lessons,
-
Or region-specific storage rules,
I’d wrap this in a more generic StorageService Interface, and let Cloudinary be “just one implementation”. That makes it easier to:
-
Introduce S3 or another provider later.
-
Route different media types to different backends.
-
Run A/B migrations without changing every feature module.
Where I’d Optimize Next
1. Operational Tooling Around Tenants
Right now, the code for tenants is solid, but I would invest in tooling:
-
Tenant health dashboard:
-
Is the tenant DB reachable?
-
When was the last migration/backup?
-
How big is the dataset?
-
-
Lifecycle tooling:
-
Safely archive a tenant.
-
Clone a tenant database to a sandbox.
-
Run migrations in a canary fashion (a few tenants first, then roll out).
-
This doesn’t change the code as much as it changes the confidence with which you ship.
2. End-to-End Observability
The app already has health checks and logging, but for a multi-tenant system, I’d double down on tenant-scoped observability:
-
Log and trace with
tenantIdandschoolIdas first-class fields. -
Build dashboards that answer:
-
“Which tenants are slow?”
-
“Which endpoints are most error-prone per tenant?”
-
“Where are timeouts happening in the tenant-aware guards?”
-
flowchart TB
subgraph Metrics["Observability"]
Logs["Structured Logs\n(tenantId, schoolId, userId)"]
Traces["Distributed Traces"]
Dashboards["Dashboards by Tenant"]
end
API["API Instances"] --> Logs
API --> Traces
Logs --> Dashboards
Traces --> Dashboards
3. Pushing More Domain Logic to the Edges
The codebase has good separation of concerns (modules, services, guards). Over time, I’d:
-
Move cross-cutting concerns (logging, metrics, error shaping) into interceptors more aggressively.
-
Keep controllers thin, pushing logic into services.
-
Consider more CQRS-style patterns where reads and writes may have different performance and consistency requirements per tenant.
Closing Thoughts
Working through this app reminded me that multi-tenancy isn’t a feature—you feel it in every layer:
-
The database shape.
-
The guards and decorators.
-
The way your frontend thinks about “who I’m acting as” right now.
The application leans into that reality: it gives each tenant its own space, keeps the product cohesive via a monorepo, and uses strong typing and configuration validation to keep the whole thing from collapsing under its own weight.
If I were to sum it up, the architecture is ambitious in the right places. The next phase is about earning that ambition with the boring-but-crucial work of observability, tooling, and careful evolution of the tenancy model as the number of schools—and expectations—grow.