7 Powerful Prisma Patterns for Your 2025 Projects
Unlock the full potential of Prisma! Discover 7 powerful, production-ready patterns for 2025 to build scalable, maintainable, and high-performance applications.
Alexandre Dubois
Senior Backend Engineer specializing in TypeScript, Node.js, and scalable database architectures.
Is Your Prisma Code Getting a Little Wild?
You started your project with Prisma, and it was pure magic. The autocompletion, the type safety, the intuitive API... it felt like a superpower. But as your application has grown, you might be noticing some growing pains. Your service files are getting bloated with complex queries, ensuring data integrity across multiple operations feels fragile, and you're starting to wonder if you're really using the ORM to its full potential.
You're not alone. Prisma is an incredible tool, but moving from basic CRUD to a robust, scalable, and maintainable data layer requires adopting proven patterns. These aren't just clever tricks; they are architectural blueprints that bring structure, performance, and predictability to your codebase. As we head into 2025, mastering these patterns is what separates a good developer from a great one.
Ready to level up? Let's dive into seven powerful Prisma patterns that will transform your projects.
1. Supercharge Your Client with $extends
Prisma's Client Extensions ($extends
) are arguably one of its most powerful features. They allow you to add custom functionality directly to your Prisma Client, making your data access layer more expressive and reusable.
What can you do with it?
- Computed Fields: Add virtual fields to your models that are calculated on the fly.
- Custom Model Methods: Create new methods on your models, like
user.deactivate()
orpost.publish()
. - Custom Queries: Build reusable query logic, like
prisma.user.findRecent(5)
.
How to Implement It
Let's create an extended client that adds a fullName
computed field to our User
model and a custom method to find active users.
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const extendedPrisma = prisma.$extends({
result: {
user: {
fullName: {
needs: { firstName: true, lastName: true },
compute(user) {
return `${user.firstName} ${user.lastName}`
},
},
},
},
model: {
user: {
async findActive() {
// 'this' refers to the User model
return prisma.user.findMany({ where: { isActive: true } })
},
},
},
})
// Export this extended client throughout your app
export default extendedPrisma
Now you can use it anywhere in your app:
// Usage
import prisma from '@/lib/prisma'
const user = await prisma.user.findFirst()
console.log(user?.fullName) // "John Doe"
const activeUsers = await prisma.user.findActive()
console.log(activeUsers) // [ { id: ..., isActive: true, ... } ]
2. The Repository Pattern for Clean Architecture
While using the Prisma Client directly is fine for smaller projects, coupling your business logic directly to the ORM can lead to issues with testability and code organization. The Repository Pattern solves this by creating an abstraction layer.
What is it?
A repository mediates between the domain and data mapping layers, acting like an in-memory collection of domain objects. In simple terms: you create a class (e.g., UserRepository
) that handles all data access for a specific model. Your services then talk to the repository, not to Prisma directly.
How to Implement It
First, define a repository for your User
model. It takes an instance of the Prisma Client in its constructor.
// src/repositories/UserRepository.ts
import { PrismaClient, User } from '@prisma/client'
export class UserRepository {
private prisma: PrismaClient
constructor(prisma: PrismaClient) {
this.prisma = prisma
}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } })
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } })
}
async create(data: { email: string; firstName: string; }): Promise<User> {
return this.prisma.user.create({ data })
}
}
Your service layer then uses this repository, making it easy to mock for testing.
// src/services/AuthService.ts
import { UserRepository } from '@/repositories/UserRepository'
export class AuthService {
private userRepository: UserRepository
constructor(userRepository: UserRepository) {
this.userRepository = userRepository
}
async registerUser(email: string, firstName: string) {
const existingUser = await this.userRepository.findByEmail(email)
if (existingUser) {
throw new Error('Email already in use')
}
return this.userRepository.create({ email, firstName })
}
}
3. Bulletproof Operations with Transactional Services
What happens when you need to perform multiple database writes that must all succeed or all fail together? Think about a user signing up and creating their initial workspace. That's two separate writes. If one fails, you need to roll back the other. Prisma's $transaction
is the answer, and wrapping it in a service is the pattern.
What is it?
This pattern involves creating service methods that wrap a series of database operations within a prisma.$transaction()
block. This guarantees atomicity: the entire block is treated as a single unit of work.
How to Implement It
Imagine a user registration that also creates a default project for them.
// src/services/OnboardingService.ts
import prisma from '@/lib/prisma'
class OnboardingService {
async onboardUser(userData: { email: string; name: string }, projectName: string) {
return prisma.$transaction(async (tx) => {
// Note: we use 'tx' instead of 'prisma' inside the transaction
const newUser = await tx.user.create({
data: userData,
})
if (!newUser) {
throw new Error('Failed to create user.')
}
const newProject = await tx.project.create({
data: {
name: projectName,
ownerId: newUser.id,
},
})
// If we reach here, both user and project were created.
// The transaction will be committed automatically.
return { user: newUser, project: newProject }
})
}
}
export const onboardingService = new OnboardingService()
If the project.create
call fails for any reason, the user.create
operation will be automatically rolled back. Your database remains in a consistent state.
4. Blazing-Fast Pagination with Cursors
As your data grows, simple offset-based pagination (using `skip` and `take`) becomes inefficient. It forces the database to count through all the skipped rows on every request. Cursor-based pagination provides a high-performance alternative.
What is it?
Instead of telling the database how many items to `skip`, you provide a `cursor`—a pointer to the last item you saw (usually its unique ID). The database can then jump directly to that point and fetch the next `take` items.
Aspect | Offset Pagination (skip /take ) | Cursor Pagination (cursor /take ) |
---|---|---|
Performance | Degrades as `skip` value increases (O(n)) | Consistent, fast performance regardless of page (O(1)) |
Data Consistency | Can skip or show duplicate items if data is added/removed between requests | Stable; new items don't affect the position of the next page |
Use Case | Simple lists, admin dashboards, when you need to jump to a specific page number | Infinite scrolling feeds, large datasets, APIs where performance is critical |
How to Implement It
Your API endpoint would take an optional `cursor` parameter. The first request has no cursor.
// Example API route (e.g., in Next.js)
import prisma from '@/lib/prisma'
export async function getPosts(cursor?: string) {
const take = 10
const posts = await prisma.post.findMany({
take,
// If a cursor is provided, skip one (the cursor itself) and start from there
skip: cursor ? 1 : 0,
// The cursor points to the last record from the previous page
cursor: cursor ? { id: cursor } : undefined,
orderBy: {
createdAt: 'desc',
},
})
const nextCursor = posts.length === take ? posts[take - 1].id : null
return {
posts,
nextCursor,
}
}
Your frontend receives `posts` and `nextCursor`. For the next request (e.g., when the user scrolls), it sends the `nextCursor` value back to the API.
5. Implementing "Soft Deletes" Like a Pro
Sometimes, you don't want to permanently delete data. You want to hide it, but keep it in the database for auditing, analytics, or potential restoration. This is called a "soft delete," and Prisma middleware makes implementing it elegantly simple.
What is it?
You add a field like `deletedAt` (a `DateTime?`) to your models. Instead of running a `DELETE` SQL command, a soft delete performs an `UPDATE` to set the `deletedAt` timestamp. You then filter out these "deleted" records from all your `find` queries.
How to Implement It
We can use Prisma middleware to intercept `delete` and `find` queries automatically. This is incredibly powerful because your application code doesn't need to change at all—it just keeps calling `prisma.user.delete()`.
// Add to your extended client setup from Pattern 1
const prisma = new PrismaClient()
const softDeletePrisma = prisma.$extends({
query: {
// Add for every model you want to soft delete
user: {
// 1. Intercept 'delete' and 'deleteMany' and turn them into 'update'
async delete({ model, args }) {
return prisma.user.update({
where: args.where,
data: { deletedAt: new Date() },
})
},
// 2. Intercept 'find' queries to exclude soft-deleted records
async findMany({ model, args, query }) {
args.where = { ...args.where, deletedAt: null }
return query(args)
},
async findUnique({ model, args, query }) {
args.where = { ...args.where, deletedAt: null }
return query(args)
},
// ... add for findFirst, count, etc.
},
},
});
export default softDeletePrisma
6. End-to-End Type Safety with Prisma.validator
One of Prisma's core promises is type safety. But how do you ensure the types you define for your API responses or frontend components perfectly match the data you've selected from the database? `Prisma.validator` is the key.
What is it?
Prisma.validator
allows you to create a reusable query definition (including `select` and `include` clauses) and, more importantly, derive a TypeScript type from it. This means if you change your query, your TypeScript types will automatically update (or break, telling you to fix them!).
How to Implement It
Let's define a reusable query for a user with their posts and derive a type from it.
import { Prisma } from '@prisma/client'
// 1. Define the query arguments using a validator
const userWithPostsArgs = Prisma.validator<Prisma.UserArgs>()({
include: { posts: { select: { id: true, title: true } } },
})
// 2. Derive the type for the payload
export type UserWithPosts = Prisma.UserGetPayload<typeof userWithPostsArgs>
// 3. Use them together in a function
async function findUserWithPosts(userId: string): Promise<UserWithPosts | null> {
return prisma.user.findUnique({
where: { id: userId },
...userWithPostsArgs, // Spread the reusable args
})
}
Now, the `UserWithPosts` type is guaranteed to match the shape of the data returned by `findUserWithPosts`. You can export this type and use it in your API layers (like tRPC) or directly in your frontend components for perfect type safety.
7. The "Raw Query" Escape Hatch for Peak Performance
While Prisma's query engine is highly optimized, there are rare cases where you need to drop down to raw SQL for maximum performance or to use a database feature Prisma doesn't yet support (like certain PostGIS functions).
What is it?
Prisma provides `queryRaw` and `executeRaw` as safe "escape hatches." They allow you to write raw SQL while still providing protection against SQL injection attacks through parameterized queries.
How to Implement It
Use `queryRaw` for queries that return data and `executeRaw` for those that don't (like `UPDATE` or `DELETE`). Let's say we need a complex report that's easier to write in SQL.
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
async function getMonthlyUserSignups(year: number) {
const result = await prisma.$queryRaw<{ month: number; count: BigInt }[]>`
SELECT
EXTRACT(MONTH FROM "createdAt") as month,
COUNT(*) as count
FROM "User"
WHERE EXTRACT(YEAR FROM "createdAt") = ${year}
GROUP BY month
ORDER BY month ASC;
`
// Note: COUNT returns BigInt, you may need to convert it
return result.map(r => ({ ...r, count: Number(r.count) }))
}
When to use this? Be cautious. This is a powerful tool, but it bypasses many of Prisma's abstractions. Use it for performance-critical analytics, complex joins that are awkward in the Prisma API, or when using database-specific features.
Conclusion: From Code to Craft
Prisma gives you the building blocks. These patterns give you the blueprints. By moving beyond basic queries and adopting structured approaches like repositories, extensions, and transactional services, you're not just writing code—you're crafting a durable, scalable, and professional-grade application.
Don't try to implement all of them at once. Pick one that solves a problem you're facing right now. Is your pagination slow? Try cursors. Are your services a mess? Introduce the repository pattern. As you integrate these patterns, you'll find your confidence in your data layer skyrocketing.
What are your go-to Prisma patterns? Did I miss any of your favorites? Share your thoughts in the comments below!