Client Extensions

Prisma Intermediate/Advanced

Chapter 6 ยท Client Extensions

Some behaviour belongs everywhere: logging slow queries, hiding soft-deleted posts, adding a computed url field. Copying that into every route invites mistakes. Client extensions let you build it into Prisma Client itself, with $extends.

Middleware is gone in Prisma 7
Older tutorials use prisma.$use(...) middleware. It was deprecated in Prisma 5 and removed in Prisma 7. The query component of $extends, covered below, replaces it.

How $extends Works

const base = new PrismaClient({ adapter }); const prisma = base.$extends({ /* ... */ });

$extends returns a new client and leaves the original untouched. You can chain several extensions — base.$extends(a).$extends(b) — and if two define the same thing, the later one wins. An extension object can have four parts:

ComponentAdds or changesExample
queryWhat happens when a query runsTiming, filtering, default arguments
modelNew methods on a modelprisma.post.findPublishedBySlug()
resultComputed fields on returned recordspost.url
clientNew top-level methodsprisma.$healthCheck()

query: Wrapping Every Query

const prisma = base.$extends({ query: { $allModels: { async $allOperations({ model, operation, args, query }) { const start = performance.now(); const result = await query(args); // run the real query const ms = performance.now() - start; if (ms > 200) console.warn(`Slow query: ${model}.${operation} took ${ms.toFixed(0)} ms`); return result; }, }, }, });
  • query(args) runs the original operation. Forget to call it, or to return its result, and the query silently does nothing useful.
  • Target one model and operation (post: { findMany(...) }), all operations on a model (post: { $allOperations(...) }), or everything ($allModels).
  • Raw queries have their own top-level hooks: $queryRaw, $executeRaw and the Unsafe variants.

Changing arguments: a soft-delete filter

Soft deletion marks a row as deleted instead of removing it, so it can be restored. Add deletedAt DateTime? to Post and migrate, then hide deleted posts by default:

const prisma = base.$extends({ query: { post: { async findMany({ args, query }) { args.where = { ...args.where, deletedAt: null }; return query(args); }, async findFirst({ args, query }) { args.where = { ...args.where, deletedAt: null }; return query(args); }, async count({ args, query }) { args.where = { ...args.where, deletedAt: null }; return query(args); }, }, }, });
Soft delete by extension has real gaps
  • Nested reads aren't covered. Prisma's docs state that query extensions don't apply to nested read and write operations. prisma.user.findMany({ include: { posts: true } }) still returns deleted posts.
  • Every operation needs thought. findUnique, aggregate, groupBy, raw SQL and updates each behave differently; the example above covers only three.
  • You can't change select or include in a query extension, because that would change the result's type.
For data that must never leak, filter explicitly, use a database view, or use row-level security in the database.

model: Adding Methods

import { Prisma } from "../generated/prisma/client"; const prisma = base.$extends({ model: { post: { async findPublishedBySlug(slug: string) { return base.post.findFirst({ where: { slug, published: true, deletedAt: null } }); }, async softDelete(id: number) { return base.post.update({ where: { id }, data: { deletedAt: new Date() } }); }, }, $allModels: { async exists<T>(this: T, where: Prisma.Args<T, "findFirst">["where"]) { const ctx = Prisma.getExtensionContext(this); const found = await (ctx as any).findFirst({ where, select: { id: true } }); return found !== null; }, }, }, }); await prisma.post.softDelete(12); await prisma.user.exists({ email: "ada@example.com" }); // true / false, on every model

Model methods keep business rules in one place: routes call findPublishedBySlug instead of remembering all three conditions. The $allModels version uses Prisma.getExtensionContext(this) to find out which model it was called on.

result: Computed Fields

const prisma = base.$extends({ result: { post: { url: { needs: { slug: true }, compute(post) { return `/posts/${post.slug}`; }, }, readingMinutes: { needs: { content: true }, compute(post) { const words = (post.content ?? "").split(/\s+/).filter(Boolean).length; return Math.max(1, Math.round(words / 200)); }, }, }, }, }); const post = await prisma.post.findFirst({ select: { title: true, url: true } }); // { title: "Hello", url: "/posts/hello" }

needs lists the real fields the computation uses, so Prisma fetches them even when you only select the computed field. Computed fields are typed, can be selected like real ones, and are calculated only when read. They can't be used in where or orderBy — the database doesn't know they exist.

client: Top-Level Methods

const prisma = base.$extends({ client: { async $healthCheck() { await base.$queryRaw`SELECT 1`; return { ok: true, at: new Date().toISOString() }; }, }, }); app.get("/health", async (req, res) => res.json(await prisma.$healthCheck()));

Packaging and Sharing Extensions

// lib/extensions/slowQueryLog.ts export const slowQueryLog = (thresholdMs = 200) => Prisma.defineExtension({ name: "slowQueryLog", query: { $allModels: { async $allOperations({ model, operation, args, query }) { /* ... */ } } }, }); // lib/prisma.ts const base = new PrismaClient({ adapter }); export const prisma = base .$extends(slowQueryLog(200)) .$extends(postHelpers) .$extends(postComputedFields); export type AppPrisma = typeof prisma;
Update your types after extending
An extended client has a different type from PrismaClient. Functions that receive the client (as in Chapter 5) should use AppPrisma; otherwise TypeScript won't know about softDelete or url. In unit tests, you can still mock it with mockDeep<AppPrisma>().

Hands-On Exercises

Exercise 1

Write a query extension that makes post.findMany return at most 50 rows unless the caller passes take, and never more than 100 even if they do.

๐Ÿ“„ View solution
Exercise 2

Add a result extension giving users a displayName (their name, or the part of their email before the @ if they have none), and a model method user.findByEmail that ignores letter case in the email.

๐Ÿ“„ View solution
Exercise 3

Using the soft-delete extension from this chapter, write an integration test (Chapter 5 style) that shows exactly which queries hide a soft-deleted post and which still return it. Explain what you'd change before relying on it.

๐Ÿ“„ View solution

Chapter 6 Quick Reference

  • client.$extends({...}) returns a new client; the original is unchanged; chain several; later wins
  • query: wrap operations; always return query(args); $allModels/$allOperations; replaces removed $use middleware
  • Query extensions don't apply to nested reads/writes and can't change select/include
  • model: custom methods; Prisma.getExtensionContext(this) in $allModels
  • result: computed fields with needs and compute; not usable in where/orderBy
  • client: top-level $methods; package reusable ones with Prisma.defineExtension
  • Export type AppPrisma = typeof prisma and use it wherever the client is passed around