DEV Community

Cover image for NestJS + Prisma: Singleton Provider & Service with Extensions
Milan Barać
Milan Barać

Posted on

NestJS + Prisma: Singleton Provider & Service with Extensions

Prisma offers a slick, type-safe way to work with databases, and NestJS provides a robust structure for building modern backend apps. Together, they’re a great match — but only if Prisma is correctly initialized within NestJS’s lifecycle. A misconfigured Prisma provider can lead to subtle bugs or resource leaks, especially when custom extensions come into play.

1. Introduction

NestJS and Prisma are a natural fit for building modern, type-safe backend applications. NestJS brings structure, modularity, and a powerful dependency injection system, while Prisma provides a clean, auto-typed ORM with excellent developer ergonomics. Together, they offer an efficient and scalable development experience.

However, using Prisma effectively within a NestJS project requires more than just generating a client and injecting it into services. To avoid connection issues, memory leaks, or unexpected behavior — especially during development with hot reloading or when running in serverless environments — Prisma should be initialized as a singleton and properly integrated into NestJS’s lifecycle hooks.

A well-structured PrismaProvider class that implements OnModuleInit and OnModuleDestroy ensures the database connection is opened and closed at the right times. This pattern avoids repeated connections and supports safe shutdowns — crucial for production-grade apps.

On top of this, Prisma’s $extends API enables powerful customization through reusable extensions. Features like exists checks, soft delete support, and query scoping can be cleanly abstracted into custom extensions. But for these to be reliably available across your application, they must be bound to a single instance of the client — again reinforcing the need for a correctly managed singleton.

In this article, we’ll walk through how to:

  • Set up Prisma as a properly managed singleton provider in NestJS
  • Leverage OnModuleInit and OnModuleDestroy for lifecycle-safe initialization
  • Create and apply custom Prisma extensions like exists, softDelete, and scoped queries
  • Avoid common pitfalls that arise from improper initialization or multiple Prisma instances

Let’s dive in by first building a minimal PrismaProvider that plays nicely with NestJS’s lifecycle and sets the stage for custom extensions.

2. Prisma Extensions

Prisma’s $extends API unlocks a powerful way to modularize and reuse database logic across your application. Instead of repeating common patterns — like checking if a record exists or handling soft deletes — you can encapsulate them in clean, composable extensions. These extensions not only reduce boilerplate but also improve code clarity and consistency across your services. In this section, we’ll explore how to build custom extensions for exists checks, soft deletion — and how to cleanly attach them to your singleton Prisma client.

prisma.extensions.ts

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

export const existsExtension = Prisma.defineExtension({
  name: 'exists-extension',
  model: {
    $allModels: {
      async exists<T>(this: T, where: Prisma.Args<T, 'findFirst'>['where']): Promise<boolean> {
        const context = Prisma.getExtensionContext(this);
        const count = await context['count']({
          where,
          take: 1
        } as Prisma.Args<T, 'count'>);
        return count > 0;
      }
    }
  }
});
export const softDeleteExtension = Prisma.defineExtension({
  name: 'soft-delete-extension',
  model: {
    $allModels: {
      async softDelete<T>(
        this: T,
        where: Prisma.Args<T, 'update'>['where']
      ): Promise<Prisma.Result<T, unknown, 'update'>> {
        const context = Prisma.getExtensionContext(this);
        return context['update']({
          where,
          data: {
            deletedAt: new Date()
          }
        } as Prisma.Args<T, 'update'>);
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

3. Singleton Prisma Provider

To fully harness Prisma in a NestJS project — especially when using custom extensions — you need a properly initialized singleton provider. Prisma’s client is designed to be reused across your application, and creating multiple instances can lead to duplicated connections, race conditions, or lost context for extensions.

A common pattern is to extend PrismaClient in a custom PrismaProvider that implements NestJS’s OnModuleInit and OnModuleDestroy interfaces. This ensures the client connects only once when the app starts and disconnects cleanly on shutdown.

To support extensions like exists, softDelete, or scoped queries, the singleton should expose a .withExtensions() method that returns an $extends()- chained version of the client. This allows you to separate core access (e.g., for migrations or internal use) from wrapped access where extended behavior is needed.

Here’s a quick breakdown of the pattern:

  • Use a static flag to ensure initialization happens only once, even across hot reloads.
  • Implement lifecycle hooks to manage connection state properly.
  • Provide a withExtensions() method that wraps the singleton with the desired extension chain.

This setup ensures your extensions are always attached to the correct Prisma instance, avoids unnecessary connections, and keeps your application behavior consistent and predictable.

prisma.provider.ts

import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { existsExtension, softDeleteExtension } from './prisma.extensions';

@Injectable()
export class PrismaProvider extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  private static initialized = false;
  async onModuleInit() {
    if (!PrismaProvider.initialized) {
      PrismaProvider.initialized = true;
      await this.$connect();
    }
  }
  async onModuleDestroy() {
    if (PrismaProvider.initialized) {
      PrismaProvider.initialized = false;
      await this.$disconnect();
    }
  }
  withExtensions() {
    return this.$extends(existsExtension).$extends(softDeleteExtension);
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Extended Prisma Service

In the previous sections, we designed a PrismaProvider that manages Prisma’s lifecycle as a singleton and offers a .withExtensions() method to attach custom Prisma extensions like exists and softDelete.

However, to make this extended Prisma client seamlessly injectable and usable throughout your NestJS app, it’s helpful to introduce a dedicated PrismaService class. This class acts as a thin wrapper that inherits from the extended Prisma client instance returned by PrismaProvider.withExtensions().

By doing so, PrismaService:

  • Encapsulates the extended Prisma client as a proper injectable NestJS service
  • Abstracts away the need for consumers to manually call .withExtensions()
  • Provides a clean, type-safe Prisma client interface enhanced with all your custom extensions

The trick here is to create a dynamic class ExtendedPrismaClient that takes the singleton PrismaProvider and returns its extended client. Then, PrismaService extends this class and can be injected anywhere via NestJS’s DI system.

This glue layer improves code clarity and encourages consistent use of your extended Prisma client, making your database access both powerful and ergonomic.

prisma.service.ts

import { Injectable, Type } from '@nestjs/common';
import { PrismaProvider } from './prisma.provider';

const ExtendedPrismaClient = class {
  constructor(provider: PrismaProvider) {
    return provider.withExtensions();
  }
} as Type<ReturnType<PrismaProvider['withExtensions']>>;

@Injectable()
export class PrismaService extends ExtendedPrismaClient {
  constructor(provider: PrismaProvider) {
    super(provider);
  }
}
Enter fullscreen mode Exit fullscreen mode

Using PrismaService for exists and softDelete:

user.service.ts

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

  // Check if a user exists by email
  async userExists(email: string): Promise<boolean> {
    return this.prisma.user.exists({
      where: { email },
    });
  }

  // Soft delete a user by ID
  async softDeleteUser(userId: number): Promise<void> {
    await this.prisma.user.softDelete({
      where: { id: userId },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Conclusion

Integrating Prisma with NestJS is straightforward at first glance, but improper initialization can lead to subtle, hard-to-debug issues like multiple database connections, memory leaks, or inconsistent extension behavior. Common pitfalls include creating multiple PrismaClient instances, ignoring lifecycle management, or incorrectly applying extensions without a stable singleton.

The optimal approach is to create a dedicated Prisma provider that acts as a singleton, managing connection lifecycle through NestJS’s module hooks. This ensures Prisma connects once, disconnects cleanly, and extensions are reliably attached to the same client instance.

To complement the singleton provider, a dedicated Prisma service acts as a clean, injectable interface that exposes the extended Prisma client throughout your NestJS application. This service abstracts away the complexity of managing extensions and lifecycle, allowing your business logic to access Prisma with all custom enhancements seamlessly. Together, the provider and service form a solid foundation for scalable, maintainable database interactions in NestJS projects using Prisma.

By adopting this pattern, you not only improve application stability and performance but also unlock the full potential of Prisma’s custom extensions — making your database access code cleaner, more maintainable, and robust.

Top comments (0)