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.16.1 | 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.7.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.7.2 | 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 22.13+ within 22.x or Node.js 24.x; Prisma 7 | Generated-client output and a driver adapter; audit-log sets the shared Node 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.
For current applications, pair audit-log 0.5.0 with soft-delete 0.7.2. Apply the extensions in the fixed tenancy → audit-log → soft-delete order, opt into tenancy's interactive-transaction support, and use atomic-required for both automatic tracking and soft-delete lifecycle auditing:
const lifecycleModels = ['User'];
const lifecycleBatchCap = 1000;
const currentClient = basePrisma
.$extends(createPrismaTenancyExtension(tenancyService, {
autoInjectTenantId: true,
tenantIdField: 'tenantId',
interactiveTransactionSupport: true,
}))
.$extends(createAuditExtension({
consistency: 'atomic-required',
trackedModels: lifecycleModels,
maxBatchRecords: lifecycleBatchCap,
databaseMapping: { User: { tableName: 'users' } },
prismaModule,
}))
.$extends(createPrismaSoftDeleteExtension({
softDeleteModels: lifecycleModels,
auditLifecycle: 'atomic-required',
auditMaxBatchRecords: lifecycleBatchCap,
dmmf: prismaDmmf,
}));
await currentClient.withAuditTransaction((tx) =>
tx.user.delete({ where: { id: userId } }),
);Keep the same model set, deployed table/key mapping, batch cap, and DMMF across the extension and SoftDeleteModule configuration. Cascade children must be included too, and restore or purge service calls must run inside withAuditTransaction() through the same fully composed client. Explicit best-effort remains intentionally non-atomic: rollback can leave orphan success rows and transaction-local diffs can be stale. Audit-log accepts NestJS 12.0.1+, but this seven-package composition must use the intersection of every installed package's peer ranges.
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 for cascade, relation filters, and custom-primary-key audited bulk operations. Use the current Prisma Extension Chaining example instead of copying the snapshot's bootstrap. The supported current order is tenancy → audit-log → soft-delete, with the 0.7.2 lifecycle bridge configured as shown above.
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.
EXTENDED_PRISMA must be a real provider alias for the exact fully composed client, not a token shown only in the module options:
export const EXTENDED_PRISMA = Symbol('EXTENDED_PRISMA');
@Global()
@Module({
providers: [
PrismaService,
{
provide: EXTENDED_PRISMA,
inject: [PrismaService],
useFactory: (prisma: PrismaService) => prisma.client,
},
],
exports: [PrismaService, EXTENDED_PRISMA],
})
export class PrismaModule {}The PrismaService.client in this alias is the tenancy → audit-log → soft-delete composition shown above. Import PrismaModule before registering SoftDeleteModule.
// app.module.ts
@Module({
imports: [
// Exposes the fully composed client as EXTENDED_PRISMA
PrismaModule,
// 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,
}),
}),
// Uses the same lifecycle contract as the fully composed client
SoftDeleteModule.forRoot({
softDeleteModels: lifecycleModels,
auditLifecycle: 'atomic-required',
auditMaxBatchRecords: lifecycleBatchCap,
dmmf: prismaDmmf,
prismaServiceToken: EXTENDED_PRISMA,
}),
// 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
That result is a limitation of the pinned historical snapshot. In a current application, audit-log 0.5.0 and soft-delete 0.7.2 provide the supported atomic bridge: use tenancy → audit-log → soft-delete, set auditLifecycle: 'atomic-required', and execute the delete inside withAuditTransaction() as shown in the migration checklist. Do not retrofit the new contract into only one side of the snapshot.
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": "alice@example.com"}'
# 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.