Prisma has become the most popular TypeScript ORM, and for good reason: its type-safe query API, declarative schema language, and migration tooling dramatically reduce the friction of database operations. But many teams only scratch the surface of what Prisma offers. This guide covers advanced patterns that we use at Nexis Limited when building production TypeScript applications with Prisma.
Middleware for Cross-Cutting Concerns
Prisma middleware intercepts queries before they reach the database, enabling patterns like logging, soft deletes, and multi-tenancy filtering without modifying business logic:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Soft delete middleware
prisma.$use(async (params, next) => {
// Intercept delete operations and convert to soft delete
if (params.action === 'delete') {
params.action = 'update';
params.args.data = { deletedAt: new Date() };
}
if (params.action === 'deleteMany') {
params.action = 'updateMany';
if (params.args.data !== undefined) {
params.args.data.deletedAt = new Date();
} else {
params.args.data = { deletedAt: new Date() };
}
}
// Automatically filter out soft-deleted records on reads
if (params.action === 'findMany' || params.action === 'findFirst') {
if (!params.args) params.args = {};
if (!params.args.where) params.args.where = {};
params.args.where.deletedAt = null;
}
return next(params);
});
Interactive Transactions
Prisma supports interactive transactions where multiple operations share a transactional context. This is essential for maintaining data consistency in complex business operations:
async function transferFunds(
fromAccountId: string,
toAccountId: string,
amount: number
): Promise<void> {
await prisma.$transaction(async (tx) => {
// Debit source account
const source = await tx.account.update({
where: { id: fromAccountId },
data: { balance: { decrement: amount } }
});
if (source.balance < 0) {
throw new Error('Insufficient funds');
}
// Credit destination account
await tx.account.update({
where: { id: toAccountId },
data: { balance: { increment: amount } }
});
// Record the transfer
await tx.transfer.create({
data: {
fromAccountId,
toAccountId,
amount,
executedAt: new Date()
}
});
}, {
isolationLevel: 'Serializable',
timeout: 10000
});
}
Raw Queries for Complex Operations
Some queries are too complex for Prisma's query API. The $queryRaw method executes raw SQL while still returning typed results:
interface RevenueByMonth {
month: Date;
revenue: number;
orderCount: bigint;
}
const monthlyRevenue = await prisma.$queryRaw<RevenueByMonth[]>`
SELECT
date_trunc('month', created_at) AS month,
SUM(total)::float AS revenue,
COUNT(*) AS "orderCount"
FROM orders
WHERE created_at >= {startDate}
AND status = 'completed'
GROUP BY date_trunc('month', created_at)
ORDER BY month DESC
`;
Multi-Tenancy with Prisma
For SaaS applications, isolating tenant data is critical. Row-level multi-tenancy adds a tenantId column to every table and filters all queries by tenant. Prisma Client Extensions (introduced in Prisma 4.16) provide a clean abstraction:
function getClientForTenant(tenantId: string) {
return prisma.$extends({
query: {
$allModels: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId };
return query(args);
}
}
}
});
}
// Usage
const tenantPrisma = getClientForTenant('tenant-abc');
const users = await tenantPrisma.user.findMany(); // Automatically filtered
Optimizing Query Performance
Prisma generates SQL, and understanding the generated queries helps optimize performance. Enable query logging to inspect what Prisma sends to the database:
const prisma = new PrismaClient({
log: [
{ level: 'query', emit: 'event' }
]
});
prisma.$on('query', (e) => {
console.log(`Query: ${e.query}`);
console.log(`Duration: ${e.duration}ms`);
});
Use select and include judiciously. Avoid fetching relations you do not need. For list endpoints, use pagination with cursor-based queries instead of offset-skip patterns. Batch related lookups using findMany with where: { id: { in: ids } } rather than individual findUnique calls.
Schema Patterns
Polymorphic Relations
Prisma does not natively support polymorphic relations, but you can model them with a union-like approach: create a join table for each concrete type or use a discriminator field with optional relations. The join-table approach is more normalized and works well with Prisma's relation system.
Audit Logging
Use Prisma middleware or database triggers to automatically capture create, update, and delete events into an audit log table. The middleware approach keeps audit logic in the application layer, making it testable and portable across database backends.
Prisma strikes an excellent balance between developer experience and production readiness. By leveraging middleware, extensions, raw queries, and thoughtful schema design, you can build TypeScript applications that are both type-safe and performant. Explore our portfolio to see projects where we have applied these patterns at scale.