VOOZH about

URL: https://dev.to/rodrigobnogueira/a-nestjs-reference-app-that-proves-the-nest-native-stack-under-realistic-backend-pressure-5dc0

⇱ A NestJS reference app that proves the nest-native stack under realistic backend pressure - DEV Community


TL;DR: nest-native/reference-app is a v0.1 reference application that demonstrates the nest-native stack end-to-end — nest-drizzle-native and nest-trpc-native composing under realistic backend pressure: feature modules, multi-tenant auth context, cross-service transactions via @Transactional(), an outbox-pattern worker for post-commit side effects, and a typed tRPC client smoke check. Everything decorator-first, ~zero hidden magic, and all eight implementation milestones shipped via PR with security and dependency review on each one. Repo: github.com/nest-native/reference-app.

A quick note about the org

nest-native is a fresh community GitHub organization publishing decorator-first NestJS integrations. The point is that each integration should feel like an official NestJS package — modules, decorators, DI, enhancers, lifecycle hooks — while staying honest about the underlying tool. Drizzle stays SQL-first. tRPC stays tRPC. No hidden magic where explicit application code would be clearer.

Two libraries are currently published:

  • nest-drizzle-native — Drizzle ORM with DrizzleModule.forRoot(), forFeature([Repo]), @DrizzleRepository, @InjectDrizzle, and @Transactional via @nestjs-cls/transactional.
  • nest-trpc-native — Decorator-first tRPC for NestJS: TrpcModule.forRoot(), @Router, @Query/@Mutation/@Subscription, @Input, @TrpcContext, plus generated AppRouter types for fully-typed tRPC clients.

Both ship with samples, ≥99% coverage, support policies, and a strict design bar. That bar is exactly what made the reference app necessary.

The problem this exists to solve

If you're starting a new NestJS backend in 2026 and you've picked Drizzle for the database and tRPC for the API layer, you have a composition problem. Each library is well-documented in isolation, but a real backend is the composition — and the composition is where most of the design decisions live. Library docs cover their slice; nobody covers the seams.

Here are the questions you'd otherwise have to answer from scratch, in roughly the order they bite:

  • How do I thread "current user" and "current organization" through everything? An Express middleware can set req.authContext easily enough — but how does that reach a tRPC procedure? A guard? A service three calls deep in a request? The right answer involves request-scoped DI, TrpcModule.forRoot({ createContext }), custom param decorators that work for both transports, and class-level @UseGuards. There's no canonical example anywhere that wires all of it together end-to-end.

  • How do I do a real transaction across services? "Insert a user, then a membership, then a project, then an audit row, then enqueue a notification" is a single business operation. If you split it across services — which you should — do they share a transaction? @nestjs-cls/transactional gives you a @Transactional() decorator, but how does the inner repo know to use the tx client and not the raw one? And what if you're on better-sqlite3 for local dev? (Spoiler: not with the official adapter — its async wrapper silently commits empty transactions against synchronous sqlite. You need a custom sync adapter, which the reference app ships as ~30 lines.)

  • How do I send a post-commit side effect without losing it? "After the transaction commits, send the welcome email" sounds easy until you realize you can't send inside the tx (rollback → ghost email) and you can't send after the tx return either (process crashes → lost email). The transactional outbox is the right answer. Reinventing it is a week of subtle bugs around atomic claims, idempotency keys, stuck-claim recovery, and exponential backoff.

  • How do I prove my tRPC procedures still match my clients? tRPC's pitch is end-to-end type safety — but only if the generated AppRouter actually round-trips into a real typed client at CI time. Most teams skip this and find out their procedures broke when their frontend breaks in staging.

  • What does the boring scaffolding actually look like? ESLint flat config with a cognitive-complexity ceiling, drizzle-kit migrations with a forward-only contract, node:test + c8 coverage, an npm run ci chain (typecheck → lint → complexity → tests → audit → build), a Dockerfile that runs both API and worker off the same image, a CHANGELOG with per-dependency justifications. Two to four days of YAML and config decisions before you can write any business logic.

This repo answers each of those with a decision, not a menu of options. You can disagree with any one of them and swap it out — but you're disagreeing with something concrete rather than designing in a vacuum.

It is not a product and not a library. The two implicit deliverables are:

  1. A credible demo a team can fork (or copy patterns from) for a real backend.
  2. A feedback loop into the libraries themselves — if a pattern feels awkward here, that's signal to add API upstream in a separate PR to the relevant library repo.

A bug found while building it actually flowed exactly that way. A missing await in nest-trpc-native was silently mis-mapping HttpException → INTERNAL_SERVER_ERROR whenever any interceptor was in the chain (and @nestjs-cls/transactional always registers a passthrough interceptor). The fix shipped as nest-trpc-native@0.4.3 before milestone 6 of the reference app could land. Without a reference app exercising the composition under load, that bug would have hit production for someone else first.

