RaR-IT SaaS Platform Architecture Policy
Mandatory Technology & Structural Standards for All Shared Engines and Product Verticals
:::danger Status: PROPOSED — Pending Review, Approval, and Publication Version 1.0 · 25 August 2026. This document is not yet binding — see Section 10. :::
1. Purpose & Scope
This policy establishes the mandatory architectural structure and technology stack for every SaaS system built or maintained under the RaR-IT multi-vertical SaaS program — including the shared platform kernel (@rarit-kernel) and all product verticals (currently AMAAR, Clinivio, EduSuite, Shipping & Transport, and Tourism, plus any future vertical added to the program). It governs backend services, shared engines, and their direct integration points. Separate policies may govern frontend/mobile client technology choices in more detail, but this policy establishes their mandatory baseline (Section 5).
The purpose of this policy is to keep the platform's current five verticals — and any future ones — structurally consistent, avoid duplicated engineering effort across verticals, and ensure that architecture decisions already evaluated once, at real engineering cost, are not silently re-opened or contradicted vertical by vertical.
2. Applicability & Enforcement
Upon publication, this policy is mandatory for all RaR-IT employees, contractors, and any third party performing engineering work on a RaR-IT SaaS system, with no exceptions other than through the formal deviation process in Section 8.
This policy applies to: all new backend services; all new shared engines; all significant refactors of existing services. It does not, by itself, require rewriting compliant existing code predating this policy, but it does apply to any new work on that code going forward.
Non-compliant work merged without an approved exception under Section 8 is subject to being reverted or migration-planned at the responsible team's cost, at the discretion of the Architecture Review Board (Section 9).
3. Platform Structure — Shared Kernel & Verticals
3.1 Shared Kernel (@rarit-kernel)
The platform's shared kernel provides five engines, each published as an npm package under the @rarit-kernel scope, consumed — not re-implemented — by every vertical:
@rarit-kernel/entitlements— tenant plans, feature-bundle manifests, per-tenant module gating.@rarit-kernel/iam— authentication, session management, role-based access control (RBAC), and tenant identity (thetenantstable lives here; every other engine references it).@rarit-kernel/payment— billing, invoicing, settlement, refund and payout primitives.@rarit-kernel/notifications— in-app, email, and SMS dispatch across all lifecycle events.@rarit-kernel/audit— a single, shared, append-only audit trail across every vertical.
3.2 Product Verticals
Each vertical is an isolated runtime and database, multi-tenant within itself via PostgreSQL Row-Level Security, and structurally required to consume all five shared engines rather than reimplementing any part of their function:
- AMAAR — Real Estate & Construction
- Clinivio — Medical / Healthcare
- EduSuite — Education
- Shipping & Transport
- Tourism
Any vertical added to the program in the future is automatically in scope of this policy from its first commit.
4. Core Architectural Principles
The following are mandatory and non-negotiable without a Section 8 exception:
- Engines are embedded plugins, not standalone microservices — the five kernel engines are Fastify plugins embedded directly within each vertical's own backend process; they are never deployed as separately-networked services unless a specific engine's scale requirements are formally reviewed and an exception is granted.
- Fastify's plugin encapsulation is a composition boundary, not an authorization boundary — encapsulating an engine as a Fastify plugin controls how code is organized and loaded; it does not, by itself, enforce who is allowed to call it. Authorization is always the Entitlements/IAM engines' responsibility, explicitly checked, never assumed from plugin structure.
- IAM's authorization and Entitlements' plan-gating are two different checks, not one — a tenant's plan including a feature (Entitlements) does not mean the specific user making the request has permission to use it (IAM's RBAC). Every request-handling code path is expected to pass through both, in order, not either as a substitute for the other. The full sequence, tying this together with Annex A.10's database-layer guarantees: Authentication (IAM) → Tenant Resolution → Authorization/RBAC (IAM) → Entitlement check (Entitlements) → Row-Level Security (PostgreSQL) → Business logic. Every example in Annex A shows the entitlement-check step (
requireEntitlement()) explicitly; IAM's authentication and RBAC steps happen earlier in the request pipeline (session/token validation before the route handler runs) and are not re-shown in every example for brevity — their absence from a given code sample is not license to skip them. - Tenant identity is owned by IAM, not by any other engine — the
tenantstable lives inside@rarit-kernel/iam; every other engine (Entitlements, Payment, Notifications, Audit) references tenant identity as a cross-engine logical foreign key, never duplicating or re-defining it. - Manifest-driven vs. fixed-contract engines — Entitlements, IAM, and Notifications are manifest-driven (their behavior is runtime-configurable per tenant); Payment and Audit are fixed-contract (their behavior is structurally identical for every tenant). Any new engine capability must be classified as one or the other before being built.
- Vertical-specific monetization models (e.g., commission/settlement logic, VIP or premium tiers) are configuration on top of Payment and Entitlements, not new engines.
- Monitoring and observability are infrastructure instrumentation (structured logging plus a hosted APM), not a sixth stateful engine.
- Engine-to-engine dependencies must be acyclic — no two shared engines may import each other's package directly, in either direction. Where two engines have a genuine two-way relationship (for example, IAM must trigger Notifications for OTP delivery, while Notifications must resolve tenant contact details from IAM), the relationship must be restructured so that at most one direction is a direct import; the other direction is expressed as a published event, never a reverse import. See Annex A for a worked example.
- This rule applies identically to product verticals: a shared engine must never import a vertical's package, and one vertical must never directly import another vertical's package. Functionality that two verticals both need must be promoted into a shared, kernel-consumable package instead of creating a vertical-to-vertical dependency. See Annex A for a worked example.
5. Mandatory Technology Stack
| Layer | Technology | Status | Rationale |
|---|---|---|---|
| Language & Runtime | Node.js + TypeScript | Mandatory | One language across backend, tooling, and much of the frontend build chain. TypeScript's static typing is essential at this scale — five engines and five-plus verticals sharing contracts cannot be safely maintained in untyped JavaScript. |
| Backend Framework | Fastify | Mandatory | Its plugin/encapsulation model maps directly onto the "engines as embedded plugins" architecture in Section 4 — an engine is a Fastify plugin, with no translation layer between the framework's modularity primitive and the platform's module boundary. Schema-first validation (JSON Schema + Zod) gives manifest-driven engines a natural validation layer. Lower overhead than heavier frameworks matters at the scale of many tenants sharing backend processes. |
| Database | PostgreSQL | Mandatory | Row-Level Security (RLS) is the platform's core tenant-isolation mechanism — the mechanism every vertical depends on to guarantee tenant A never sees tenant B's data at the database layer, not just the application layer. JSONB supports manifest-driven engine configuration without a schema migration per tenant option. LISTEN/NOTIFY supports lightweight real-time needs without a separate broker. |
| Web Frontend | React | Mandatory | Established across every vertical's tenant-facing web app; consistent component/design-system reuse across AMAAR, Clinivio, EduSuite, Transport, and Tourism depends on this. |
| Mobile | Flutter | Mandatory | Chosen once for the whole platform rather than per-vertical, so patterns proven in one vertical's mobile app are directly reusable in another's. |
| Dependency-Cycle Enforcement | eslint-plugin-import's no-cycle rule, enforced in CI across the whole npm workspace | Mandatory | Turns the acyclic-dependency rule in Section 4 from a written principle into a build failure. npm can technically install a circular package dependency, and Node can technically execute one by returning a partially-initialized module — neither guarantees correct runtime behavior. A pull request introducing an import cycle between any two packages in the workspace fails CI automatically, the same way an unreviewed Section 5/6 deviation would. See Annex A for what this rule guards against and how it's implemented. |
| Tenant-Scoped Database Access | getDb() + withTenantScope(), exported by @rarit-kernel/platform-core (AsyncLocalStorage-based execution scope) | Mandatory | Accepted 2026-08-26 (db-access-pattern-decision.md). This is what makes the PostgreSQL RLS mandate above actually engage at runtime — a repository handle that never sets app.tenant_id leaves RLS with nothing to filter on, which fails silently rather than erroring. It also keeps a mutation and its Audit-engine write in one transaction, so a failed audit write rolls back the mutation instead of leaving it uncommitted-to-the-log. Repository function signatures never change to adopt this pattern — only the body does. See Annex A.10 for the full pattern and its rejected alternatives. |
6. Evaluated & Rejected Alternatives (for the Shared Engines)
The following technologies were formally evaluated for use in authoring the shared engines (Section 3.1) and explicitly rejected in that role. They must not be introduced into new kernel engine work without a new Architecture Review Board decision superseding this section. Section 6.1 below addresses a distinct question — Nest's use in a vertical's own business logic — which was left open, not rejected.
| Technology | Status | Reason for Rejection |
|---|---|---|
| NestJS | Rejected for authoring the shared engines | NestJS's primary value proposition — a structured, DI-driven module system enforcing separation of concerns — duplicates what Fastify's own plugin encapsulation already provides for the "engines as embedded plugins" model. Wrapping the engines in Nest would force every vertical to be a Nest application just to consume a shared engine, and would mean engine routes never pass through Fastify's own request pipeline. A fit-for-this-architecture judgment for the engines specifically, not a general verdict on NestJS. |
| Express | Rejected in favor of Fastify | Superseded by Fastify's schema-first validation and materially better throughput at the connection volumes the shared-process, multi-tenant engines need to sustain. |
| MongoDB / other NoSQL as a primary tenant datastore | Rejected for core tenant data | RLS is a relational, PostgreSQL-specific mechanism the tenant-isolation model is built around; a document-store alternative would require rebuilding that guarantee at the application layer, which Section 4 explicitly prohibits. |
fastify.decorate('db', ...) for repository database access | Rejected for all engines and verticals | Accepted alongside the mandatory pattern above, same ADR. The decorated handle is a pool, not a transaction, so a mutation and its Audit-engine write cannot commit atomically. It carries no app.tenant_id, so PostgreSQL RLS silently stops filtering rather than erroring — the system keeps running with one fewer enforcement layer and no error to notice it by. And it requires a repository to import or receive the Fastify instance, contradicting Section 4's existing rule that services never import Fastify. One engine's codebase had this pattern prior to the ADR; it is being removed, not extended (see Annex A.10). |
Explicit transaction parameter (getPlan(tx, id)) for repository database access | Rejected for all engines and verticals | More conventional than the accepted AsyncLocalStorage pattern, but would change every repository function's signature across all five engines, and would require the Audit engine's write to receive a Drizzle transaction object across a package boundary — making Drizzle's own transaction type part of every engine's public API, so a Drizzle upgrade would become a breaking change everywhere at once. |
6.1 Scoped Allowance: NestJS for a Vertical's Own Business Logic
Unlike Section 6 above, NestJS is not rejected as a general-purpose tool. A vertical may use Nest to structure its own app-specific business logic (code that is not one of the five shared engines) once that vertical's complexity genuinely benefits from Nest's module/DI structure. This is a local, per-vertical decision — it does not require Architecture Review Board sign-off on its own, but it is bound by two conditions:
"Genuinely benefits from Nest's module/DI structure" is deliberately not left to individual judgment alone — it means at least one of the following is true, anchored to real examples in Annex A:
- Multiple interdependent providers sharing state or collaborating, not a single class with no internal dependencies (Annex A.4's
TemplateRegistryService,BodyDiagramWidgetService, andClinicalValidationServicegenuinely depend on each other; a single CRUD handler with no collaborators, per A.1 or A.3, does not qualify). - A pluggable-strategy or registry pattern, where the set of implementations is selected at runtime rather than hardcoded (Annex A.5's interchangeable forecast strategies via an injection token).
- Lifecycle-managed providers that need construction-time setup distinct from per-request handling.
A single class with no constructor dependencies, or logic that's really just "a few sequential steps with one database call," does not meet this bar — that's A.1/A.3's territory, plain Fastify, no DI container. If it's unclear which side of the line a module falls on, default to Fastify; adopting Nest later for a module that turns out to need it is a small, contained change, while removing Nest from a module that never needed it is a larger one.
- Integration pattern: Nest must be used as a dependency-injection application context only (via
NestFactory.createApplicationContext), with Fastify retaining ownership of every HTTP route, the module's feature manifest, and the entitlement-check hook. Nest-resolved services are called from inside ordinary Fastify route handlers; Nest never runs its own HTTP pipeline for tenant-facing traffic under this default pattern. - Manifest compliance: regardless of whether a module's internals are plain Fastify or Nest-organized, it must export the same framework-agnostic feature manifest (key, bilingual display name, required plan, routes) that Entitlements' discovery mechanism already reads for every other module. Entitlements must never need special-case logic to discover a Nest-organized module.
A second pattern — Nest running its own Fastify-adapter HTTP instance, mounted or proxied under the parent process — is technically possible but not the default, because it moves entitlement enforcement out of Fastify's own request pipeline. This second pattern requires Architecture Review Board sign-off before use. See Annex A for worked code examples of both the permitted pattern and this restricted one.
If the shared engines are ever split into independently-deployed microservices in the future (an explicitly open possibility, not a current plan), this calculus changes — that is a future-state trigger for revisiting this section, not a basis for adopting it today.
7. Recommended but Replaceable Components
Unlike Sections 5 and 6, the following are the platform's current defaults but may be replaced by a team for good technical reason without a full Architecture Review Board exception — a lighter-weight notification to the platform architecture owner (Section 9) is sufficient.
| Component | Current Default | May Be Replaced By |
|---|---|---|
| ORM / query layer | Drizzle ORM | Any type-safe, SQL-first TypeScript query layer (e.g., Kysely). Full ORMs that obscure RLS-relevant queries are discouraged, not banned, and need Section 8 review. |
| Background job queue | pg-boss (PostgreSQL-backed) or BullMQ (Redis-backed) | Either is acceptable by default; choose based on whether Redis is already needed for another reason. |
| Schema validation | Zod | Any TypeScript-first schema validator compatible with Fastify's validation hooks. |
| API documentation | OpenAPI/Swagger generated from Fastify schemas | Must be generated from the same schemas used for runtime validation, not hand-maintained separately. |
8. Exception & Deviation Process
Any deviation from Section 5 (Mandatory) or Section 6 (Rejected) requires:
- A written proposal stating the specific technical constraint that the mandatory/rejected choice cannot satisfy — not a preference-based justification.
- Review and sign-off by the Architecture Review Board (Section 9).
- Documentation of the approved exception in this policy's own change log, including scope (which service/vertical — not a platform-wide reopening) and an expiry/review date.
Exceptions are granted per-service, not platform-wide, and do not set precedent for other services without their own review.
9. Governance & Ownership
This policy is owned by CTO. The Architecture Review Board consists of CTO plus one senior engineer per active vertical. This policy is reviewed at minimum every two quarters, or immediately upon any Section 8 exception being granted, to determine whether the exception should instead become a policy update.
Open action item, tracked as Actions ACT-002: a Data Ownership Matrix — which entity owns which data, and who holds direct read/write access to each table, per engine and per vertical — does not yet exist as an approved artifact. This policy already states the principle (each engine owns its own tables; a vertical never queries another engine's or vertical's tables directly), but there is no single document enumerating it table by table. Its absence is a real gap: without it, "which engine owns this data" is answered by convention and code review rather than by a document anyone can check, and the risk is the same shared-database drift this policy's other sections already guard against by other means (Section 4's acyclic-dependency rule, Section 5's getDb()/withTenantScope() mandate). Producing this matrix is assigned to the Architecture Review Board as a standing action item, not deferred indefinitely.
10. Policy Sign-off
By signing below, the undersigned parties approve this policy as a mandatory standard, effective from its official publication date.
| Role | Name | Signature | Date |
|---|---|---|---|
| Chief Technology Officer | |||
| Vertical Lead (AMAAR) | |||
| Vertical Lead (Clinivio) | |||
| Vertical Lead (EduSuite) | |||
| Vertical Lead (Transport) | |||
| Vertical Lead (Tourism) |
:::info Signed record
Once signed, the approved PDF/DOCX for this version is filed under signed-records/saas-architecture/ in this repository as the immutable controlled copy. This Markdown source continues to evolve toward the next version.
:::