Executive Summary:
Prisma is a type‑safe ORM for Node.js and TypeScript. It replaces most handwritten SQL and builds a strongly‑typed client from a schema you control. In this guide, you’ll set up Prisma with PostgreSQL 15, use it from NestJS (API) and Next.js (web), run it in Docker Compose, and adopt production‑grade workflows—migrations, seeding, transactions, RBAC, multi‑tenancy, performance, observability, and CI/CD. Copy‑paste snippets are included.
Table of Contents
-
What is Prisma? When to use it
-
Architecture overview (Prisma schema → Client → DB)
-
Quick Start: Docker Compose + Postgres + Prisma
-
Initialize Prisma in a monorepo (apps/api, apps/web, packages/db)
-
Modeling the schema (Users, Roles, Sessions, Audit Logs)
-
Migrations workflow (dev → staging → prod)
-
Using Prisma Client from NestJS (services, modules, DI)
-
Using Prisma Client from Next.js (server actions / API routes)
-
CRUD patterns: select/include, pagination, filtering
-
Transactions and consistency (interactive + batch)
-
RBAC in practice (policies, guards, scoping)
-
Multi‑tenancy strategies (single DB, per‑tenant schema, per‑DB)
-
Seeding strategies (idempotent seeders)
-
Performance and indexing best practices
-
Soft deletes, partial unique indexes, and archival
-
Middleware, hooks, and auditing
-
Error handling (Prisma errors → API responses)
-
Testing with a disposable Postgres (Jest/Vitest)
-
CI/CD with GitHub Actions (migrate deploy)
-
Observability (metrics, slow query logs)
-
Backups and restore basics
-
Common pitfalls & troubleshooting
-
Prisma CLI cheat‑sheet
1) What is Prisma? When to use it
Prisma turns your DB schema into a type‑safe client. You define models in schema.prisma
; Prisma generates a client (@prisma/client
) that knows your tables, relations, and types.
Use Prisma when you want:
-
End‑to‑end TypeScript safety (compile time catching of schema mismatches).
-
Faster iteration with migrations and a declarative model.
-
Rich relations and query ergonomics without fragile SQL string building.
Skip Prisma when you need vendor‑specific features extensively, custom SQL tuning everywhere, or your team prefers a query builder/raw SQL approach for full control.
2) Architecture overview
┌──────────────┐ prisma migrate ┌─────────────┐ type‑safe queries ┌──────────────┐
│ schema.prisma├────────────────────▶│ database │◀──────────────────────────│ Prisma Client │
└──────┬───────┘ └─────────────┘ └──────┬───────┘
│ prisma generate import/use
▼
node_modules/@prisma/client (generated)
3) Quick Start: Docker Compose + Postgres + Prisma
docker-compose.yml (dev):
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: appdb
ports: ["5432:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
.env (root):
DATABASE_URL="postgresql://app:app@localhost:5432/appdb?schema=public"
Install & init
pnpm add -D prisma
pnpm add @prisma/client
npx prisma init --datasource-provider postgresql
This creates prisma/schema.prisma
and .env
(uses DATABASE_URL
).
4) Initialize Prisma in a monorepo
Recommended structure:
apps/
api/ (NestJS)
web/ (Next.js)
packages/
db/
prisma/
schema.prisma
src/
client.ts
packages/db/src/client.ts (singleton):
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ?? new PrismaClient({
log: [{ level: 'query', emit: 'event' }, 'info', 'warn', 'error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
Then import prisma
from packages/db
in both api
and web
.
5) Modeling the schema
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
audit AuditLog[]
}
enum Role {
USER
ADMIN
}
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id])
action String
entity String?
entityId String?
meta Json?
createdAt DateTime @default(now())
}
Apply migration:
npx prisma migrate dev --name init
Generate client:
npx prisma generate
6) Migrations workflow
-
Local dev:
migrate dev
creates SQL and applies it. -
CI/CD: use
prisma migrate deploy
(applies existing migrations only; no drift). -
Hot‑fix: create a new migration; avoid editing old migrations after they shipped.
-
Branching: rebase or re‑create migrations on feature branches to minimize conflicts.
Production command (idempotent):
npx prisma migrate deploy
7) NestJS usage
PrismaModule (simple):
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { prisma } from '@acme/db';
@Module({ providers: [{ provide: 'PRISMA', useValue: prisma }], exports: ['PRISMA'] })
export class PrismaModule implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await prisma.$connect(); }
async onModuleDestroy() { await prisma.$disconnect(); }
}
UsersService:
import { Inject, Injectable } from '@nestjs/common';
import { PrismaClient, Role } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(@Inject('PRISMA') private db: PrismaClient) {}
findPage = (page=1, size=20) => this.db.user.findMany({
skip: (page-1)*size,
take: size,
select: { id: true, email: true, role: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
create = (email: string, name?: string) => this.db.user.create({
data: { email, name, role: Role.USER },
select: { id: true, email: true },
});
}
8) Next.js usage
Server action / route handler:
// apps/web/app/api/me/route.ts
import { prisma } from '@acme/db';
export async function GET() {
const me = await prisma.user.findFirst({ select: { id: true, email: true } });
return Response.json(me);
}
Avoid multiple clients by importing the shared singleton from packages/db
.
9) CRUD patterns
Create
await prisma.user.create({ data: { email: 'a@b.com', name: 'A' } });
Read with relation
await prisma.user.findUnique({
where: { id },
include: { sessions: { select: { id: true, expiresAt: true } } },
});
Update (partial)
await prisma.user.update({ where: { id }, data: { name: 'New' } });
Delete
await prisma.user.delete({ where: { id } });
Pagination
await prisma.user.findMany({
cursor: cursorId ? { id: cursorId } : undefined,
take: 25,
orderBy: { createdAt: 'desc' },
});
Filtering
await prisma.user.findMany({ where: { email: { contains: '@domainindia.com', mode: 'insensitive' } } });
10) Transactions
Batch
await prisma.$transaction([
prisma.user.create({ data: { email: 'x@y.com' } }),
prisma.auditLog.create({ data: { action: 'USER_CREATE', entity: 'User' } }),
]);
Interactive
await prisma.$transaction(async (tx) => {
const u = await tx.user.create({ data: { email: 'z@y.com' } });
await tx.auditLog.create({ data: { action: 'USER_CREATE', entity: 'User', entityId: u.id } });
});
11) RBAC in practice
-
Add
Role
enum in schema (already shown) and read it from JWT/session. -
Guard DB queries by scoping (e.g., tenantId) and role checks at service layer.
-
Example: only admins can list all users; users can only read themselves.
if (role !== 'ADMIN') return prisma.user.findUnique({ where: { id: userId } });
return prisma.user.findMany({ take: 50 });
12) Multi‑tenancy strategies
-
Single DB, tenantId column (simplest)
-
Single DB, per‑tenant schema (requires connection switching or
search_path
) -
Per tenant DB (isolation; heavier ops)
Tenant column approach:
model Project {
id String @id @default(cuid())
tenantId String @index
name String
createdAt DateTime @default(now())
}
Always add where: { tenantId }
in queries (wrap in service/helpers to avoid leaks).
13) Seeding strategies
Enable prisma/seed.ts
in package.json
:
{
"prisma": { "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" }
}
prisma/seed.ts
import { PrismaClient, Role } from '@prisma/client';
const prisma = new PrismaClient();
async function main(){
await prisma.user.upsert({
where: { email: 'admin@domainindia.com' },
update: {},
create: { email: 'admin@domainindia.com', role: Role.ADMIN, name: 'Admin' },
});
}
main().finally(()=>prisma.$disconnect());
Run: npx prisma db seed
14) Performance & indexing
-
Use
select
to avoid over‑fetching. -
Add indexes for frequent filters (
@@index([createdAt])
,@@index([tenantId, createdAt])
). -
Prefer cursor pagination for large lists.
-
Batch with
$transaction
to reduce round trips. -
For heavy analytics, use
prisma.$queryRaw
(parameterized!) or a warehouse.
Example index:
model AuditLog {
id String @id @default(cuid())
action String
createdAt DateTime @default(now())
@@index([action, createdAt])
}
15) Soft deletes & unique constraints
Add deletedAt
and partial unique indexes (Postgres):
model Customer {
id String @id @default(cuid())
email String
deletedAt DateTime?
@@unique([email], map: "ux_customer_email_active")
}
Then enforce uniqueness only for active rows with a partial index via SQL migration:
-- migration.sql
CREATE UNIQUE INDEX ux_customer_email_active
ON "Customer" ("email")
WHERE "deletedAt" IS NULL;
Queries should add { where: { deletedAt: null } }
by default.
16) Middleware & auditing
Prisma supports middlewares:
prisma.$use(async (params, next) => {
const result = await next(params);
if (['create','update','delete'].includes(params.action)) {
await prisma.auditLog.create({
data: { action: `${params.model}.${params.action}`, entity: params.model, meta: params.args }
});
}
return result;
});
Use caution to avoid recursion (log with a separate client or conditionally skip AuditLog
).
17) Error handling
Map known errors:
import { Prisma } from '@prisma/client';
try {
// ...
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') return { status: 409, message: 'Unique constraint failed' };
if (e.code === 'P2025') return { status: 404, message: 'Record not found' };
}
throw e;
}
18) Testing
Spin up test DB via Docker; run migrations before tests:
docker run --rm -p 5433:5432 -e POSTGRES_PASSWORD=test -e POSTGRES_USER=test -e POSTGRES_DB=testdb postgres:15
DATABASE_URL="postgresql://test:test@localhost:5433/testdb" npx prisma migrate deploy
Use a fresh schema per test file or transactions with rollback.
19) CI/CD (GitHub Actions)
name: ci
on: [push]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: appdb
ports: ["5432:5432"]
options: >-
--health-cmd="pg_isready -U app" --health-interval=10s --health-timeout=5s --health-retries=5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: pnpm i --frozen-lockfile
- run: npx prisma generate
- run: npx prisma migrate deploy
- run: pnpm test
Deploy step (container): run prisma migrate deploy
on startup before serving traffic.
20) Observability
-
Enable Prisma query logging in dev.
-
Pipe Postgres logs to your stack (e.g., Grafana Loki).
-
Sample slow query detector: wrap calls and log when > N ms.
21) Backups & restore
-
Nightly logical backups with
pg_dump
(retain 7/30/90 days). -
Periodic restore drills in a staging DB.
-
For Docker, mount a backup volume or push to S3/Backblaze.
22) Common pitfalls
-
Multiple clients in hot‑reload: use a global singleton (provided above).
-
Missing
migrate deploy
in prod: schema drift and runtime errors. -
Forgetting indexes on filter columns → slow queries.
-
Unbounded
findMany
→ memory pressure; always page. -
Recursive middleware when logging AuditLog; guard it.
23) Prisma CLI cheat‑sheet
prisma init # create prisma/ and schema.prisma
prisma generate # regen client after schema change
prisma migrate dev --name xyz # create & apply migration (dev)
prisma migrate deploy # apply pending migrations (prod/stage)
prisma db seed # run seed script
prisma studio # web UI for DB browsing (dev only)
Next steps for your stack (Domain India / HostCore)
-
Put
packages/db
in the monorepo and importprisma
in NestJS API and Next.js web. -
Add baseline tables you already planned (users/roles/clients/settings/audit_logs) and migrate.
-
Build idempotent seeds for the admin user and mandatory settings.
-
Wire CI/CD to run
migrate deploy
before starting containers in production.
If you want, I can tailor this to your exact monorepo (service names, existing models, and GitHub Actions you’re running) and include guarded raw SQL examples for reporting/perf‑critical paths.