The stack

Layer Pick
API NestJS 11.x
RPC tRPC 11.x via nest-trpc-native
ORM Drizzle 0.45.x via nest-drizzle-native
DB (local) better-sqlite3 (zero-setup)
DB (prod recipe) Postgres via pg (documented swap)
Transactions @nestjs-cls/transactional with a custom sync adapter for better-sqlite3
Auth scrypt + HS256 JWT, all via node:crypto (no JWT lib dep)
Tests node:test + c8 coverage
Lint ESLint 10 flat config + sonarjs (cognitive complexity ceiling 15)

Runtime dependencies are pinned and every one of them has a one-line justification in CHANGELOG.md. Default to Node built-ins. New deps need explicit acceptance in the PR.

Module shape

👁 reference-app as an architecture city: a central reference-app building wires nine feature modules (auth, users, projects, memberships, onboarding, audit, outbox, trpc, db) via glowing teal paths; two red arcs trace the cross-cutting concerns — tx (transactions) and auth (request context)

reference-app (center) wires the nine feature modules around it. Two concerns cut across the architecture: *tx** — a @Transactional() method spans users, memberships, projects, audit, outbox in one transaction — and auth — the request-scoped CURRENT_USER / CURRENT_ORGANIZATION context is consumed by every procedure.*

AppModule wires DatabaseModule (with DrizzleModule.forRoot), ClsModule (with ClsPluginTransactional), AuthModule, RequestContextModule, one module per feature (organizations, users, projects, audit-log, outbox, onboarding), and AppTrpcModule (with TrpcModule.forRoot). Every feature module is the same four files: <feature>.repository.ts (@DrizzleRepository), <feature>.service.ts (business logic, reads CURRENT_USER / CURRENT_ORGANIZATION), <feature>.router.ts (@Router('…') + @UseGuards(AuthGuard)), <feature>.module.ts (DrizzleModule.forFeature([Repo]) + service + router). Shape borrowed from nest-drizzle-native's sample-17.

Multi-tenant auth, end-to-end

One Express middleware reads the Authorization: Bearer … header, calls AuthService.resolve(token) (HS256 verify via node:crypto), and sets req.authContext = { user, organization }. That single shape is then consumed three ways:

  • REST controllers@CurrentUser() / @CurrentOrganization() param decorators read it via switchToHttp().getRequest().
  • tRPC proceduresTrpcModule.forRoot({ createContext: ({ req }) => ({ authContext: req.authContext ?? null }) }) puts the same value on the tRPC ctx. The same param decorators fall through getArgs()[1] first, so one implementation serves both transports.
  • Request-scoped DIRequestContextModule exposes CURRENT_USER and CURRENT_ORGANIZATION as Scope.REQUEST providers backed by req.authContext. Services inject them directly, no threading through every method signature.

JWT signing and verification: ~50 lines on top of node:crypto's HMAC, no JWT library dep. Password hashing: scrypt with the format scrypt$<salt-hex>$<hash-hex> — same helpers in the seed and the auth service so seeded admins can log in immediately.

The central proof — a five-step @Transactional() workflow

The brief calls it "the central proof": one method that writes across five tables inside a single transaction, then queues a post-commit side effect via the outbox pattern. If transactions don't compose cleanly across services, this is where it breaks.

👁 inviteUser as a pipeline: five numbered steps — users, memberships, projects, audit_events, outbox_events — flow through a transaction; a commit valve releases the queued outbox event to the worker

Five steps inside one transaction (1. users, 2. memberships, 3. projects, 4. audit_events, 5. outbox_events), then commit, then the worker delivers the post-commit side effect.

The annotated body:

@Transactional()
inviteUser(input: InviteUserInput): Promise<InviteUserResult> {
 const user = this.upsertUser(input.email, input.initialPassword);
 const membership = this.memberships.create({ /* orgId, userId, role */ });
 const project = this.projects.create({ /* orgId, name, createdBy */ });
 this.audit.record({ /* "user.invited" with invitee+project metadata */ });
 const event = this.outbox.enqueue({
 topic: 'user.invited',
 payload: { invitedEmail: input.email, /* … */ },
 idempotencyKey: `user.invited:${input.orgId}:${user.id}:${project.id}`,
 });
 return { user, membership, project, outboxEventId: event.id };
}

