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 TenantService and TenantGuard Turn 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_LOGGING or JWT_REFRESH_EXPIRES_IN should 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.

  • DatabaseManagerService will happily create and hold a DataSource per 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 tenantId and schoolId as 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.