Architectural Decision Record: Fastify for Shared Engines (Not NestJS)
:::info Mirrored record — Version 1.2 · Original source: internal ADR, August 10, 2026 This page reproduces an internal ADR (addendum to the Shared Platform Modules Program Charter, Section 6 — Tech Stack) covering why the five shared engines are authored as plain Fastify plugins rather than wrapped in NestJS. Reconstructed from the original document's actual content, matching the treatment already applied to ADR: How a Repository Reaches the Database. This decision directly underpins Policy Section 6 and Section 6.1. :::
Addendum to: Shared Platform Modules Program Charter, Section 6 (Tech Stack) | v1.2 | August 10, 2026
Supersedes: v1.1 (Aug 9, 2026) — informal team discussion + two independent architectural critiques. v1.2 adds a 12-criterion weighted scoring matrix produced by the team and removes the "no migration cost" driver — implementation had not started, so it was never a valid basis for the decision.
1. Decision
The five shared engines (Entitlements, Auth/RBAC, Payment, Notifications, Audit) are implemented as plain Fastify plugins, registered within each vertical's own single-process modular monolith. NestJS is not introduced as an application framework wrapping the engines.
This is a within-vertical framework decision only. It does not change the platform's existing separation between verticals: each vertical (AMAAR, Clinivo, EduSuite, future verticals) already runs its own isolated runtime, database, and deployment, consuming the shared engines as versioned packages. Engine source is shared; engine runtime is not — that has been true since the engines were specified, and this decision does not alter it.
2. Context
- A team discussion raised whether NestJS should wrap the engines to gain Dependency Injection, guards, and interceptors.
- Two independent written critiques of the original Fastify rationale were subsequently reviewed. Both are treated as inputs to this ADR, not as the ADR itself — Section 5 records what each got right and what this decision does not accept.
- The Program SRS (Section 6, Tech Stack) already states Fastify as the implementation choice; this ADR replaces the previously undocumented rationale behind that line with an explicit, defensible one.
3. Decision Drivers (Revised)
Each driver below has been restated to remove claims the original rationale could not actually support.
3.1 One runtime, one lifecycle, no unresolved requirement
- Within a vertical's process, all five engines already compose through Fastify's plugin tree — one HTTP lifecycle, one set of hooks, one registration model. Nest would sit as a second application/module abstraction on top of a boundary Fastify already defines, without addressing a requirement that is currently unmet.
- The correct framing of the Nest rejection is not "Nest cannot do this" — Nest has modules, DI, guards, interceptors, and a Fastify adapter, and technically could. The correct framing is that Nest does not provide enough additional architectural value here to justify introducing its abstraction on top of a Fastify plugin architecture that already defines the required module boundaries.
3.2 Encapsulation is a composition boundary, not an authorization boundary — corrected
- Retracted claim: an earlier version of this rationale stated that Fastify's plugin encapsulation "is the exact boundary model the entitlements-driven module system depends on." This overstates what encapsulation provides.
- Corrected statement: Fastify's encapsulation provides the runtime/module boundary within which entitlement enforcement, hooks, decorators, and schemas are composed consistently. It is not itself the authorization boundary. Entitlement and RBAC checks remain explicit, server-side business logic (the
requirePermission/requireEntitlementmiddleware already specified in the Entitlements and IAM Engine SRS documents), enforced identically regardless of which application framework sits above Fastify. - Consequence: this decision is not an argument that Nest guards "can't" enforce entitlements — they could. It is an argument that they would only re-wrap logic Fastify middleware already handles just as directly, at the cost of a second framework layer.
3.3 Portability of the engines across independently-run verticals
- Because engine source is shared but engine runtime is isolated per vertical, the engines must remain consumable by any Fastify-based vertical application, regardless of what that vertical wraps around its own routes.
- Authoring the engines as plain Fastify plugins (functions, no framework-specific decorators) preserves this: a vertical consumes them by registering the plugin, whether or not that vertical's own app happens to run Nest-on-Fastify for its own business logic.
- Authoring the engines as Nest modules instead would impose a much heavier constraint on every consumer: every current and future vertical would be required to be a Nest application merely to register a shared engine. This is a portability cost with no offsetting benefit, since no engine capability actually requires Nest's DI to function.
:::note Removed in v1.2 An earlier version of this decision listed "no migration cost" as a driver (§3.4), on the premise that the engines were already built. That premise does not hold — engine implementation had not started at the time of this decision — so that driver was removed rather than corrected. This decision rests on the architectural fit argued in §3.1–3.3 and the quantified matrix in §4, not on sunk cost. :::
4. Quantified Decision Matrix (Team Scoring)
To move this decision from narrative argument to something the team could challenge point-by-point, the same comparison was scored explicitly against 12 concerns tied to this program's actual architecture (manifest-discovered vertical modules, boolean/metered entitlement gating, isolated per-vertical runtimes) — not generic "Nest vs. Fastify" opinion. Scale: score 1 = poor fit, 3 = adequate, 5 = excellent fit; weight 1 = minor, 5 = decisive.
| # | Concern | Wt. | Nest score | Nest wtd. | Fastify score | Fastify wtd. |
|---|---|---|---|---|---|---|
| 1 | Vertical module discovery (folder + manifest auto-registration) | 5 | 2 | 10 | 5 | 25 |
| 2 | Single module abstraction (no manifest ↔ @Module duality) | 4 | 2 | 8 | 5 | 20 |
| 3 | Domain structure for complex engines (Payment 26 req, Auth 25 req) | 4 | 5 | 20 | 3 | 12 |
| 4 | Cross-cutting enforcement (entitlement / RBAC / metering) | 5 | 5 | 25 | 4 | 20 |
| 5 | Ease & dev speed (learning curve, boilerplate, team of 8) | 4 | 3 | 12 | 4 | 16 |
| 6 | Portability / lock-in across verticals | 3 | 2 | 6 | 5 | 15 |
| 7 | Testability of engine logic | 3 | 4 | 12 | 4 | 12 |
| 8 | Abstraction complexity / debuggability | 3 | 2 | 6 | 5 | 15 |
| 9 | Ecosystem / hiring / familiarity | 2 | 4 | 8 | 3 | 6 |
| 10 | Runtime performance | 2 | 4 | 8 | 5 | 10 |
| 11 | Future flexibility / exit options | 3 | 2 | 6 | 5 | 15 |
| 12 | Upgrade & maintenance burden | 2 | 3 | 6 | 4 | 8 |
| TOTAL (max weighted = 200) | 40 | 127 | 174 | |||
| % of maximum | 63.5% | 87.0% |
Score rationale (one line each, so the team can challenge them):
| # | Why Nest+Fastify scores that | Why Fastify scores that |
|---|---|---|
| 1 | Needs a pre-bootstrap scan + dynamic imports + a DynamicModule factory — doable but fights Nest's static module model. | @fastify/autoload does folder discovery in one line. |
| 2 | Manifest and @Module() class = two module concepts kept in sync. | Plugin + manifest collapse into one concept. |
| 3 | Modules + DI + enforced layering = real structure for 500-point engines. | Achievable via conventions, but not framework-enforced. |
| 4 | Guards + interceptors + DI are declarative and clean. | Hooks + decorators work well, slightly more manual wiring. |
| 5 | Batteries included, but more concepts plus a custom discovery loader to hand-write. | Smaller surface, less to learn, discovery is free. |
| 6 | Every consuming vertical must be a Nest app, forever. | Any Fastify app can register the plugins — even a Nest-wrapped one. |
| 7 | Good test utilities, but tests often need Nest's application context. | Light to spin up; tied if domain cores stay framework-agnostic. |
| 8 | Two framework layers (Nest on Fastify) plus DI magic to trace. | Thin and transparent. |
| 9 | Larger community, more hiring pool, more tutorials. | Popular but a smaller ecosystem. |
| 10 | Fastify underneath, minor Nest overhead per request. | Direct, no intermediate layer. |
| 11 | Hard to un-choose Nest across all verticals later. | Verticals can adopt Nest-on-Fastify locally later without touching the engines. |
| 12 | Nest majors + adapter compatibility + DI changes to coordinate. | Fewer moving parts to upgrade. |
Two assumptions baked into these scores:
- Framework-agnostic domain cores. Several scores (testability, portability, structure) assume engine domain logic stays free of framework-specific imports. If Nest primitives leak into the domain, Nest's scores on #6, #7, and #11 drop further.
- The folder + manifest discovery requirement is real. Concerns #1 and #2 are weighted high because that is this program's actual module model. If folder-discovery were abandoned in favor of static imports, #1 and #2 would shift toward Nest — this matrix is a function of the architecture already chosen, not a generic framework ranking.
Reading the result: the gap (87.0% vs. 63.5%) is driven by the highest-weight, architecture-specific concerns — module discovery, cross-cutting enforcement, portability, debuggability, future flexibility — not by generic framework popularity (#9, weight 2, is the one row where Nest leads and it is deliberately low-weighted). This mirrors §3: Fastify wins on fit to this specific manifest-driven architecture, not on being a "better framework" in the abstract.
5. What the Reviewed Critiques Changed in This Decision
| Point raised | Accepted? | Disposition |
|---|---|---|
| "Fastify encapsulation is the entitlement boundary" is overstated. | Accepted | Retracted and corrected in §3.2. This is the most important correction in this revision. |
| "Nest can't provide the same lifecycle/guards" is too strong a claim. | Accepted | Reframed in §3.1 as insufficient marginal value, not technical impossibility. |
| Failure isolation between engines sharing one process is under-specified. | Accepted | Elevated to a first-class requirement — see §6. This was missing from the original rationale entirely. |
| Payment reading Entitlements' tables directly is a hidden schema coupling, not a clean contract. | Accepted | Elevated to a first-class requirement — see §7. |
| "Engines" language risks being misread as "independently deployable services." | Accepted | Terminology clarified in §8. |
| Because engine runtime is isolated per vertical, the engines should be authored as NestJS modules distributed as packages, with Fastify only as Nest's HTTP adapter underneath. | Rejected | The premise (source shared, runtime isolated) was already the existing architecture, not new information — it doesn't bear on which framework authors the engine code itself. Authoring in Nest would force every vertical to be a Nest application to consume a shared engine (§3.3), which is a portability regression with no capability gained in return. |
| A vertical's own app-specific logic might benefit from Nest's structure as that vertical's complexity grows. | Accepted, scoped | Left open as a local, per-vertical decision — see §9. Does not apply to how the shared engines themselves are authored. |
6. New Requirement: Runtime Failure Isolation Between Engines
Sharing one Fastify process gives the five engines low-latency composition, a shared lifecycle, and simple deployment — but it also means a fault in one engine can propagate to the whole runtime if isolation is not designed for explicitly. This was not previously specified and is now a program-level non-functional requirement, binding on all five engines equally:
- Notifications must not be able to degrade or block Authentication.
- Audit write volume or backlog must not make Payment unavailable.
- Payment gateway latency must not block ordinary API requests indefinitely (timeout boundaries required on every outbound call).
- Each engine's async work runs through the shared job queue with per-engine queue isolation, not a single undifferentiated queue.
- Startup failure in one engine's manifest validation must not prevent the other four from booting.
- Exception boundaries: an unhandled error inside one engine's plugin must be caught at that plugin's boundary, logged, and must not crash the shared process.
7. New Requirement: Explicit Cross-Engine Database Coupling Policy
The program's existing choice — one shared PostgreSQL database, RLS for tenant isolation, each engine owning its own tables — is a larger architectural decision than the framework choice this ADR covers, and it currently permits at least one engine (Payment) to read another engine's tables (Entitlements' tenant_subscriptions) directly. Direct table reads create a hidden schema contract rather than an explicit one. This program adopts, explicitly:
- Any cross-engine table read is a versioned contract, not an implementation detail. A schema change to a table read by another engine is treated as a breaking-change candidate under the same sign-off rule already specified in the Program SRS §8 ("a breaking change requires sign-off from every known consuming vertical before merging") — extended here to cover consuming engines, not only consuming verticals.
- Where practical, a direct table read is replaced by a call to the owning engine's own internal API/domain function, so the dependency is on a contract, not a schema. Where a direct read is kept for performance reasons, it must be documented in that engine's SRS as an explicit dependency, not left implicit.
8. Terminology Clarification
"Engine" in this program's documentation means an independently owned modular domain/component within the shared platform runtime — not an independently deployable service. This program's own Tech Stack section already states engines are "never a separate deployable microservice"; this ADR makes the terminology explicit so the word "engine" is not later misread as implying independent deployability. If and when an engine is genuinely extracted into its own deployable service (left open as a future option in Section 9), that is a distinct, separately-recorded architectural decision — not a reinterpretation of the current one.
9. Explicitly Left Open — Not Decided Here
- A vertical whose own business logic grows complex enough to benefit from enforced module boundaries may wrap its own app-specific code in NestJS-on-Fastify. This is a local, per-vertical decision, triggered by observed difficulty (recurring cross-module boundary violations, onboarding friction), not adopted pre-emptively. It does not change how the shared engines are authored or consumed.
- An engine may later be extracted into a genuinely independent, separately deployed service if operational requirements justify it (e.g., Payment needing independent scaling or a stricter compliance boundary). This ADR does not decide that; it only confirms today's architecture is a modular monolith, not five services, and that today's decision keeps that extraction possible rather than foreclosing it.