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 entre as principais versões:
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 relações 1:N grandes ouincludeprofundo — compare ambas as abordagens- O modificador de campo
omite a API de Extensões do Clienteprisma.$extendsforam adicionados- Instalações mais recentes: o pacote pode ser nomeado
prismaem vez de@prisma/client;PrismaClientpode exigir um adaptador de driver (ex.:@prisma/adapter-pg);datasource.urlpode estar emprisma.config.tsem vez deschema.prisma- Comandos CLI (
migrate dev,migrate deploy,generate) são inalterados entre versões
Quando Ativar
- Projetando ou modificando modelos e relações de schema Prisma
- Escrevendo consultas, transações ou lógica de paginação
- Usando
updateMany,deleteManyou qualquer operação em lote - Executando ou planejando migrações de banco de dados
- Implantando em ambientes serverless (Vercel, Lambda, Cloudflare Workers)
- Implementando soft delete ou filtragem de linhas multi-inquilino
Conceitos Centrais
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 escritas (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) |
Padrões 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 soft delete + consultas de 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 posteriormente requer uma migração em uma tabela ativa. updatedAt @updatedAté definido automaticamente pelo Prisma apenas emupdateeupsert(veja Anti-Padrões para a armadilha de atualização em lote).
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, evitar busca excessiva |
| Desempenho | Pode buscar em excesso 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 campos expostos:
// RUIM: vaza passwordHash, deletedAt, campos internos
return await prisma.user.findUniqueOrThrow({ where: { id } });
// BOM: mapeamento DTO explícito
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
Seleção da Forma de Transação
| Situação | Use |
|---|---|
| Operações independentes, sem interdependência | Forma de array |
| Etapa posterior depende do resultado anterior | Forma interativa |
| Chamadas externas (email, HTTP) envolvidas | Fora da transação completamente |
// Forma de array — agrupado 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ário em instalações Prisma mais recentes)
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, sem 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 dizer qual está correto.
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 (ex.: 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 administrativas).
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('Registro referenciado não existe');
}
throw e;
}
Códigos comuns: P2002 violação de unicidade · 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 Prisma brutas aos consumidores da API.
Pool de Conexões — Serverless
Incorpore parâmetros de conexão diretamente em DATABASE_URL — concatenação de strings quebra se a URL já tiver parâmetros de consulta (ex.: ?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:
// limite 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();
Anti-Padrõ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 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.
A forma interativa $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 lote realmente precisar
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev pode redefinir o banco de dados
migrate dev detecta deriva de schema e pode solicitar a redefinição do banco, descartando todos os dados.
# NUNCA em dev compartilhado, staging ou produção
npx prisma migrate dev --name add_column
# Seguro em todos os lugares, exceto dev local solo
npx prisma migrate deploy
# Verifique a deriva 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
Prisma soma de verificação (checksum) de cada arquivo de migração. Editar após a aplicação causa P3006 checksum mismatch em todos os ambientes onde o original já foi executado. Crie uma nova migração.
Mudanças de schema que quebram exigem migração em várias etapas
Adicionar NOT NULL a uma coluna existente ou renomear uma coluna em uma única migração bloqueará a tabela ou descartará dados. Use expandir e contrair:
# Passo 1: criar migração localmente, depois implantar
npx prisma migrate dev --name add_new_column # apenas local
npx prisma migrate deploy # staging / produção
// Passo 2: preencher dados (execute em um script ou job de migração, não no shell)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# Passo 3: criar a migração da restrição NOT NULL localmente, depois implantar
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 lote 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 deletados
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 única em where — adicionar deletedAt: null junto com id quebra o tipo porque { id, deletedAt } não é uma restrição única composta. Use findFirstOrThrow.
// RUIM: retorna usuário com soft delete
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// RUIM: erro de tipo Prisma — { id, deletedAt } não é uma restrição única
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 ao detectar deriva |
| Mapeie entidades para DTOs de resposta | Evita vazar campos internos |
Sempre filtre deletedAt: null em consultas que devem ignorar soft delete | O Prisma não filtra automaticamente linhas com soft delete |
Use findFirstOrThrow em vez de findUniqueOrThrow ao filtrar por deletedAt: null | findUniqueOrThrow requer uma restrição única, não suporta deletedAt |
Defina updatedAt manualmente em updateMany e deleteMany se precisar de registro de data/hora preciso | O decorador @updatedAt não é acionado em operações em lote |
| Mantenha chamadas externas (API HTTP, email) fora das transações do banco de dados | Transações têm timeout de 5s; chamadas externas podem exceder |
| Use paginação por cursor para feeds e listas roláveis | Desempenho consistente, evita saltos de dados |
| Use paginação por deslocamento apenas para recursos de administração / backend | Requer contagem, pode ser caro em grandes conjuntos |
| Crie um singleton do PrismaClient e reutilize em todas as requisições | Evita esgotamento do pool de conexões |
Em serverless, defina connection_limit=1 e pool_timeout via variável de ambiente | Impede contenção de conexão entre instâncias |
Sempre use try/catch com códigos de erro Prisma para mapear para erros HTTP significativos | Mensagens de erro Prisma brutas vazam detalhes internos |
Referências
-
Documentação do Prisma:
-
Referência de código de erro: