NestJS with Prisma

Schema-first Prisma in NestJS. PrismaService lifecycle, migrations on Aurora, interactive transactions, client extensions, and pool tuning that actually holds under load.

The first time I shipped Prisma into a NestJS service at scale, I broke an Aurora writer in a way I should have seen coming. OK so we had a Postgres column to add, a NestJS module that owned the boundary, and a migration helper that read “safe” on the box. It wasn’t. The migration ran past midnight UTC, took a hot table on the Rails monolith at the creator-economy platform I worked at with it, and login error rate hit 100% for about 85 seconds before locks released. Different stack, same lesson. ORMs make schema changes feel cheap. They aren’t.

That story sits next to me every time I wire Prisma into a Nest module. The patterns below are the ones I keep reaching for when the service has to survive real traffic and a real on-call rotation, not just feel nice in the README.

The PrismaService boundary

The first decision is the smallest one and it gets botched constantly. Make PrismaService extend PrismaClient, register it as a singleton provider, and connect on onModuleInit. Do not connect lazily on the first query. Do not call $disconnect outside onModuleDestroy. Anything else and you’ll get cold-start latency spikes the first time a feature flag opens up traffic.

import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient, Prisma } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(PrismaService.name);

  constructor() {
    super({
      log: [
        { emit: 'event', level: 'query' },
        { emit: 'event', level: 'warn' },
        { emit: 'event', level: 'error' },
      ],
      transactionOptions: {
        maxWait: 2_000,
        timeout: 8_000,
        isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
      },
    });

    this.$on('warn', (e) => this.logger.warn(e.message));
    this.$on('error', (e) => this.logger.error(e.message));
  }

  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

I expose this in a PrismaModule marked @Global(). One singleton, one pool. Anything else fragments the connection budget across your DI tree and you’ll find out at 09:31:14 on a Tuesday.

Schema-first or you’re guessing

Prisma is the only ORM I’ll defend for greenfield Nest services, and it’s because of the schema. The schema.prisma file is the contract. Every migration is a generated diff, every type comes from that diff, and the client is regenerated on each prisma generate. No metadata reflection, no decorator soup, no surprise columns appearing because someone added an annotation in a feature branch.

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["clientExtensions", "metrics"]
  binaryTargets   = ["native", "linux-musl-openssl-3.0.x"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // shadow database used for migrate dev. NEVER point this at prod.
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}

model CreatorSubscription {
  id                       String   @id @default(cuid())
  creatorId                String
  appleOriginalTransactionId String?
  notificationUuid         String?
  status                   String
  createdAt                DateTime @default(now())
  updatedAt                DateTime @updatedAt

  @@unique([appleOriginalTransactionId, notificationUuid], name: "iap_idempotency")
  @@index([creatorId, status])
}

That @@unique is not academic. It’s the exact shape of constraint that would have killed the duplicate-IAP incident we lived through on the branded-mobile-app native billing path at the creator platform I worked at, where Apple’s server-to-server renewal hit our endpoint twice and we cheerfully wrote two rows because we trusted a 200 OK. A schema-level constraint is the contract. Application code is the suggestion.

For deploys, prisma migrate deploy runs in a one-shot init container ahead of the rolling deploy. Never migrate dev against production. Never auto-apply a destructive migration. The shadow DB sits in CI, not in your staging Aurora.

Interactive transactions that don’t melt

The bit of Prisma I see misused most often is $transaction. The array form is fine for “do these three writes atomically”. The interactive form, with a callback, is where you put real work, and where pool starvation begins if you’re sloppy.

import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from './prisma.service';

@Injectable()
export class SubscriptionService {
  constructor(private readonly prisma: PrismaService) {}

  async applyRenewal(input: {
    appleOriginalTransactionId: string;
    notificationUuid: string;
    creatorId: string;
    expiresAt: Date;
  }) {
    return this.prisma.$transaction(
      async (tx) => {
        const existing = await tx.creatorSubscription.findUnique({
          where: { iap_idempotency: {
            appleOriginalTransactionId: input.appleOriginalTransactionId,
            notificationUuid: input.notificationUuid,
          } },
        });
        if (existing) return existing;

        const row = await tx.creatorSubscription.create({
          data: {
            creatorId: input.creatorId,
            appleOriginalTransactionId: input.appleOriginalTransactionId,
            notificationUuid: input.notificationUuid,
            status: 'active',
          },
        });

        await tx.subscriptionEvent.create({
          data: { subscriptionId: row.id, kind: 'renewed', expiresAt: input.expiresAt },
        });

        return row;
      },
      {
        isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
        maxWait: 1_500,
        timeout: 5_000,
      },
    );
  }
}

Two things matter here. maxWait is how long the call will wait for a free connection before throwing. timeout is how long the actual transaction may run. Both should be small, and both should be smaller than your upstream HTTP timeout. If your timeout is 30 seconds and your gateway is 10, you’re holding a connection that nobody is waiting on. Multiply that by a few hundred requests a second and the pool is gone.

Client extensions instead of repositories

For a long time I wrote a repository per model on top of Prisma. It felt right coming from Rails. It was overkill. Prisma client extensions cover the same surface with less code and keep the typed return shape intact.

import { Prisma } from '@prisma/client';

export const softDeleteExtension = Prisma.defineExtension((client) => {
  return client.$extends({
    name: 'softDelete',
    model: {
      $allModels: {
        async softDelete<T>(this: T, where: Prisma.Args<T, 'update'>['where']) {
          const ctx = Prisma.getExtensionContext(this) as unknown as {
            update: (args: { where: typeof where; data: { deletedAt: Date } }) => Promise<unknown>;
          };
          return ctx.update({ where, data: { deletedAt: new Date() } });
        },
      },
    },
    query: {
      $allModels: {
        async findMany({ args, query }) {
          args.where = { deletedAt: null, ...args.where };
          return query(args);
        },
      },
    },
  });
});

I bind the extended client in the PrismaService constructor and inject it the same way. One DI token, one extension chain, everywhere. The type system carries the new methods through. No factory, no abstract base class, no second layer of mocks in tests.

The Aurora reader incident

The other story I keep coming back to is from a Tuesday morning at the creator-economy platform when an AuroraReplicaLagMaximum alert fired on the community product. Replica lag climbed to fourteen minutes, p99 read latency on the posts endpoint went from about 120 ms to over 8 seconds, and a teammate’s first instinct was to bump the reader instance class two tiers because we were “CPU bound on the readers”. Honestly, we weren’t. A long-running ANALYZE on a hot table was holding write-side locks on the writer and starving WAL emission. Killed the analyze. Lag drained in about six minutes. Twenty-two minutes of degraded reads, no data loss, and the runbook now opens by telling you to look at pg_stat_activity on the writer before you touch reader scaling.

The Prisma lesson from that day is dull and unsexy. If you split reads and writes, you’re committing to staleness as a feature, and your migrations and maintenance jobs need to respect peak windows. prisma migrate deploy against Aurora should not run between 06:00 and 22:00 UTC. The CI job that triggers it is gated on that. Treat it like a Kafka deploy. Pin the version, run it in a window, watch the writer.

Pool tuning, the short version

The defaults are wrong for a NestJS service running behind nginx with a tight upstream timeout. Set connection_limit in the URL explicitly, size it for pods x limit < max_connections * 0.8, and put a pool_timeout smaller than your HTTP timeout. Anything else and the failure mode is silent waits.

# .env.production
DATABASE_URL="postgresql://app:***@aurora.cluster.example/app?schema=public&connection_limit=20&pool_timeout=2&connect_timeout=5&sslmode=require"

Takeaways

  • One global PrismaService, connect on onModuleInit, disconnect on onModuleDestroy. No clever lazy init.
  • Treat schema.prisma and @@unique as the real contract. Application code is the suggestion.
  • prisma migrate deploy in a one-shot init container, gated to off-peak hours, never migrate dev in prod.
  • Always set maxWait, timeout, and isolationLevel on interactive transactions, smaller than the upstream HTTP timeout.
  • Prefer client extensions over a repository layer per model. Less code, types intact.
  • Size the connection limit per pod against the writer’s max_connections, not vibes.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.