Skip to content

Architecture

Version: 0.2 — March 2026


1. Overview & Philosophy

SIS is a multi-tenant Student Information System for multi-level schools (kindergarten through high school). The architecture prioritizes developer velocity for a small team (2–4 devs) shipping a beta in 6 months, while preserving a clear migration path toward scale.

Architecture philosophy:

  • Monolith-first: Ship fast, split later. NestJS modules map 1:1 to future services.
  • Single language: TypeScript end-to-end reduces context-switching and maximizes code sharing.
  • Managed infrastructure: Zero DevOps overhead during MVP. Railway and Cloudflare handle operations.
  • Defense in depth: Service-layer tenant filtering at the API, scope-based guards for field access, action-level guards for operation access, scope-level filtering at the response layer. PostgreSQL RLS is the target architecture for an additional database-level safety net (not yet deployed — see chapter 02).
  • Multitenancy from day one: Every table carries tenant_id; service-layer filtering enforces isolation. RLS policies are designed as a future safety net (no schema-per-tenant for now).

2. Decision Summary

Area Decision Key Reason
Backend NestJS monolith + Prisma + PostgreSQL Fastest dev speed, single language with frontend, modular
Multitenancy tenant_id column + PostgreSQL RLS Simpler ops than schema-per-tenant, single migration path
Authentication Custom with Passport.js + JWT Full control over Entity-Scope permission model, no per-user cost
Frontend Micro frontends with React + Tailwind on Cloudflare Workers Edge-served, independent deployments, global low latency
Storage Cloudflare R2 S3-compatible, zero egress fees, pairs with Cloudflare edge
Queues/Jobs BullMQ + Redis Mature Node.js queue, cron/retry/priority support, Redis reusable for caching
Deployment Railway (backend) + Cloudflare Workers (frontend) Simplest PaaS, managed PostgreSQL, preview environments

For detailed technology comparisons and decision rationale, see docs/stack-analisys.md.


3. System Components

Backend — NestJS Monolith + Prisma + PostgreSQL

NestJS 11 with Prisma ORM on PostgreSQL. Modules (StudentsModule, TeachersModule, AttendanceModule) map directly to domain boundaries — each can be extracted into a microservice later without (too much) rewriting.

Guards and custom decorators (@RequireScopes(), @RequireAction()) integrate naturally with the Entity-Scope-Action permission model. Prisma provides type-safe database access generated from the schema with excellent migration tooling.

Trade-offs accepted:

  • Prisma has limitations with complex raw queries — mitigated by using $queryRaw for RLS policy setup and complex reporting queries.
  • Node.js memory management needs attention for large data exports — mitigated by streaming responses and offloading to BullMQ jobs.
  • Multitenancy is not built-in — implemented via service-layer tenantId filtering (RLS deferred to Phase 2).

Multitenancy details: chapter 02

Authentication details: chapter 03

Frontend — Micro Frontends on Cloudflare Workers

Micro frontend (MFE) architecture with React + Tailwind CSS, deployed on Cloudflare Workers/Pages for edge serving. An orchestrator shell handles shared concerns (auth, routing, layout). Individual MFEs map to domain modules.

Trade-offs accepted:

  • Higher initial complexity compared to a monolithic SPA. Mitigated by starting with 2-3 MFEs (shell + students + teachers) and expanding.
  • Shared state between MFEs requires careful design (event bus or shared store in the shell).
  • Cloudflare Workers have a V8 runtime (not Node.js) — MFEs are static React builds served from Workers, not SSR on Workers.

Storage — Cloudflare R2

Cloudflare R2 for all file storage (documents, photos, uploads). Zero egress fees, S3-compatible API (@aws-sdk/client-s3), and Cloudflare edge pairing. Large file uploads go directly to R2 via presigned URLs, bypassing the NestJS backend.

Queues & Background Jobs — BullMQ + Redis

BullMQ for job queues and scheduled tasks, backed by Redis. Redis serves dual purpose: BullMQ backend and caching layer (permission caches, tenant configs).

Key use cases: Substitute teacher access revocation (scheduled), email/SMS dispatch, document processing, attendance report generation, admission workflow reminders.

Note: Redis and BullMQ are planned Phase 2 infrastructure — not yet deployed.

Deployment — Railway (Backend) + Cloudflare Workers (Frontend)

Railway (backend): Git-push deployment, managed PostgreSQL and Redis, preview environments per PR, EU region available, usage-based pricing.

Cloudflare Workers/Pages (frontend): Global edge serving from 300+ locations, instant deploys from Git, generous free tier, pairs with R2 and CDN.

Trade-offs accepted:

  • Two providers to manage instead of one. Acceptable because each excels at its job — Railway for server workloads, Cloudflare for edge/static content.
  • Railway is a smaller provider than AWS/GCP. The migration path to any Docker-compatible platform is straightforward (NestJS runs in a standard Docker container).

Full deployment details: chapter 10


4. Architecture Diagram

                            ┌─────────────────────────────────────────┐
                            │           CLOUDFLARE EDGE               │
                            │   CDN · WAF · DDoS Protection · DNS     │
                            └──────────────────┬──────────────────────┘
                    ┌──────────────────────────┼──────────────────────────┐
                    │                          │                          │
                    ▼                          ▼                          ▼
    ┌───────────────────────┐  ┌───────────────────────┐  ┌───────────────────────┐
    │   CLOUDFLARE WORKERS  │  │       RAILWAY          │  │   CLOUDFLARE R2       │
    │                       │  │                        │  │                       │
    │  ┌─────────────────┐  │  │  ┌──────────────────┐  │  │  Documents            │
    │  │ Orchestrator    │  │  │  │   NestJS API     │  │  │  Photos               │
    │  │ Shell (Auth,    │  │  │  │                  │  │  │  Uploads               │
    │  │ Routing, Layout)│  │  │  │  Passport.js JWT │  │  │                       │
    │  └────────┬────────┘  │  │  │  Prisma ORM      │  │  │  (S3-compatible)      │
    │           │           │  │  │  BullMQ Workers   │  │  │                       │
    │  ┌────────┴────────┐  │  │  └────────┬─────────┘  │  └───────────────────────┘
    │  │   MFE: Students │  │  │           │            │
    │  │   MFE: Teachers │  │  │  ┌────────┴─────────┐  │
    │  │   MFE: Attend.  │  │  │  │   PostgreSQL     │  │
    │  │   MFE: Admiss.  │  │  │  │   (Managed)      │  │
    │  │   MFE: Comms    │  │  │  │                  │  │
    │  │   ...           │  │  │  │  · tenant_id RLS │  │
    │  └─────────────────┘  │  │  │  · UUID PKs      │  │
    │                       │  │  │  · JSONB fields   │  │
    │  React + Tailwind     │  │  └──────────────────┘  │
    └───────────────────────┘  │                        │
                               │  ┌──────────────────┐  │
                               │  │   Redis          │  │
                               │  │   (Managed)      │  │
                               │  │                  │  │
                               │  │  · BullMQ queues │  │
                               │  │  · Permission    │  │
                               │  │    cache         │  │
                               │  │  · Session store │  │
                               │  └──────────────────┘  │
                               └────────────────────────┘

Note: Redis and BullMQ are planned Phase 2 infrastructure — not yet deployed. The current system uses request-scoped permission memoization and has no background job processing.

Request flow:

  1. User hits app.sis.example → Cloudflare DNS resolves to nearest edge
  2. Static MFE assets served from Workers (cached at edge)
  3. API calls go to api.sis.example → Cloudflare proxy → Railway backend
  4. NestJS extracts JWT from access_token cookie (or Authorization: Bearer header), validates it, extracts tenantId
  5. ScopeGuard checks route-level scope permissions; ActionGuard checks action permissions; services filter by tenantId in every query
  6. File uploads go directly to R2 via presigned URLs (bypass backend)

5. Data Access — Direct Prisma, No Repository Layer

Services inject PrismaService directly. No repository pattern. No domain model layer.

Current data access pattern:

Controller → Service → PrismaService → PostgreSQL
          toScopedResponse() → Response DTO

Services own business logic + data access. toScopedResponse() maps flat Prisma records to scope-grouped DTOs. FieldFilterInterceptor handles scope-level response filtering.

Tenant isolation is enforced by manually including tenantId in every Prisma where clause (RLS deferred to Phase 2 as a safety net — see chapter 02).

Academic year scoping — Student, Teacher, and Staff records are full snapshots per academic year (academicYearId FK). personUuid links copies of the same person across years. BaseTenantedCrudService.create() and findAll() accept an optional academicYearId; when omitted, the tenant's ACTIVE year is resolved automatically. Configuration entities (Department, Grade, Room) also have academicYearId but handle year scoping in their own service overrides. Year rollover (bulk copy across years) is deferred.

Year writabilityBaseTenantedCrudService.assertYearWritable() blocks all mutations (create, update, remove, import) on ARCHIVED academic years, throwing ACADEMIC_YEAR_ARCHIVED (409). The create() lifecycle runs resolveActiveYear first (setting ctx.academicYearId), then assertYearWritable, then beforeCreate — so hooks have access to ctx.academicYearId when they run. update() and remove() pre-fetch the record to read its academicYearId before calling assertYearWritable.

Duplicate detection is year-scoped — both beforeCreate hooks and import checkDuplicates callbacks scope lookups to the target academic year. The same person (by personUuid) may legitimately exist in multiple years; only same-year duplicates are rejected. All people entities (Student, Teacher, Staff) use the shared assertNoPeopleDuplicate() utility (src/common/utils/people-duplicate-checker.ts) for single-create duplicate detection, enforcing two independent checks: (1) identity match (firstName + lastName + dateOfBirth, case-insensitive) and (2) email uniqueness (entity-specific email field). Import pipelines use multi-rule checkImportDuplicates() with separate identity and email rules for the same independent-check semantics.

Cross-year identity on importresolvePersonUuid() (src/common/utils/resolve-person-uuid.ts) links an imported row to an existing person across years via cascading match: taxCode first, then name + date-of-birth. If a match is found, the existing personUuid is reused; otherwise a new UUID is generated.

Revisit triggers — introduce domain models and/or repositories per-module when:

  • Business rules don't map 1:1 to CRUD (e.g., enrollment workflow, grade promotion logic)
  • Cross-entity invariants appear (e.g., schedule conflict detection)
  • Aggregate roots coordinate multiple entities in a transaction
  • A module's query logic exceeds what fits cleanly in a service method

These should be adopted per bounded context, not as a codebase-wide mandate.

Trade-offs accepted:

  • Tenant filtering is repeated manually in every service method — accepted because RLS will be the long-term solution, and a utility helper introduces coupling for a temporary pattern.
  • Services mix business logic with data access — acceptable at current complexity level (5–10 methods per service). If a service grows beyond ~15 methods or contains complex orchestration, consider extracting a repository for that specific module.

6. Academic Year Scoping

All domain entities (students, teachers, staff, departments, grades, rooms) are scoped via academicYearId FK.

Key behaviors

  • Person entities (Student, Teacher, Staff) are snapshot-per-year. personUuid links copies across years.
  • resolveActiveYear(tenantId): called by create() and findAll() when academicYearId is omitted. Returns the tenant's ACTIVE year.
  • assertYearWritable(tenantId, yearId): blocks mutations on ARCHIVED years. Called automatically by base service in create(), update(), remove(). Import pipelines must call it explicitly.
  • create() lifecycle: resolveActiveYear -> assertYearWritable -> beforeCreate (hooks can rely on ctx.academicYearId being set)
  • Duplicate detection: scoped to target year. Same personUuid may exist in multiple years.

Error codes

Code Status When
ACADEMIC_YEAR_NOT_FOUND 404 Requested year ID doesn't exist for tenant
NO_ACTIVE_ACADEMIC_YEAR 409 No ACTIVE year and none specified
ACADEMIC_YEAR_ARCHIVED 409 Mutation attempted on archived year

Not year-scoped (intentionally)

  • Configuration / catalogue entities: RoomType, CustomFieldDefinition, User, Role, School.
  • Referent (parent/guardian): a flat per-tenant row keyed by (tenantId, email). Year context is reachable through StudentReferentLink → Student.academicYearId, so the referent itself does not need a per-year snapshot. Rationale:
  • Referent fields are pure identity (anagraphic, contacts, documents) — none vary by school year the way Teacher/Staff role-bound facts do, so re-snapshotting annually would duplicate identity without adding semantics.
  • (tenantId, email) uniqueness is load-bearing for the 1:1 with User, the Invitation row, sibling collapsing on student create/import (upsert by tenantId_email), and the referent-role record-level filter. Year-scoping would force (tenantId, email, academicYearId) and break those invariants.
  • Trade-off accepted: no historical snapshot of a referent's contacts. Acceptable because guardian contact history is not an HR/regulatory requirement the way teaching-assignment history is.

Full reference: src/common/services/base-tenanted-crud.service.ts, docs/workflows.md §13