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
- Define a class, that is both an injection token and a proxy for a table on the Prisma client.
- 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 😉