TL;DR:
nest-native/reference-appis a production-shaped multi-tenant work-tracking SaaS — think a small Linear — that composes all six 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, 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.
What changed since this post first went up
This post originally covered a two-library app (nest-drizzle-native + nest-trpc-native). Since then:
- The libraries moved to the
nest-nativeorg and are published scoped:@nest-native/drizzleand@nest-native/trpc. - The family grew to six packages —
@nest-native/kafka,@nest-native/messaging,@nest-native/asyncapi, and@nest-native/ai-sdkjoined. - The reference app grew from "prove the wiring" into a story: one product where each library earns its place, and where the reliable-messaging half was battle-tested hard enough that it got extracted into a library.
Everything below describes the app as it is today.
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?
- 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 six 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:
-
An org invites a teammate — one
@Transactional()method writes the user, membership, project, and audit rows and enqueues auser.invitedevent, atomically. -
They work tasks — create → assign → complete; each write emits
task.created/task.assigned/task.completedin the same transaction. - 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.
-
The contracts are published — an AsyncAPI 3.0 catalog at
/asyncapidocuments every event, generated from the same Zod schemas the code validates with. - AI reads the activity — a streaming assistant turns a project's recent activity into a status update, token by token.
| 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/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 still the crown jewel. inviteUser() writes five rows — user, membership, project link, audit event, and the outbox event — inside a single @Transactional() method:
@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}`,
});
}
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 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_BROKERSand the same domain code relays throughKafkaOutboxTransport, with@KafkaConsumers (via@nest-native/kafka, on Confluent's official client) on the other side. Same event bodies, same dedup keys, same wire headers.
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-end —
activity.listreturns realDateobjects, 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
errorFormatterflattensZodErrors so a failing mutation surfaceserror.data.zodError.fieldErrors.titleon the typed client. -
Cache headers where they're safe —
responseMetasetscache-controlonly on the publicpingquery; 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.
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 ≥ 22 — ai@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
- tRPC at
/trpc, health at/health, the AsyncAPI catalog at/asyncapi, the AI assistant atPOST /projects/1/assistant - Seed login:
admin@acme.test/admin123! - The outbox worker runs as its own process:
npm run start:worker - No Docker, no broker, 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. - 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 (killingas unknown as Record<string, unknown>casts), the in-memory Kafka broker gaining an awaitableidle()(killingsleep(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, 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 affiliated with the NestJS core team.
Where to find it
- The app: github.com/nest-native/reference-app — start with the README's story, then
docs/architecture.md - The org + all six packages: github.com/nest-native
- Docs hub: nest-native.dev
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)