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
$queryRawfor 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
tenantIdfiltering (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:
- User hits
app.sis.example→ Cloudflare DNS resolves to nearest edge - Static MFE assets served from Workers (cached at edge)
- API calls go to
api.sis.example→ Cloudflare proxy → Railway backend - NestJS extracts JWT from
access_tokencookie (orAuthorization: Bearerheader), validates it, extractstenantId ScopeGuardchecks route-level scope permissions;ActionGuardchecks action permissions; services filter bytenantIdin every query- 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:
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 writability — BaseTenantedCrudService.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 import — resolvePersonUuid() (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.
personUuidlinks copies across years. resolveActiveYear(tenantId): called bycreate()andfindAll()whenacademicYearIdis omitted. Returns the tenant's ACTIVE year.assertYearWritable(tenantId, yearId): blocks mutations on ARCHIVED years. Called automatically by base service increate(),update(),remove(). Import pipelines must call it explicitly.create()lifecycle:resolveActiveYear->assertYearWritable->beforeCreate(hooks can rely onctx.academicYearIdbeing set)- Duplicate detection: scoped to target year. Same
personUuidmay 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 throughStudentReferentLink → 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 withUser, theInvitationrow, sibling collapsing on student create/import (upsertbytenantId_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