After five years building production platforms at scale, I've settled on a data layer pattern that just works: Prisma with NestJS: the clean setup. It keeps your services decoupled, avoids boilerplate, and scales gracefully from a single CRUD endpoint to a complex trading system. In this post, I'll walk you through the exact approach I use in every NestJS project.
Why Prisma + NestJS matters
When you're shipping features under pressure, your data layer becomes critical. Prisma gives you type-safe queries and automatic migrations; NestJS gives you modules and dependency injection. Combine them well, and you eliminate entire categories of runtime bugs.
Most teams either:
- Inject PrismaClient directly everywhere (no structure, hard to test)
- Create a repository layer upfront that becomes bloat for simple CRUD (over-engineering)
- Mix ORM logic into services until the codebase is a mess (entropy wins)
The clean setup sits in the middle: a single injectable PrismaService, exported globally, with repositories added only when they solve a real problem.
A PrismaService — Extend PrismaClient and connect on module init
Your first step is to wrap Prisma's client in a NestJS-native service. This gives you lifecycle control and keeps the DI container happy.
import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}
A few key points here:
- Extends PrismaClient: You inherit all Prisma methods directly—
findUnique,create,update—without wrapping them. - OnModuleInit: NestJS calls this after the dependency graph is ready, so your database connection happens at the right moment. No race conditions.
- enableShutdownHooks: Optional but recommended for production. Ensures Prisma closes gracefully when the app shuts down, preventing connection leaks.
In a real project, I often add a simple error handler too:
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
async onModuleInit() {
try {
await this.$connect();
this.logger.log('Database connected');
} catch (err) {
this.logger.error('Database connection failed', err);
throw err;
}
}
async onModuleDestroy() {
await this.$disconnect();
}
}
This catches connection errors early and logs them. I've found this pattern saves hours debugging silent failures in CI pipelines.
A global PrismaModule — Export PrismaService once; every feature module can inject it
Now wrap PrismaService in a module. Create it once, import it globally, and every feature module gets the same instance.
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
Then in your root AppModule:
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { UsersModule } from './users/users.module';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [
PrismaModule,
UsersModule,
OrdersModule,
],
})
export class AppModule {}
Every feature module can now inject PrismaService without re-importing PrismaModule:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string) {
return this.prisma.user.findUnique({
where: { id },
});
}
async create(data: CreateUserDto) {
return this.prisma.user.create({ data });
}
}
This scales to 20+ modules. I've used it on platforms with 50+ tables, and the pattern holds. The key is that PrismaService is a singleton—one connection pool shared across the entire application.
Repositories are optional — Add one when queries get complex
Here's where most tutorials mislead you: you don't need a repository layer from day one. Keep queries in services until you hit real pain.
In a small feature like user management, query directly:
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findActive() {
return this.prisma.user.findMany({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
}
async findByEmail(email: string) {
return this.prisma.user.findUnique({
where: { email },
});
}
}
But when a service has dozens of complex queries, or you're querying across multiple tables in multiple places, extract a repository:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class OrdersRepository {
constructor(private readonly prisma: PrismaService) {}
async findRecentByUserId(userId: string, days: number = 30) {
const since = new Date();
since.setDate(since.getDate() - days);
return this.prisma.order.findMany({
where: {
userId,
createdAt: { gte: since },
},
include: {
items: true,
shipment: true,
},
orderBy: { createdAt: 'desc' },
});
}
async findWithRevenue(userId: string) {
return this.prisma.order.findMany({
where: { userId },
select: {
id: true,
total: true,
createdAt: true,
items: {
select: { price: true, quantity: true },
},
},
});
}
}
Then inject it into your service:
@Injectable()
export class OrdersService {
constructor(private readonly orders: OrdersRepository) {}
async getUserStats(userId: string) {
const recent = await this.orders.findRecentByUserId(userId);
const withRevenue = await this.orders.findWithRevenue(userId);
return {
recentCount: recent.length,
totalRevenue: withRevenue.reduce((sum, o) => sum + o.total, 0),
};
}
}
A good heuristic: if a single service file exceeds 300 lines, or a query is used in more than two places, extract it. Not before.
Generate types in CI — prisma generate runs on build so types always match the schema
Prisma's type generation is automatic once you run prisma generate. But in CI, you need to ensure types are regenerated every build—otherwise you deploy with stale types.
Add this to your package.json:
{
"scripts": {
"build": "prisma generate && nest build",
"start": "node dist/main",
"dev": "nest start --watch",
"migration:dev": "prisma migrate dev",
"migration:deploy": "prisma migrate deploy"
}
}
When you run npm run build, Prisma regenerates types from your schema before compilation. This catches schema-code mismatches immediately.
In your Dockerfile:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma
COPY src ./src
COPY tsconfig.json .
# Generate types and build
RUN npm run build
# Production image
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.env .
CMD ["node", "dist/main.js"]
I've seen type mismatches crash production servers at 2am. Regenerating in CI prevents that entirely.
Common mistakes
- Forgetting to call $connect(): If you don't call
await this.$connect()inonModuleInit, queries fail silently or timeout. Always implement it. - Creating multiple PrismaService instances: Don't provide PrismaService in every module. Export it from a global PrismaModule. Multiple instances drain your connection pool.
- Checking the generated Prisma client into git: Add
node_modules/.prisma/to.gitignore. It's generated, not source code. I've seen repos bloat by hundreds of MB. - Skipping migration deploy in CI: If you don't run
prisma migrate deploybefore starting your app, the schema and code drift. Your database will be out of sync. - Writing raw SQL too early: Prisma queries are type-safe and composable. Reach for raw SQL only when you need analytics or bulk operations that Prisma can't express cleanly.
Key takeaways
- Create one PrismaService that extends PrismaClient and connect in
onModuleInit. - Export PrismaService from a global PrismaModule so every feature module can inject it as a singleton.
- Keep queries in services. Add a repository layer only when complexity demands it—not upfront.
- Run
prisma generateduring your build step in CI to keep types synchronized with your schema. - Use TypeScript strict mode and let Prisma's generated types catch schema mismatches at compile time, not runtime.






Comments
Loading comments…