Skip to content
ExponaBLOG
NESTJS · DAY 012 / 100

Prisma with NestJS: the clean setup — a practical guide

My default data layer for NestJS projects.

Expona Admin7 min read

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.

typescript
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:

typescript
@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.

typescript
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Then in your root AppModule:

typescript
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:

typescript
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:

typescript
@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:

typescript
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:

typescript
@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:

typescript
{
  "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:

typescript
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() in onModuleInit, 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 deploy before 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 generate during 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles