Backend Development

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.

A

Alexandre Dubois

Senior Backend Engineer specializing in TypeScript, Node.js, and scalable database architectures.

8 min read16 views

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() or post.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

Advertisement

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.

AspectOffset Pagination (skip/take)Cursor Pagination (cursor/take)
PerformanceDegrades as `skip` value increases (O(n))Consistent, fast performance regardless of page (O(1))
Data ConsistencyCan skip or show duplicate items if data is added/removed between requestsStable; new items don't affect the position of the next page
Use CaseSimple lists, admin dashboards, when you need to jump to a specific page numberInfinite 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!

Tags

You May Also Like