
NestJS has first-class support forTypeORM. And while there is somedocumentation how to use Prisma in NestJS, it basically stops at providing thePrismaClient
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()exportclassUserRepositoryextendsPrismaRepository('user'){}@Injectable()exportclassUserService{constructor(privatereadonlyuserRepo:UserRepository,){}getByEmail(email:string){returnthis.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...constprisma=newPrismaClient();// ...and access the tables defined in the schemas.constusers=awaitprisma.user.findMany();
Providing this client in NestJS is just as easy (taken from theofficial NestJS
documentation):
@Injectable()exportclassPrismaServiceextendsPrismaClientimplementsOnModuleInit{asynconModuleInit(){awaitthis.$connect();}}
Then you can quite easily write your own services like so:
@Injectable()exportclassUsersService{constructor(privatereadonlyprisma:PrismaService){}getUserByEmail(email:string):Promise<User|null>{returnthis.prisma.user.findFirst({where:{email}});}}
But there is a catch.
Transactions in Prisma
Transactions in Prisma can be implemented like this:
constprisma=newPrismaClient();prisma.$transaction(async(tx)=>{constusers=awaittx.user.findMany();});
Note, that the "transaction" object has basically exactly the same interface as thePrismaClient
. 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:
asyncfunctiongetAllUsers(userTable:PrismaClient['user']):Promise<User[]>{returnuserTable.findMany();}constprisma=newPrismaClient();// Now the function runs outside a transactionconstusers=awaitgetAllUsers(prisma.user);// Now the function runs inside one:prisma.$transaction(async(tx)=>{constusers=awaitgetAllUser(tx.user);})
But passing the table to the implementation is no fun. We can do better. Introducing:
Storing The Transaction In TheAsyncLocalStorage
NodeJS has a very underrated API calledAsyncLocalStorage
. The name is maybe a bit confusing, but think of like Java'sThreadLocal. 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 signatureexporttypeTransactionPrismaClient=Omit<PrismaClient,ITXClientDenyList>;consttransactionStore=newAsyncLocalStorage<TransactionPrismaClient>();exportfunctiongetTransaction(){returntransactionStore.getStore();}// Utility function to execute a function inside the context of a transactionexportfunctionwithTransaction<Result>(prismaClient:PrismaClient,fn:()=>Promise<Result>):Promise<Result>{returnprismaClient.$transaction(async(transaction)=>{returntransactionStore.run(transaction,()=>fn());});}
To use it we can simplify our transaction code from above:
asyncfunctiongetAllUsers(userTable:PrismaClient['user']):Promise<User[]>{letuserTable;consttransaction=getTransaction();if(transaction){// Current context has a transaction -> use ituserTable=transaction.user;}else{// Current context has no transaction -> user the global prisma clientuserTable=prisma.user;}returnuserTable.findMany();}constprisma=newPrismaClient();// Now the function runs outside a transactionconstusers=awaitgetAllUsers();// Now the function runs inside one:withTransaction(prisma,async()=>{constusers=awaitgetAllUser();})
That's much nicer, because now we don't have to pass thePrismaClient
(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, theis 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 thePrismaService
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()exportclassUserRepositoryimplementsPrismaClient['user']{// Some magic here, so that we don't *actually* have to implement every method// and field of the User table.}@Injectable()exportclassUserService{constructor(// Just declare the type - no weird, unsafe// `@InjectRepository('some-token')` stuffprivatereadonlyuserRepo:UserRepository){}getUsersByEmail(email:string){// Ideally, the `UserRepository` should include the AsyncLocalStorage magic,// so that we don't have to worry about transactionsreturnthis.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-intJavaScript Proxy object.
The way it works is quite simple, actually:
consttarget={fn:()=>console.log('Hello world'),};constproxyToTarget=newProxy(target,{get(theTarget,property,receiver){console.log('Accessing property on target:',property);returntarget[property];}});proxyToTarget.fn();
This will first logAccessing property on target: fn
- when readingproxyToTarget.fn
- followed byHello 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 valuestypeTable=Exclude<keyofPrismaClient,symbol|`$${string}`>;functionrepository<TextendsTable>(table:T,prismaClient:PrismaClient):Prisma[T]{// @ts-expect-error Typescript will not like what we are doing herereturnnewProxy({},// Target does not matter for us{get(_target,property){// @ts-expect-error Again, TS is not happy with thisreturnprismaClient[table][property];}})}constprisma=newPrismaClient();constusersRepository=repository('user');awaitusersRepository.findMany();
With this it's super easy to implement the transaction-check for every repository:
functionrepository<TextendsTable>(table:T,prismaClient:PrismaClient):Prisma[T]{// @ts-expect-error Typescript will not like what we are doing herereturnnewProxy({},// Target does not matter for us{get(_target,property){lettable;consttransaction=getTransaction();if(transaction){table=transaction[table];}else{table=prismaClient[table];}// @ts-expect-error Again, TS is not happy with thisreturntable[property];}})}
Voila, we can now make as many repositories as we want, and all of them correctly handle transactions:
constprisma=newPrismaClient();constuserRepository=repository('user',prisma);constproductRepository=repository('product',prisma);awaitwithTransaction(prisma,async()=>{// All interaction with any repository here will be inside the transactionconstusers=awaituserRepository.findMany();constproducts=awaitproductRepository.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 somePrismaModule.forFeature()
, which maps an array of e.g. table names to NestJS providers and then you have to do something likeconstructor(@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 tokenand 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. AsMDN puts it:
A class's constructor can return a different object, which will be used as the new this for thederived 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 anythingclassUserRepositoryProxyimplementsPrismaClient['user']{constructor(prismaClient:PrismaClient){returnnewProxy(this,{get(target,property){// Put the transaction + forwarding code from above here}});}}@Injectable()exportclassUserRepositoryextendsUserRepositoryProxy{}
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{TypeasConstructor,Inject}from'@nestjs/common';import{PrismaClient}from'@prisma/client';import{PrismaClientService}from'./prisma-client.service';import{getTransaction}from'./prisma.transaction';exportfunctionPrismaRepository<TableextendsExclude<keyofPrismaClient,symbol|`$${string}`>>(table:Table,):Constructor<PrismaClient[Table]>{constPrismaTable=classPrismaTable{constructor(prismaClient:PrismaService){returnnewProxy(this,{get(target,p){consttransaction=getTransaction();letprismaTable;if(transaction){prismaTable=transaction[table];}else{prismaTable=prismaClient[table];}if(pinprismaTable){// @ts-expect-error we have to treat prismaTable as "any" basicallyreturnprismaTable[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" basicallyreturntarget[p];},});}};// Because this class is dynamic, we need to manually declare what to injectInject(PrismaService)(PrismaTable,'constructor',0);// @ts-expect-error The PrismaTable does not implement PrismaClient[Table] - but the Proxy willreturnPrismaTable;}
As a result, writing new repositories is super easy:
@Injectable()exportclassUserRepositoryextendsPrismaRepository('user'){}@Injectable()exportclassProductRepositoryextendsPrismaRepository('product'){}@Injectable()exportclassOrderRepositoryextendsPrismaRepository('order'){}// Provide it like any other service@Module({providers:[// Don't forget to add the PrismaServicePrismaService,UserRepository,ProductRepository,OrderRepository,],exports:[UserRepository,ProductRepository,OrderRepository,]})exportclassPrismaModule{}
And usage is super straight forward as well:
@Injectable()exportclassUserService{constructor(privatereadonlyuserRepo:UserRepository,){}getByEmail(email:string){returnthis.userRepo.findMany({where:{email}})}}
Don't forget transactions:
exportclassOrderService{constructor(privatereadonlyuserRepo:UserRepository,privatereadonlyorderRepo:OrderRepository,privatereadonlyproductRepo:ProductRepository,){}order(userId:number,productId:number,quantity:number){returnwithTransaction(async()=>{const[user,product]=awaitPromise.all([this.userRepo.findFirst({where:{id:userId}}),this.productRepo.findFirst({where:{id:productId}}),]);if(product.inStock<quantity){thrownewError('Not enough in stock');}consttotal=product.price*quantity;if(total>user.balance){thrownewError('User has not enough balance');}constnewOrder=awaitthis.orderRepo.create({data:{userId,productId,quantity,total}});returnnewOrder;})}}
The keen eyed may have noticed, thatwithTransaction
actually requires aPrismaClient
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 thePrismaClient
.
But this is an exercise for the reader 😉
If you have any other questions, I'd love to answer them in the comments below!
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse