Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Nestjs + Prisma + Proxy = ♥️
Simon Hayden
Simon Hayden

Posted on • Originally published atsimon.hayden.wien

     

Nestjs + Prisma + Proxy = ♥️

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}});}}
Enter fullscreen modeExit fullscreen mode

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();
Enter fullscreen modeExit fullscreen mode

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

@Injectable()exportclassPrismaServiceextendsPrismaClientimplementsOnModuleInit{asynconModuleInit(){awaitthis.$connect();}}
Enter fullscreen modeExit fullscreen mode

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}});}}
Enter fullscreen modeExit fullscreen mode

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();});
Enter fullscreen modeExit fullscreen mode

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);})
Enter fullscreen modeExit fullscreen mode

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());});}
Enter fullscreen modeExit fullscreen mode

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();})
Enter fullscreen modeExit fullscreen mode

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 likePrismaClient.$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}});}}
Enter fullscreen modeExit fullscreen mode

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();
Enter fullscreen modeExit fullscreen mode

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();
Enter fullscreen modeExit fullscreen mode

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];}})}
Enter fullscreen modeExit fullscreen mode

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();});
Enter fullscreen modeExit fullscreen mode

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

  1. Define a class, that is both an injection tokenand a proxy for a table on the Prisma client.
  2. Inject this class like any other service, likeconstructor(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{}
Enter fullscreen modeExit fullscreen mode

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;}
Enter fullscreen modeExit fullscreen mode

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{}
Enter fullscreen modeExit fullscreen mode

And usage is super straight forward as well:

@Injectable()exportclassUserService{constructor(privatereadonlyuserRepo:UserRepository,){}getByEmail(email:string){returnthis.userRepo.findMany({where:{email}})}}
Enter fullscreen modeExit fullscreen mode

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;})}}
Enter fullscreen modeExit fullscreen mode

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 the
PrismaClient.

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)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

  • Joined

Trending onDEV CommunityHot

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp