DEV Community

Cover image for A NestJS reference app that proves seven nest-native libraries under realistic backend pressure
Rodrigo Nogueira
Rodrigo Nogueira

Posted on • Edited on • Originally published at nest-native.dev

A NestJS reference app that proves seven nest-native libraries under realistic backend pressure

TL;DR: nest-native/reference-app is a production-shaped multi-tenant work-tracking SaaS — think a small Linear — that composes all seven nest-native libraries the way a real product would: Drizzle persistence, a typed tRPC API, domain events emitted in the same transaction as the writes, a Kafka backbone with exactly-once consumer effects, a published AsyncAPI 3.0 catalog, background jobs in the same database (no Redis), and a streaming AI assistant. Every guarantee has a test. It runs with zero infrastructure by default (SQLite, no broker, offline AI), and the same domain code runs against real Kafka by setting one env var.

The org, in one paragraph

nest-native publishes decorator-first NestJS integrations that should feel like official NestJS packages — modules, DI, decorators, guards/interceptors, lifecycle hooks — while staying honest about the tool underneath. Drizzle stays SQL-first; tRPC stays tRPC; the outbox is a real table, not magic. Every package ships with "dependencies": {} (you install only the peers you use), 100% test coverage as a hard CI gate, runnable samples, and documented non-goals.

The problem this app exists to solve

Library docs cover their slice; nobody covers the seams. And the seams are where backends actually get built:

  • How does "current user / current organization" reach a tRPC procedure, a guard, and a repository three calls deep — through one request-scoped mechanism?
  • How do I write a row and publish an event without a crash window between them?
  • How do consumers survive Kafka's at-least-once redelivery without double-applying effects?
  • How do I schedule deferred work — reminders, follow-ups — atomically with the write that caused it, without adding Redis to my stack just to run it?
  • Where do other teams find my event contracts — and how do I keep those contracts from drifting apart across producer, consumer, and docs?
  • How do I ship a streaming AI endpoint that tests offline, deterministically, in CI?

The reference app answers all of these in one codebase, as a coherent product rather than a pile of demos.

The story: one journey through seven libraries

The product is a multi-tenant work-tracking SaaS. Follow one journey and every library shows up exactly where a real system would reach for it:

  1. An org invites a teammate — one @Transactional() method writes the user, membership, project, and audit rows and enqueues a user.invited event, atomically.
  2. They work tasks — create → assign → complete; each write emits task.created / task.assigned / task.completed in the same transaction.
  3. Events flow over the backbone — a background claimer relays committed events (in-process by default; Kafka in production), and consumers build an activity feed read-model, deduplicated against redelivery.
  4. Assignment schedules deferred work — the same delivery that projects task.assigned into the feed also enqueues a delayed assignment-reminder job, in the same transaction, keyed by the same dedup key. A worker fires it exactly once when due. The queue is a table in the same database.
  5. The contracts are published — an AsyncAPI 3.0 catalog at /asyncapi documents every event, generated from the same Zod schemas the code validates with.
  6. AI reads the activity — a streaming assistant turns a project's recent activity into a status update, token by token.

reference-app as an architecture city: a central reference-app building wires the feature modules (db, auth, users, projects, tasks, activity, onboarding, audit, reminders, outbox, inbox, events-catalog, assistant, trpc) via glowing teal paths; two red arcs trace the cross-cutting concerns — transactions and auth request context

Library Its job in the story
@nest-native/drizzle Persistence: repositories (@DrizzleRepository, @InjectTransaction), tenant-scoped queries, migrations
@nest-native/trpc The typed API: @Router/@Query/@Mutation, generated AppRouter, superjson, guards
@nest-native/messaging Reliable events: transactional outbox + idempotent inbox on Drizzle
@nest-native/kafka The backbone: KafkaOutboxTransport + @KafkaConsumer read-models
@nest-native/jobs Deferred work: the assignment reminder — enqueued transactionally, deduped, executed exactly once
@nest-native/asyncapi The event catalog other teams integrate against
@nest-native/ai-sdk The streaming assistant (@AiStream), offline-testable

The central proof: five writes, one transaction, zero lost events

The onboarding workflow is the crown jewel. inviteUser() writes five rows — user, membership, project link, audit event, and the outbox event — inside a single @Transactional() method:

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

@Transactional()
inviteUser(input: InviteUserInput) {
  const user = this.users.create(...);
  this.memberships.create(...);
  // ...audit row...
  this.outbox.enqueue({
    topic: 'user.invited',
    payload,                        // a plain typed interface — no casts
    idempotencyKey: `user.invited:${orgId}:${user.id}`,
  });
}
Enter fullscreen mode Exit fullscreen mode

A throw anywhere rolls back everything — no phantom event. A crash after commit loses nothing — the event is durably in the database. The tests prove both directions (happy path, rollback safety, crash recovery), because "trust me" is not a delivery guarantee.

No lost events, no double effects

The dual-write problem ("write the row, then publish — and pray nothing crashes in between") is solved by @nest-native/messaging: the enqueue above is just an insert into an outbox_events table, and a background claimer relays committed rows with retry/backoff and stuck-claim recovery:

The delivery lifecycle shared by the outbox and the jobs queue: a worker picks up envelopes from a

The consumer half is the mirror image: delivery is at-least-once by contract, so the idempotent inbox dedups redeliveries via a unique (source, message_key) row written in the same transaction as the side effect — exactly-once effects, provable in the database. The app's activity feed is built exactly this way, and the test suite redelivers messages on purpose and asserts the side effect ran once.

Two profiles, one codebase:

  • Default — no broker: the outbox dispatches through an in-process topic→handler registry (@nest-native/messaging/in-process). SQLite in a file. This is what CI runs.
  • Kafka — set KAFKA_BROKERS and the same domain code relays through KafkaOutboxTransport, with @KafkaConsumers (via @nest-native/kafka, on Confluent's official client) on the other side. Same event bodies, same dedup keys, same wire headers.

Deferred work in the same database (no Redis)

The newest library closes a gap the rest of the story kept pointing at: "…and then, later, do something." In NestJS the standard answer to reminders, follow-ups, and cleanups is BullMQ — which means running Redis. @nest-native/jobs puts the job queue in the database you already have (SQLite/Postgres/MySQL, on Drizzle), with @JobHandler classes, retry with backoff, scheduling, and unique-key dedup. If the lifecycle picture above looks like a job queue — that's because it is one: jobs runs the same hardened claim/retry engine the outbox relay proved in this app.

The app's use case: when a task is assigned, send a reminder later. The interesting part is where the job is created — inside the same transaction as the activity-feed projection of the task.assigned event:

@Transactional() // under Kafka this JOINS the inbox's dedup transaction
apply(payload: TaskAssignedPayload) {
  const activity = taskAssignedActivity(payload);
  const inserted = this.activity.record(activity); // (org_id, dedup_key) unique
  if (inserted) {
    this.jobs.enqueue({
      name: 'task.assignment-reminder',
      payload,                       // the event payload IS the job payload — one Zod contract
      delayMs: this.reminderDelayMs, // TASK_REMINDER_DELAY_MS
      uniqueKey: activity.dedupKey,  // the SAME key that dedups the event
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

One delivery, one transaction, one deferred reminder

One delivery ⇒ one feed row ⇒ one scheduled reminder — atomic, because the dedup row, the feed row, and the job row commit together. And redelivery can never schedule a second reminder, through two independent layers: the feed's (org_id, dedup_key) unique index skips the enqueue for an already-projected event, and the job's uniqueKey (the same dedup key) turns a concurrent duplicate into a no-op that returns the existing row.

When the reminder comes due, a handler class picks it up. A payload that can never succeed fails immediately — no retries burned:

@JobHandler('task.assignment-reminder')
export class AssignmentReminderHandler implements JobHandler {
  handle(payload: Record<string, unknown>, ctx: JobContext): void {
    if (!isTaskAssignedPayload(payload)) {
      throw new PermanentError('task.assignment-reminder: malformed payload');
    }
    // record the notification (audit entry with ctx.jobId / ctx.attempt)
  }
}
Enter fullscreen mode Exit fullscreen mode

The integration test redelivers the event on purpose, enqueues a duplicate on purpose, drains the queue twice, and asserts: one job, one reminder, unique key released on completion. And the worker didn't become a second process — npm run start:worker drains the outbox and runs due jobs: two library-provided loops, one shared graceful shutdown.

A typed API that doesn't lie

The tRPC layer (@nest-native/trpc) is decorator-first (@Router('tasks'), @Query, @Mutation, Zod I/O) with a generated AppRouter consumed by a typed client in CI. Three details worth stealing:

  • superjson end-to-endactivity.list returns real Date objects, and the generated router carries a type-level transformer marker: a client that forgets to configure superjson fails to compile. Both directions are CI steps.
  • Validation errors clients can use — an errorFormatter flattens ZodErrors so a failing mutation surfaces error.data.zodError.fieldErrors.title on the typed client.
  • Cache headers where they're saferesponseMeta sets cache-control only on the public ping query; everything tenant-scoped stays uncached, and tests assert both presence and absence.

Your events, documented

@nest-native/asyncapi publishes an AsyncAPI 3.0 document at /asyncapi (plus -json/-yaml) describing all four domain events. The payload schemas are defined once as Zod objects — the same definitions derive the TypeScript types (z.infer), the runtime guards (safeParse), and the catalog schemas. Producer, consumer, and documentation cannot drift apart, and the document validates against @asyncapi/parser in the test suite. (The reminder job reuses the task.assigned guard — the job payload can't drift from the event contract either.)

AI over your data, tested offline

POST /projects/:id/assistant streams a status update summarizing the project's recent activity, via @nest-native/ai-sdk's @AiStream (SSE, abort on client disconnect). By default it streams from an offline mock model built from the real activity digest — deterministic, no API key, CI-safe. Set OPENAI_API_KEY and a real provider swaps in with no code change. (Adopting the AI SDK is also why the app requires Node ≥ 22ai@7 demands it.)

Try it in 30 seconds

git clone https://github.com/nest-native/reference-app && cd reference-app
nvm use            # Node >= 22
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
Enter fullscreen mode Exit fullscreen mode
  • tRPC at /trpc, health at /health, the AsyncAPI catalog at /asyncapi, the AI assistant at POST /projects/1/assistant
  • Seed login: admin@acme.test / admin123!
  • The background worker runs as its own process: npm run start:worker — it relays the outbox and executes due jobs
  • No Docker, no broker, no Redis, no API keys required for any of it

The loop that makes it honest

The app isn't just a showcase — it's the feedback loop for the libraries, and it has teeth. Recent examples, all shipped upstream because building this app surfaced them:

  • The in-process outbox transport was hand-rolled here first, proved generic, and was extracted into @nest-native/messaging/in-process.
  • @nest-native/jobs exists because of this loop: the claim/retry/backoff engine hardened here as the outbox relay was generalized into a job queue — and when this app adopted it back for the assignment reminder, the dogfood PR found zero API warts. Porting a battle-tested engine is what that buys.
  • The app's hand-written AI mock became @nest-native/ai-sdk/testing (createMockLanguageModel) — and deleted every copy-pasted mock in the family.
  • enqueue() becoming generic (killing as unknown as Record<string, unknown> casts), the in-memory Kafka broker gaining an awaitable idle() (killing sleep(50) in tests), and AsyncAPI accepting Zod schemas directly — all of it started as friction in this codebase.
  • The app's install even caught a broken peer-dependency range in a fresh release before any user hit it.

If something feels awkward here, that's treated as a library bug — fixed upstream, in its own PR, with its own tests.

What it deliberately is not

  • Not a starter kit or boilerplate — it optimizes for being read, not forked. Copy patterns, not the repo.
  • Not 100%-covered — the 100% bar belongs to the libraries; the app's suite is pragmatic and targets the guarantees (rollback, crash recovery, dedup, exactly-once jobs, catalog validity, stream framing).
  • Not a CDC system — the outbox is the app-level answer; Debezium-style log tailing is a different trade-off and an explicit non-goal.
  • Not a BullMQ replacement — if you already run Redis and need its throughput, BullMQ is the right tool; @nest-native/jobs is for apps that want deferred work in the database they already operate.
  • Not affiliated with the NestJS core team.

Where to find it

Feedback, issues, and "this seam is still awkward" reports are the most useful thing you can send — that's literally the mechanism this whole stack improves by.

Top comments (0)