engineering
Building Scalable Backend APIs with NestJS & Prisma
A comprehensive guide to building enterprise-grade backend applications using NestJS framework and Prisma ORM with TypeScript.
S
Sambo Chea
Staff Engineer
October 5, 2025
10 min read
At CUBIS, we've built dozens of production APIs using NestJS and Prisma. This powerful combination provides type-safety, excellent developer experience, and scalability out of the box. Let's dive into why we love this stack and how to leverage it effectively.
npm i -g @nestjs/cli nest new my-api cd my-api
npm install prisma @prisma/client npx prisma init
This creates:
prisma/schema.prisma - Database schema definition.env - Environment variables# .env DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
Define your data model in prisma/schema.prisma:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(uuid()) email String @unique name String? password String role Role @default(USER) posts Post[] comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([email]) } model Post { id String @id @default(uuid()) title String slug String @unique content String published Boolean @default(false) views Int @default(0) authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) categoryId String category Category @relation(fields: [categoryId], references: [id]) tags Tag[] comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) @@index([categoryId]) @@index([slug]) } model Category { id String @id @default(uuid()) name String @unique slug String @unique description String? posts Post[] createdAt DateTime @default(now()) } model Tag { id String @id @default(uuid()) name String @unique slug String @unique posts Post[] } model Comment { id String @id @default(uuid()) content String postId String post Post @relation(fields: [postId], references: [id], onDelete: Cascade) authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@index([postId]) @@index([authorId]) } enum Role { USER ADMIN MODERATOR }
npx prisma migrate dev --name init
This:
Create a reusable Prisma service:
// src/prisma/prisma.service.ts import { Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect(); } async enableShutdownHooks(app: any) { this.$on('beforeExit', async () => { await app.close(); }); } }
// src/prisma/prisma.module.ts import { Module, Global } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Global() @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {}
Register in app.module.ts:
import { Module } from '@nestjs/common'; import { PrismaModule } from './prisma/prisma.module'; @Module({ imports: [PrismaModule], }) export class AppModule {}
// src/posts/posts.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreatePostDto, UpdatePostDto } from './dto'; @Injectable() export class PostsService { constructor(private prisma: PrismaService) {} async findAll(page: number = 1, limit: number = 10) { const skip = (page - 1) * limit; const [posts, total] = await Promise.all([ this.prisma.post.findMany({ skip, take: limit, where: { published: true }, include: { author: { select: { id: true, name: true, email: true }, }, category: true, tags: true, _count: { select: { comments: true } }, }, orderBy: { createdAt: 'desc' }, }), this.prisma.post.count({ where: { published: true } }), ]); return { data: posts, meta: { total, page, limit, totalPages: Math.ceil(total / limit), }, }; } async findOne(id: string) { const post = await this.prisma.post.findUnique({ where: { id }, include: { author: { select: { id: true, name: true, email: true }, }, category: true, tags: true, comments: { include: { author: { select: { id: true, name: true }, }, }, orderBy: { createdAt: 'desc' }, }, }, }); if (!post) { throw new NotFoundException(`Post with ID ${id} not found`); } // Increment views await this.prisma.post.update({ where: { id }, data: { views: { increment: 1 } }, }); return post; } async create(userId: string, dto: CreatePostDto) { const slug = this.generateSlug(dto.title); return this.prisma.post.create({ data: { title: dto.title, slug, content: dto.content, published: dto.published ?? false, author: { connect: { id: userId } }, category: { connect: { id: dto.categoryId } }, tags: { connectOrCreate: dto.tags.map(tag => ({ where: { name: tag }, create: { name: tag, slug: this.generateSlug(tag) }, })), }, }, include: { author: true, category: true, tags: true, }, }); } async update(id: string, dto: UpdatePostDto) { await this.findOne(id); // Check existence return this.prisma.post.update({ where: { id }, data: { ...dto, tags: dto.tags ? { set: [], connectOrCreate: dto.tags.map(tag => ({ where: { name: tag }, create: { name: tag, slug: this.generateSlug(tag) }, })), } : undefined, }, include: { author: true, category: true, tags: true, }, }); } async delete(id: string) { await this.findOne(id); // Check existence return this.prisma.post.delete({ where: { id } }); } private generateSlug(text: string): string { return text .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-'); } }
// src/posts/dto/create-post.dto.ts import { IsString, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator'; export class CreatePostDto { @IsString() @MinLength(3) title: string; @IsString() @MinLength(10) content: string; @IsString() categoryId: string; @IsArray() @IsString({ each: true }) tags: string[]; @IsBoolean() @IsOptional() published?: boolean; }
// src/posts/posts.controller.ts import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, } from '@nestjs/common'; import { PostsService } from './posts.service'; import { CreatePostDto, UpdatePostDto } from './dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @Controller('posts') export class PostsController { constructor(private postsService: PostsService) {} @Get() findAll(@Query('page') page: string, @Query('limit') limit: string) { return this.postsService.findAll( parseInt(page) || 1, parseInt(limit) || 10, ); } @Get(':id') findOne(@Param('id') id: string) { return this.postsService.findOne(id); } @Post() @UseGuards(JwtAuthGuard) create(@CurrentUser('id') userId: string, @Body() dto: CreatePostDto) { return this.postsService.create(userId, dto); } @Put(':id') @UseGuards(JwtAuthGuard) update(@Param('id') id: string, @Body() dto: UpdatePostDto) { return this.postsService.update(id, dto); } @Delete(':id') @UseGuards(JwtAuthGuard) delete(@Param('id') id: string) { return this.postsService.delete(id); } }
async transferPostOwnership(postId: string, newOwnerId: string) { return this.prisma.$transaction(async (prisma) => { const post = await prisma.post.findUnique({ where: { id: postId } }); if (!post) { throw new NotFoundException('Post not found'); } // Update post owner const updatedPost = await prisma.post.update({ where: { id: postId }, data: { authorId: newOwnerId }, }); // Log the transfer await prisma.activityLog.create({ data: { action: 'POST_TRANSFERRED', postId, oldOwnerId: post.authorId, newOwnerId, }, }); return updatedPost; }); }
async getPopularPosts(limit: number = 10) { return this.prisma.$queryRaw` SELECT p.*, COUNT(c.id) as comment_count, u.name as author_name FROM "Post" p LEFT JOIN "Comment" c ON c."postId" = p.id LEFT JOIN "User" u ON u.id = p."authorId" WHERE p.published = true GROUP BY p.id, u.name ORDER BY p.views DESC, comment_count DESC LIMIT ${limit} `; }
async getPostStatistics() { const stats = await this.prisma.post.aggregate({ _count: { id: true }, _avg: { views: true }, _sum: { views: true }, where: { published: true }, }); const byCategory = await this.prisma.post.groupBy({ by: ['categoryId'], _count: { id: true }, where: { published: true }, }); return { ...stats, byCategory }; }
// src/auth/auth.service.ts import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { PrismaService } from '../prisma/prisma.service'; import * as bcrypt from 'bcrypt'; @Injectable() export class AuthService { constructor( private prisma: PrismaService, private jwtService: JwtService, ) {} async register(email: string, password: string, name: string) { const hashedPassword = await bcrypt.hash(password, 10); const user = await this.prisma.user.create({ data: { email, name, password: hashedPassword, }, select: { id: true, email: true, name: true, role: true, }, }); return { user, access_token: this.generateToken(user), }; } async login(email: string, password: string) { const user = await this.prisma.user.findUnique({ where: { email } }); if (!user || !(await bcrypt.compare(password, user.password))) { throw new UnauthorizedException('Invalid credentials'); } return { user: { id: user.id, email: user.email, name: user.name, role: user.role, }, access_token: this.generateToken(user), }; } private generateToken(user: any) { return this.jwtService.sign({ sub: user.id, email: user.email, role: user.role, }); } }
import { Injectable, CACHE_MANAGER, Inject } from '@nestjs/common'; import { Cache } from 'cache-manager'; @Injectable() export class PostsService { constructor( private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache, ) {} async findOne(id: string) { const cacheKey = `post:${id}`; // Try cache first const cached = await this.cacheManager.get(cacheKey); if (cached) { return cached; } // Fetch from database const post = await this.prisma.post.findUnique({ where: { id }, include: { author: true, category: true, tags: true }, }); if (!post) { throw new NotFoundException('Post not found'); } // Store in cache for 5 minutes await this.cacheManager.set(cacheKey, post, 300); return post; } async invalidateCache(id: string) { await this.cacheManager.del(`post:${id}`); } }
describe('PostsService', () => { let service: PostsService; let prisma: PrismaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PostsService, { provide: PrismaService, useValue: { post: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, }, }, ], }).compile(); service = module.get<PostsService>(PostsService); prisma = module.get<PrismaService>(PrismaService); }); it('should find all posts', async () => { const mockPosts = [{ id: '1', title: 'Test Post' }]; jest.spyOn(prisma.post, 'findMany').mockResolvedValue(mockPosts as any); const result = await service.findAll(); expect(result.data).toEqual(mockPosts); expect(prisma.post.findMany).toHaveBeenCalled(); }); });
describe('Posts (e2e)', () => { let app: INestApplication; let prisma: PrismaService; beforeAll(async () => { const moduleFixture = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); prisma = app.get(PrismaService); await app.init(); }); it('/posts (GET)', () => { return request(app.getHttpServer()) .get('/posts') .expect(200) .expect((res) => { expect(res.body).toHaveProperty('data'); expect(res.body).toHaveProperty('meta'); }); }); });
DATABASE_URLcreateMany, updateMany, deleteMany// src/config/configuration.ts export default () => ({ port: parseInt(process.env.PORT, 10) || 3000, database: { url: process.env.DATABASE_URL, }, jwt: { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRES_IN || '7d', }, });
FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ COPY prisma ./prisma/ RUN npm ci RUN npx prisma generate COPY . . RUN npm run build FROM node:18-alpine WORKDIR /app COPY /app/node_modules ./node_modules COPY /app/dist ./dist COPY /app/prisma ./prisma CMD ["node", "dist/main"]
NestJS and Prisma form a powerful, type-safe backend stack that scales with your needs. At CUBIS, this combination has enabled us to:
Need help with your backend? Contact our engineering team: