Example: SaaS API with Seven Packages
This guide walks through an inspected example-saas-api repository snapshot — a small NestJS project that shows where seven nestarc packages could compose in one application.
The source contains tenant context and Prisma extension wiring, global response wrapping, User CUD audit hooks, a feature-flag decorator, soft-delete interception, a paginated list, and idempotency decorators on writes. It is a composition snapshot, not a working or production-ready starter.
Package map
The versions below come from the package catalog. “In this snapshot” describes only the code that is actually checked into the example repository; it is not a claim that the example exercises every current package feature.
| Package | Current version | Current package scope | In this snapshot |
|---|---|---|---|
@nestarc/tenancy | 0.14.0 | PostgreSQL RLS, Prisma 7 query isolation, and tenant-aware cache keys | Reads X-Tenant-Id and applies the tenancy Prisma extension |
@nestarc/safe-response | 0.15.0 | Response wrapping with Swagger integration, field selection, error catalogs, and i18n | Registers the global response wrapper |
@nestarc/audit-log | 0.4.0 | Transaction-first Prisma CUD tracking, streaming export, retention, and durable delivery | Applies the earlier audit extension to User writes |
@nestarc/feature-flag | 0.5.0 | DB-backed flags, cache adapters, rollouts, tenant overrides, and an Admin API | Gates the analytics route with @FeatureFlag() |
@nestarc/soft-delete | 0.6.0 | Prisma soft-delete, relation filters, cascade, bulk restore, purge, and lifecycle events | Rewrites User deletes and filters deleted records |
@nestarc/pagination | 0.3.0 | Prisma cursor, keyset, and offset pagination with filters, sorting, and Swagger helpers | Parses and applies list pagination/filter options |
@nestarc/idempotency | 0.4.0 | Draft-07 Idempotency-Key, stable fingerprints, response/header replay, and Redis/Postgres storage | Uses MemoryStorage and protects write routes with the interceptor |
Snapshot versus current releases
The repository's verified main snapshot at 6af390e pins Prisma 6.19.3, the legacy prisma-client-js generator, and earlier nestarc package releases. The snippets below explain that snapshot. For a new application, use the catalog versions above and follow Getting Started, Prisma 7 Setup, and each package's installation page instead of upgrading the snapshot piecemeal.
Prerequisites
| Use case | Requirement | Support boundary |
|---|---|---|
| Inspect the checked-in snapshot | Git | Fixed commit only; no supported run path |
| Start a current application | Node.js ^20.19, ^22.12, or ^24.0; Prisma 7 | Generated-client output and a driver adapter; audit-log narrows the shared Node 24 range |
The current application path also needs PostgreSQL. No global Prisma installation is required.
Inspect the checked-in snapshot
git clone https://github.com/nestarc/example-saas-api.git
cd example-saas-api
git checkout 6af390e657072384d5c2a32465915102a64f62d1Known snapshot blockers
The checked-in snapshot is not end-to-end runnable as documented in its own source:
- its create handler omits required
tenantId, while the tenancy extension leavesautoInjectTenantIdat its defaultfalse; - its
package.jsonand lockfile disagree, sonpm ciexits before installation, and a fallbacknpm installstill leaves TypeScript build errors; - its sample tenant slugs do not pass tenancy's default UUID validator;
- audit-log and feature-flag are registered with
null as anyPrisma placeholders that are never replaced; - it applies audit-table DDL during application startup and does not include RLS policy SQL or
PREMIUM_ANALYTICSseed data.
Use the repository to inspect integration locations, not as a verified quick start. The current-package section below is a migration checklist, not a complete application; the linked package installation guides are the executable contracts.
Current application migration checklist
In an existing NestJS 10/11 application, install the seven packages and the shared Prisma 7/PostgreSQL dependencies:
npm install @nestarc/tenancy @nestarc/safe-response @nestarc/audit-log \
@nestarc/feature-flag @nestarc/soft-delete @nestarc/pagination \
@nestarc/idempotency @prisma/client @prisma/adapter-pg pg dotenv \
@nestjs/swagger class-transformer class-validator
npm install --save-dev prismaThen configure Prisma 7's prisma-client generator, explicit output directory, prisma.config.ts, and PostgreSQL driver adapter as shown in Prisma 7 Setup. Storage and optional peers are package-specific: in particular, use shared Redis or Postgres storage for production idempotency rather than MemoryStorage. Follow the installation guides for tenancy, safe-response, audit-log, feature-flag, soft-delete, pagination, and idempotency.
Audit-log 0.4 makes consistency required. For authoritative ordinary CUD records, choose atomic-required and run tracked writes inside withAuditTransaction(). In a tenant-scoped chain, opt into tenancy's interactive-transaction support and validate it against the exact Prisma version. Explicit best-effort preserves the legacy non-atomic behavior. The atomic lifecycle bridge described by audit-log 0.4 is not yet exposed by the currently published soft-delete 0.6 package, so current applications must keep soft-delete evidence on the event/manual-log path.
Snapshot project structure
src/
├── main.ts # Bootstrap
├── app.module.ts # 6 Nest modules registered
├── prisma.service.ts # PrismaClient with 3 chained extensions
└── users/
├── users.module.ts
└── users.controller.ts # 5 endpoints using the package integrationsSnapshot Step 1: Prisma Extensions
The snapshot uses Prisma 6's legacy @prisma/client output and chains the three extensions in this order:
// prisma.service.ts
this.extended = this
.$extends(createPrismaTenancyExtension(this.tenancyService)) // 1st: RLS
.$extends(createPrismaSoftDeleteExtension({ // 2nd: soft-delete
softDeleteModels: ['User'],
deletedAtField: 'deletedAt',
}))
.$extends(createAuditExtension({ // 3rd: audit
trackedModels: ['User'],
}));The snapshot types its extended client as any, which hides the required tenantId create field. In a current type-safe implementation, derive that field from tenant context and enable runtime overwrite as defense in depth:
const prisma = basePrisma.$extends(
createPrismaTenancyExtension(this.tenancyService, {
autoInjectTenantId: true,
tenantIdField: 'tenantId',
}),
);
const tenantId = this.tenancyService.getCurrentTenantOrThrow();
await prisma.user.create({ data: { name, email, tenantId } });Why this order matters:
- Tenancy first — Prisma runs query callbacks in registration order, so it establishes transaction-local
app.current_tenantbefore delegating. - Soft-delete second — rewrites
delete()to a tenant-scoped update through its captured lower client. - Audit-log last — in the pinned snapshot, it tracks writes that reach it but does not see soft-deletes rewritten by the earlier extension.
For a current Prisma 7 application, create the base client from the explicit generated output with a PostgreSQL driver adapter. Audit-log also needs the generated { Prisma } namespace, and soft-delete needs explicit DMMF when cascade or relation filters are enabled. Use the current Prisma Extension Chaining example instead of copying the snapshot's bootstrap. Until a compatible soft-delete release exposes the 0.4 lifecycle bridge, retain the documented event/manual-log integration for rewritten deletes.
Current registration map (abridged)
The following is an abridged current registration map. It assumes basePrisma and prismaModule = { Prisma } come from the Prisma 7 setup described above; use async registration when those values are provided by an injectable PrismaService.
// app.module.ts
@Module({
imports: [
// Extracts tenant from X-Tenant-Id header
TenancyModule.forRoot({
tenantExtractor: 'X-Tenant-Id',
}),
// Wraps all responses in { success, data, error }
SafeResponseModule.register(),
// Tracks who changed what, with before/after diffs
AuditLogModule.forRoot({
prisma: basePrisma,
prismaModule,
actorExtractor: (req) => ({
id: req.user?.id ?? null,
type: req.user ? 'user' : 'system',
ip: req.ip,
}),
}),
// DB-backed feature flags
FeatureFlagModule.forRoot({
environment: process.env.NODE_ENV ?? 'development',
prisma: basePrisma,
cacheTtlMs: 30_000,
}),
// Pagination module
PaginationModule.forRoot(),
// Idempotency — prevents duplicate processing on retries
IdempotencyModule.forRoot({
storage: new MemoryStorage(), // local development only
ttl: 86400,
}),
],
})
export class AppModule {}Each package has its own registration or extension point. If you remove one, also remove its Prisma extension, route decorators/interceptors, schema objects, and peer dependencies where applicable.
This map assumes trusted authentication middleware registered before audit middleware has already verified the credential and populated req.user; a later Nest guard is too late for audit's middleware-phase extractor. Never derive audit identity from a caller-controlled user header, and verify stored actor attribution in an integration test.
Snapshot Step 3: The Controller
A single controller shows the seven intended package integration points. These snippets preserve the inspected snapshot, including the known build/runtime defects called out above; do not copy them into a current application.
Snapshot create handler (known broken)
@Post()
@Idempotent()
@UseInterceptors(IdempotencyInterceptor)
async create(@Body() body: { name: string; email: string }) {
return this.prisma.extended.user.create({
data: { name: body.name, email: body.email },
});
}The snapshot intends this pipeline, but step 2 stops the request before the remaining effects can be relied on:
- idempotency — if the
Idempotency-Keyheader was seen before, replays the cached response (handler skipped) - tenancy — the snapshot establishes tenant context, but this create still fails because its required
tenantIdis absent and auto-injection is disabled - audit-log — would record the create after the required Prisma and audit storage wiring is repaired
- safe-response — would wrap a successful result in
{ success: true, data: { ... } }
List (pagination + soft-delete + tenancy)
@Get()
async findAll(@Paginate() query: PaginateQuery) {
return paginate(query, this.prisma.extended.user, {
sortableColumns: ['name', 'email', 'createdAt'],
filterableColumns: { role: ['$eq', '$in'], name: ['$ilike'] },
searchableColumns: ['name', 'email'],
});
}What happens:
- pagination — parses
?page=1&limit=10&sortBy=name:ASCfrom the query string - soft-delete — automatically adds
WHERE deleted_at IS NULLto exclude deleted records - tenancy — after you install the RLS policy, PostgreSQL returns only the current tenant's records
Delete (soft-delete + audit-log)
@Delete(':id')
async remove(@Param('id') id: string) {
return this.prisma.extended.user.delete({ where: { id } });
}What happens:
- soft-delete — converts
DELETEtoUPDATE SET deleted_at = now() - audit-log — the pinned ordering does not see this rewritten delete, so the snapshot does not provide authoritative soft-delete evidence
Audit-log 0.4 contains the transaction-side lifecycle bridge, but soft-delete 0.6 does not yet invoke it. Use a lifecycle-event listener for best-effort evidence, or an explicit tenant-scoped transaction with AuditService.log(input, tx) when the mutation and audit row must commit together.
Feature-flagged endpoint
@Get('analytics')
@FeatureFlag('PREMIUM_ANALYTICS')
async analytics() {
const count = await this.prisma.extended.user.count();
return { totalUsers: count };
}Returns 403 Forbidden unless the PREMIUM_ANALYTICS feature flag is enabled for the current tenant.
Illustrative request shapes after repairing the implementation
These shapes are not a runnable path for the pinned snapshot. They assume a separate implementation has replaced the placeholders, supplied typed tenantId data, enabled runtime tenant overwrite, installed RLS policies, and seeded the feature flag.
# Create a user (idempotent — safe to retry)
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000" \
-H "X-User-Id: admin-1" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"name": "Alice", "email": "[email protected]"}'
# List users (paginated)
curl "http://localhost:3000/api/users?page=1&limit=10" \
-H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000"
# Soft-delete
USER_ID='replace-with-a-user-id'
curl -X DELETE "http://localhost:3000/api/users/${USER_ID}" \
-H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000" \
-H "X-User-Id: admin-1" \
-H "Idempotency-Key: $(uuidgen)"
# Feature-flagged (will return 403)
curl http://localhost:3000/api/users/analytics \
-H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000"What's Not in This Example
This is intentionally minimal. A production app would also need:
- Authentication middleware (JWT, session, etc.)
- Validation (
class-validator+class-transformer) - RLS setup SQL (see tenancy docs)
- Feature flag seeding (create flags via the FeatureFlagService)
- Shared idempotency storage (Redis or Postgres instead of
MemoryStorage) - Current Prisma 7 bootstrap (generated output, Prisma Config, and a driver adapter)
- Package-specific migrations and production configuration from the current installation guides
- Swagger documentation (
@nestjs/swaggerintegration)
See the Adoption Roadmap for the recommended adoption path in your own project.