Padrões Prisma
Padrões de produção e armadilhas não óbvias para o Prisma ORM em backends TypeScript.
Verifique sua versão antes de aplicar os padrões. A superfície da API do Prisma evoluiu através de grandes lançamentos:
npx prisma --versionDiferenças notáveis na API entre versões:
relationJoinspode carregar relações via JOIN em vez de consultas separadas, mas pode causar explosão de linhas em grandes relações 1:N ouincludeprofundos — compare ambas as abordagens- O modificador de campo
omite a API de Extensões do Clienteprisma.$extendsforam adicionados- Instalações mais novas: o pacote pode ser nomeado
prismaem vez de@prisma/client;PrismaClientpode exigir um adaptador de driver (por exemplo,@prisma/adapter-pg);datasource.urlpode ficar emprisma.config.tsem vez deschema.prisma- Comandos CLI (
migrate dev,migrate deploy,generate) permanecem inalterados entre versões
Quando Ativar
- Projetando ou modificando modelos e relações do schema Prisma
- Escrevendo consultas, transações ou lógica de paginação
- Usando
updateMany,deleteManyou qualquer operação em massa - Executando ou planejando migrações de banco de dados
- Implantando em ambientes serverless (Vercel, Lambda, Cloudflare Workers)
- Implementando soft delete ou filtragem de linha multi-inquilino
Conceitos Principais
Estratégia de ID
| Estratégia | Use Quando | Evite Quando |
|---|---|---|
@default(cuid()) | Escolha padrão — seguro para URL, ordenável, sem colisões | IDs sequenciais necessários para sistemas externos |
@default(uuid()) | Interoperabilidade com sistemas não Prisma é necessária | Tabelas com muitas gravações (UUIDs aleatórios fragmentam índices B-tree) |
@default(autoincrement()) | Tabelas de junção internas, logs de auditoria | IDs voltados para o público (expõe a contagem de registros) |
Defaults de Schema
model User {
id String @id @default(cuid())
email String @unique // @unique já cria um índice — nenhum @@index necessário
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // composto para consultas de soft delete + ordenação
}
- Adicione
@@indexem cada chave estrangeira e coluna usada emWHEREouORDER BY. - Declare
deletedAt DateTime?antecipadamente quando soft delete for um requisito previsível — adicioná-lo depois requer uma migração em uma tabela ativa. updatedAt @updatedAté definido automaticamente pelo Prisma apenas emupdateeupsert(veja Antipadrões para a armadilha de atualização em massa).
include vs select
include | select | |
|---|---|---|
| Retorna | Todos os campos escalares + relações especificadas | Apenas campos especificados |
| Use quando | Você precisa da maioria dos campos mais uma relação | Caminhos críticos, tabelas grandes, evitando excesso de busca |
| Performance | Pode buscar demais em tabelas largas | Payload mínimo, mais rápido em grandes conjuntos de dados |
| Nota Prisma 5 | Usa JOIN por padrão (relationJoins) | Mesmo |
// include — todas as colunas + relação
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — lista de permissões explícita
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
Nunca retorne entidades Prisma brutas de respostas de API — mapeie para DTOs de resposta para controlar os campos expostos:
// RUIM: vaza passwordHash, deletedAt, campos internos
return await prisma.user.findUniqueOrThrow({ where: { id } });
// BOM: mapeamento explícito de DTO
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
Seleção de Forma de Transação
| Situação | Use |
|---|---|
| Operações independentes, sem interdependência | Forma array |
| Etapa posterior depende do resultado anterior | Forma interativa |
| Chamadas externas (email, HTTP) envolvidas | Fora da transação completamente |
// Forma array — agrupada em uma única viagem de ida e volta
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// Forma interativa — use apenas o cliente tx, nunca o cliente prisma externo
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Forbidden');
return tx.post.create({ data: { title, authorId: user.id } });
});
Singleton do PrismaClient
Cada instância de PrismaClient abre seu próprio pool de conexões. Instancie uma vez.
// lib/prisma.ts
// Opção A — inicialização baseada em adaptador (necessária para instalações mais novas do Prisma)
import { PrismaClient } from '@prisma/client'; // ou o caminho do cliente gerado para sua configuração
import { PrismaPg } from '@prisma/adapter-pg';
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
}
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// Opção B — inicialização direta (instalações mais antigas, nenhum adaptador necessário)
// import { PrismaClient } from '@prisma/client';
// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
Use a Opção A se sua instalação do Prisma exigir um argumento adapter no construtor PrismaClient.
Use a Opção B se new PrismaClient() funcionar sem argumentos. Deixe o compilador lhe dizer qual é a correta.
O padrão globalThis evita instâncias duplicadas durante recarga a quente (Next.js, nodemon, ts-node-dev).
Problema N+1
Carregar relações dentro de um loop emite uma consulta por linha.
// RUIM: N+1 — uma consulta extra por usuário
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// BOM: consulta única
const users = await prisma.user.findMany({ include: { posts: true } });
Com relationJoins do Prisma 5+, a forma include usa um único JOIN. Em grandes conjuntos 1:N isso pode aumentar o tamanho do conjunto de resultados — compare ambas as abordagens se a relação puder retornar muitas linhas por pai.
Exemplos de Código
Paginação por Cursor (preferida para feeds e grandes conjuntos de dados)
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // ordenação secundária evita paginação instável em carimbos de data/hora duplicados
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}
Busque limit + 1 e remova o último — maneira canônica de detectar hasNextPage sem uma consulta de contagem extra. Sempre inclua um campo único (por exemplo, id) como um orderBy secundário para evitar paginação instável quando várias linhas compartilham o mesmo carimbo de data/hora. Use paginação por deslocamento apenas quando os usuários precisam pular para páginas arbitrárias (tabelas de admin).
Soft Delete
// Sempre filtre explicitamente — não confie em middleware (esconde o comportamento, difícil de depurar)
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // restaurar
Tratamento de Erros
import { Prisma } from '@prisma/client'; // ou o caminho do cliente gerado para sua configuração
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('Email já existe');
if (e.code === 'P2025') throw new NotFoundError('Registro não encontrado');
if (e.code === 'P2003') throw new BadRequestError('O registro referenciado não existe');
}
throw e;
}
Códigos comuns: P2002 violação de unique · P2025 não encontrado · P2003 violação de chave estrangeira.
Capture no limite do serviço e traduza para erros de domínio. Nunca exponha mensagens brutas do Prisma para consumidores da API.
Pool de Conexões — Serverless
Incorpore os parâmetros de conexão diretamente em DATABASE_URL — concatenação de strings quebra se a URL já tiver parâmetros de consulta (por exemplo, ?schema=public):
# .env — preferido: incorporar parâmetros na URL
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
# Com um pooler externo (PgBouncer, Supabase pooler)
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
// Vercel, AWS Lambda e runtimes serverless similares:
// limitar o pool a 1 por instância; connection_limit e pool_timeout controlados via DATABASE_URL
// Configuração baseada em adaptador (se sua instalação do Prisma exigir um adaptador):
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// Configuração direta (se sua instalação do Prisma não exigir um adaptador):
// const prisma = new PrismaClient();
Antipadrões
updateMany retorna uma contagem, não registros
// RUIM: resultado é { count: 2 } — users[0] é undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// BOM: capture os IDs primeiro, depois atualize, então busque apenas as linhas afetadas
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });
O mesmo se aplica a deleteMany — retorna { count: n }, nunca as linhas excluídas.
Forma interativa de $transaction expira após 5 segundos
// RUIM: chamada externa dentro da transação excede o padrão de 5s → "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // chamada externa
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// BOM: chamadas externas fora da transação
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// Aumente o timeout apenas quando o processamento em massa genuinamente precisar dele
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev pode redefinir o banco de dados
migrate dev detecta desvio de schema e pode solicitar a redefinição do banco, descartando todos os dados.
# NUNCA em ambientes de desenvolvimento compartilhado, staging ou produção
npx prisma migrate dev --name add_column
# Seguro em qualquer lugar, exceto desenvolvimento local solo
npx prisma migrate deploy
# Verifique o desvio sem aplicar
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
Editar manualmente um arquivo de migração quebra implantações futuras
O Prisma faz checksum de cada arquivo de migração. Editar após aplicar causa P3006 checksum mismatch em todos os ambientes onde a migração original já foi executada. Crie uma nova migração.
Alterações de schema que quebram requerem migração em várias etapas
Adicionar NOT NULL a uma coluna existente ou renomear uma coluna em uma migração bloqueará a tabela ou descartará dados. Use expandir-e-contratar:
# Passo 1: crie a migração localmente, depois implante
npx prisma migrate dev --name add_new_column # apenas local
npx prisma migrate deploy # staging / produção
// Passo 2: preencha os dados (execute em um script ou job de migração, não no shell)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# Passo 3: crie a migração da restrição NOT NULL localmente, depois implante
npx prisma migrate dev --name make_new_column_required # apenas local
npx prisma migrate deploy # staging / produção
@updatedAt não é acionado em updateMany
@updatedAt é definido automaticamente apenas em update e upsert. Escritas em massa o deixam desatualizado.
// RUIM: updatedAt permanece com seu valor antigo
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// BOM
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
Soft delete + findUniqueOrThrow vaza registros excluídos
findUniqueOrThrow lança P2025 apenas quando a linha não existe no banco. Linhas com soft delete ainda existem e são retornadas sem erro.
findUniqueOrThrow requer um campo de restrição unique em where — adicionar deletedAt: null junto com id quebra o tipo porque { id, deletedAt } não é uma restrição unique composta. Use findFirstOrThrow em vez disso.
// RUIM: retorna usuário com soft delete
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// RUIM: erro de tipo do Prisma — { id, deletedAt } não é uma restrição unique
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// BOM: findFirstOrThrow suporta condições where arbitrárias
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
deleteMany sem where exclui todas as linhas
// RUIM: limpa silenciosamente a tabela
await prisma.post.deleteMany();
// BOM
await prisma.post.deleteMany({ where: { authorId: userId } });
Melhores Práticas
| Regra | Razão |
|---|---|
migrate deploy em CI/CD, migrate dev apenas localmente | migrate dev pode redefinir o banco em desvio |
| Mapear entidades para DTOs de resposta | Previne vazamento de campos internos |
| ... [truncado para tradução] |