NestJS has first-class support for TypeORM. And while there is some documentation how to use Prisma in NestJS, it basically stops at providing the PrismaClient as a service.

I go one step further and want to show you how to inject single tables via repositories instead. Here is the result:

@Injectable()
export class UserRepository extends PrismaRepository('user') {}

@Injectable()
export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
  ) {}

  getByEmail(email: string) {
    return this.userRepo.findFirst({ where: { email } });
  }
}

No library needed! Let me show you how.

I won’t go into much detail regarding neither NestJS nor Prisma. They each have very good documentation, so go check there if you a new to them. This blog post is about marrying them together.

Prisma in NestJS

Once you’ve set it up, Prisma is very easy to use:

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

// Create a client...
const prisma = new PrismaClient();
// ...and access the tables defined in the schemas.
const users = await prisma.user.findMany();

Providing this client in NestJS is just as easy (taken from the official NestJS documentation):

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

Then you can quite easily write your own services like so:

@Injectable()
export class UsersService {

  constructor(private readonly prisma: PrismaService) {}

  getUserByEmail(email: string): Promise<User | null> {
    return this.prisma.user.findFirst({
      where: { email }
    });
  }
}

But there is a catch.

Transactions in Prisma

Transactions in Prisma can be implemented like this:

const prisma = new PrismaClient();

prisma.$transaction(async (tx) => {
  const users = await tx.user.findMany();
});

Note, that the “transaction” object has basically exactly the same interface as the PrismaClient. Which is very nice, if you think of it. You can write code that interacts with a specific table and it would be completely transparent if this interaction uses a transaction or not.

An example:

async function getAllUsers(userTable: PrismaClient['user']): Promise<User[]> {
  return userTable.findMany();
}

const prisma = new PrismaClient();

// Now the function runs outside a transaction
const users = await getAllUsers(prisma.user);

// Now the function runs inside one:
prisma.$transaction(async (tx) => {
  const users = await getAllUser(tx.user);
})

But passing the table to the implementation is no fun. We can do better. Introducing:

Storing The Transaction In The AsyncLocalStorage

NodeJS has a very underrated API called AsyncLocalStorage. The name is maybe a bit confusing, but think of like Java’s ThreadLocal. It allows attaching data to the current “context”. Any code that is run within this context, will have access to this data.

We can use this context, to store a reference to the current transaction:

import { PrismaClient } from '@prisma/client';
import { ITXClientDenyList } from '@prisma/client/runtime/library';
import { AsyncLocalStorage } from 'node:async_hooks';

// This type is just copied from the `prisma.$transaction()` method signature
export type TransactionPrismaClient = Omit<PrismaClient, ITXClientDenyList>;

const transactionStore = new AsyncLocalStorage<TransactionPrismaClient>();

export function getTransaction() {
  return transactionStore.getStore();
}

// Utility function to execute a function inside the context of a transaction
export function withTransaction<Result>(prismaClient: PrismaClient, fn: () => Promise<Result>): Promise<Result> {
  return prismaClient.$transaction(async (transaction) => {
    return transactionStore.run(transaction, () => fn());
  });
}

To use it we can simplify our transaction code from above:

async function getAllUsers(userTable: PrismaClient['user']): Promise<User[]> {
  let userTable;
  const transaction = getTransaction();
  if (transaction) {
    // Current context has a transaction -> use it
    userTable = transaction.user;
  } else {
    // Current context has no transaction -> user the global prisma client
    userTable = prisma.user;
  }
  return userTable.findMany();
}

const prisma = new PrismaClient();

// Now the function runs outside a transaction
const users = await getAllUsers();

// Now the function runs inside one:
withTransaction(prisma, async () => {
  const users = await getAllUser();
})

That’s much nicer, because now we don’t have to pass the PrismaClient (or the transaction) as a parameter form the very top of the call stack all the way down to the DB interaction.

However, we still have to write quite a bit of boilerplate every time we interact with the database. More precisely, the is this function called with the context of a transaction code . Something that’s quite easy to forget. And if we ever need to update this code, we have to update every location that interacts with Prisma. We can fix this with factory methods or even TypeScript decorators. But we are here for NestJS magic, so let’s go back to NestJS code.

Creating Repositories in NestJS

The Prisma site is pretty much solved already. In NestJS, however, only the PrismaService at the top of this blog is available in the injection context. While that gets use 90% the way, I want to provide individual repositories instead of the full client reference. That has many benefits, such as:

  • Services cannot call dangerous APIs like PrismaClient.$disconnect()
  • It’s much clearer, what service interacts with what tables. Just look at the injected repositories and you are pretty much good to go.
  • Testing/mocking can be easier, because you don’t have such a large surface you need to cover.

Code wise, what I want to achieve is something like this:

@Injectable()
export class UserRepository implements PrismaClient['user'] {
  // Some magic here, so that we don't *actually* have to implement every method
  // and field of the User table.
}

@Injectable()
export class UserService {
  constructor(
    // Just declare the type - no weird, unsafe
    // `@InjectRepository('some-token')` stuff
    private readonly userRepo: UserRepository
  ) {}

  getUsersByEmail(email: string) {
    // Ideally, the `UserRepository` should include the AsyncLocalStorage magic,
    // so that we don't have to worry about transactions
    return this.userRepo.findFirst({
      where: { email }
    });
  }
}

Though one step at at time.

Introducing: The JS Proxy class

In order to not write all methods that a table exposes by hand, we can use the built-int JavaScript Proxy object.

The way it works is quite simple, actually:

const target = {
  fn: () => console.log('Hello world'),
};

const proxyToTarget = new Proxy(target, {
  get(theTarget, property, receiver) {
    console.log('Accessing property on target:', property);
    return target[property];
  }
});

proxyToTarget.fn();

This will first log Accessing property on target: fn - when reading proxyToTarget.fn - followed by Hello world.

We can use this to auto-forward all interactions with a repository to the correct table:

import { Type } from '@nestjs/common';

// Simple type to remove '$transaction' and similar from the possible values
type Table = Exclude<keyof PrismaClient, symbol | `$${string}`>;


function repository<T extends Table>(table: T, prismaClient: PrismaClient): Prisma[T] {
  // @ts-expect-error Typescript will not like what we are doing here
  return new Proxy(
    {}, // Target does not matter for us
    {
      get(_target, property) {
        // @ts-expect-error Again, TS is not happy with this
        return prismaClient[table][property];
      }
    }
  )
}

const prisma = new PrismaClient();
const usersRepository = repository('user');

await usersRepository.findMany();

With this it’s super easy to implement the transaction-check for every repository:

function repository<T extends Table>(table: T, prismaClient: PrismaClient): Prisma[T] {
  // @ts-expect-error Typescript will not like what we are doing here
  return new Proxy(
    {}, // Target does not matter for us
    {
      get(_target, property) {
        let table;
        const transaction = getTransaction();
        if (transaction) {
          table = transaction[table];
        } else {
          table = prismaClient[table];
        }
        // @ts-expect-error Again, TS is not happy with this
        return table[property];
      }
    }
  )
}

Voila, we can now make as many repositories as we want, and all of them correctly handle transactions:

const prisma = new PrismaClient();

const userRepository = repository('user', prisma);
const productRepository = repository('product', prisma);

await withTransaction(prisma, async () => {
  // All interaction with any repository here will be inside the transaction
  const users = await userRepository.findMany();
  const products = await productRepository.findMany();
});

However, all of these are just factory functions. In order to bring them into the NestJS ecosystem, we need to provide them somehow.

The usual way of doing this is to write some PrismaModule.forFeature(), which maps an array of e.g. table names to NestJS providers and then you have to do something like constructor(@InjectRepository('user') userRepo: PrismaRepository<User>), but that’s quite type-unsafe, because there are no compile-time checks, etc. etc. etc.

As mentioned earlier, I just want to

  1. Define a class, that is both an injection token and a proxy for a table on the Prisma client.
  2. Inject this class like any other service, like constructor(userRepo: UserRepository)

In order to achieve this, I need to reveal another ace up my sleeve.

Returning Proxy From Constructor

A little know fact - or rather quirk - about JavaScript, is that you can return an object from a class constructor. As MDN puts it:

A class’s constructor can return a different object, which will be used as the new this for the derived class constructor.

Emphasis mine. It’s important, that the returned value of a constructor is only used, when we extend a class with a return value in the constructor.

How we can use this is like this:

// @ts-expect-error We are not actually implementing anything
class UserRepositoryProxy implements PrismaClient['user'] {
  constructor(prismaClient: PrismaClient) {
    return new Proxy(this, {
      get(target, property) {
        // Put the transaction + forwarding code from above here
      }
    });
  }
}

@Injectable()
export class UserRepository extends UserRepositoryProxy {}

And while this should work just fine, we still have a bunch of boilerplate. Each repository requires an accompanying “proxy class”. That’s why I wrote a utility function to create and return an inline class.

Here is what I ended up creating:

import { Type as Constructor, Inject } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaClientService } from './prisma-client.service';
import { getTransaction } from './prisma.transaction';

export function PrismaRepository<Table extends Exclude<keyof PrismaClient, symbol | `$${string}`>>(
  table: Table,
): Constructor<PrismaClient[Table]> {
  const PrismaTable = class PrismaTable {
    constructor(prismaClient: PrismaService) {
      return new Proxy(this, {
        get(target, p) {
          const transaction = getTransaction();
          let prismaTable;
          if (transaction) {
            prismaTable = transaction[table];
          } else {
            prismaTable = prismaClient[table];
          }
          if (p in prismaTable) {
            // @ts-expect-error we have to treat prismaTable as "any" basically
            return prismaTable[p];
          }
          // Allow repositories to add more functions, that are not part of the
          // prisma table
          // @ts-expect-error we have to treat target as "any" basically
          return target[p];
        },
      });
    }
  };

  // Because this class is dynamic, we need to manually declare what to inject
  Inject(PrismaService)(PrismaTable, 'constructor', 0);

  // @ts-expect-error The PrismaTable does not implement PrismaClient[Table] - but the Proxy will
  return PrismaTable;
}

As a result, writing new repositories is super easy:

@Injectable()
export class UserRepository extends PrismaRepository('user') {}
@Injectable()
export class ProductRepository extends PrismaRepository('product') {}
@Injectable()
export class OrderRepository extends PrismaRepository('order') {}

// Provide it like any other service
@Module({
  providers: [
    // Don't forget to add the PrismaService
    PrismaService,

    UserRepository,
    ProductRepository,
    OrderRepository,
  ],
  exports: [
    UserRepository,
    ProductRepository,
    OrderRepository,
  ]
})
export class PrismaModule {}

And usage is super straight forward as well:

@Injectable()
export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
  ) {}

  getByEmail(email: string) {
    return this.userRepo.findMany({
      where: { email }
    })
  }
}

Don’t forget transactions:

export class OrderService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly orderRepo: OrderRepository,
    private readonly productRepo: ProductRepository,
  ) {}

  order(userId: number, productId: number, quantity: number) {
    return withTransaction(async () => {
      const [user, product] = await Promise.all([
        this.userRepo.findFirst({ where: { id: userId  } }),
        this.productRepo.findFirst({ where: { id: productId}}),
      ]);
      if (product.inStock < quantity) {
        throw new Error('Not enough in stock');
      }
      const total = product.price * quantity;
      if (total > user.balance) {
        throw new Error('User has not enough balance');
      }
      const newOrder = await this.orderRepo.create({
        data: { userId, productId, quantity, total }
      });
      return newOrder;
    })
  }
}

The keen eyed may have noticed, that withTransaction actually requires a PrismaClient as first argument. I would recommend wrapping this function into a service as well. Then, with proper scoping of providers, you can disallow anything except the repositories and the “transaction service” to inject the PrismaClient.

But this is an exercise for the reader 😉