Two non-obvious choices in the wiring around it:

  1. @InjectTransaction(), not @InjectDrizzle(). The CLS proxy resolves to the active tx client inside @Transactional and falls back to the raw client outside one — transparent to the repo. @InjectDrizzle() always returns the raw client, so writes from inside the transactional method would not participate in the transaction. Easy to miss; the symptom is "everything looks fine, nothing rolls back."
  2. A custom sync transactional adapter for better-sqlite3. The official @nestjs-cls/transactional-adapter-drizzle-orm wraps the inner Drizzle callback in async, which silently commits empty transactions against synchronous sqlite (its client.transaction(fn) is sync and treats the async callback's immediately-returned Promise as a successful return). The repo ships a ~30-line SyncDrizzleTransactionalAdapter that keeps the inner callback synchronous while still returning a Promise to satisfy the plugin contract. Swap to the official adapter when moving to libsql or Postgres.

The brief mandates three tests around this method, and all three pass:

  • Happy path — all five rows persisted; the outbox row is visible after commit; the worker tick processes it; FakeEmailTransport records exactly one email.
  • Rollback safety — force a throw between project insert and the audit event; assert zero rows from this transaction persist; assert no email recorded.
  • Worker crash recovery — seed an outbox row in processing state with a stale claimed_at; the next claimer tick re-claims it (under the stuck-timeout), processes it exactly once, and a follow-up tick is a no-op.

The outbox + worker

👁 The outbox lifecycle: a worker picks up envelopes from a

Rows go pending → processing → completed. Retryable errors bounce back to pending (attempts++ + backoff). Max attempts go to failed. Stuck processing rows get re-claimed by another worker after a timeout.

Three small pieces, one file each: OutboxProducer inserts a pending row inside the active tx with a partial-unique idempotency key (multiple NULLs coexist, non-null is unique). OutboxRegistry is a Map<topic, handler> populated by handlers on module init. OutboxClaimer.tick() opens a tx, selects pending-OR-stuck rows, marks them processing, dispatches, then marks completed / retries with backoff+jitter / marks failed at max attempts. The claim is the "BEGIN IMMEDIATE + status filter" shape from the brief; Postgres would use SELECT … FOR UPDATE SKIP LOCKED.

The worker process is just scripts/start-worker.ts — boot a headless Nest application context, resolve OutboxClaimer, tick on a configurable interval, abort cleanly on SIGTERM/SIGINT. Same Docker image runs either the API (default CMD) or the worker (node dist/scripts/start-worker.js override); docker-compose.yml wires both on a shared SQLite volume with a healthcheck.

Typed tRPC client smoke

The brief's "definition of done" requires a typed client that consumes the generated AppRouter and exercises one query, one mutation, and one auth-protected call against a live local server. That lives in client-smoke/ and is part of npm run ci:

import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../src/@generated/server';

const anon = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: `${baseUrl}/trpc` })] });

const ping = await anon.ping.query(); // query
const login = await anon.auth.login.mutate({ email, password }); // mutation

const auth = createTRPCClient<AppRouter>({
 links: [httpBatchLink({
 url: `${baseUrl}/trpc`,
 headers: () => ({ authorization: `Bearer ${login.token}` }),
 })],
});
const me = await auth.users.me.query(); // auth-protected query

The compiled output (which lands in CI as npm run client-smoke:typecheck) catches any router-shape change that would break a real client.

Try it in 30 seconds

git clone https://github.com/nest-native/reference-app
cd reference-app
nvm use && npm install
DATABASE_URL=./reference-app.db npm run db:migrate
DATABASE_URL=./reference-app.db npm run seed

AUTH_SECRET=dev-secret-must-be-at-least-32-characters-xxxxx \
 DATABASE_URL=./reference-app.db \
 npm run start:dev

Then in another terminal:

AUTH_SECRET=dev-secret-must-be-at-least-32-characters-xxxxx \
 DATABASE_URL=./reference-app.db \
 npm run start:worker

The seed creates admin@acme.test / admin123! with one starter project. client-smoke walks the typed end-to-end flow.

What it deliberately is not

Restating these because they shape what shouldn't be added:

  • Not a CLI (create-nest-native-app). Permanent maintenance cost, marginal value over a well-organized template repo.
  • Not the home of a standalone outbox package. The outbox pattern lives here as an in-app module. A hypothetical nest-outbox-native extraction (no such package exists today) would only be worth considering after three+ real apps independently rewrite the same shape.
  • Not a frontend. client-smoke/ is a typed-client smoke test, not a UI.
  • Not multi-database / GraphQL / micro-frontends. Resist scope creep.

If a pattern repeats three times here, it's a candidate for upstreaming to nest-drizzle-native or nest-trpc-native — not for a local helper.

Where to find it

The org as a whole: nest-native.dev · github.com/nest-native.

If you build something on top, open an issue — pattern repetition is what tells us when an idea is ready to graduate from "this is how the reference app does it" to "this is shipped library API."