Prisma ORM com Claude Code: schema, migrations, transactions e testes
Implemente Prisma ORM com Claude Code: schema, revisão de migration, transactions, seed/test e checklist de produção.
Prisma ORM é uma camada tipada de banco de dados para acessar a DB com segurança a partir de TypeScript. Com Claude Code, ele vira um fluxo completo: desenhar o schema, gerar e revisar SQL de migration, escrever queries com Prisma Client, limitar transactions e criar seed/test.
O risco é tratar Prisma como um gerador automático de CRUD. Isso costuma produzir código que roda, mas com índices ausentes,include grande demais, cascade perigoso ou migration que quebra quando encontra dados reais. Este guia usa uma API de blog para mostrar um caminho revisável e bom o suficiente para sites de conteúdo, SaaS e ferramentas internas.
Use as fontes oficiais como referência: Prisma ORM, Prisma Schema, Transactions e Prisma Migrate. No ClaudeCodeLab, veja também guia inicial de Claude Code, Drizzle ORM, Supabase e Redis caching.
Fluxo de trabalho
flowchart LR
A["Regras do produto para Claude Code"] --> B["Desenhar schema.prisma"]
B --> C["Gerar e revisar migration.sql"]
C --> D["Implementar Prisma Client"]
D --> E["Adicionar seed e testes"]
E --> F["Checklist antes de produção"]
Separe a entrega em schema, SQL migration e código TypeScript. Claude Code pode criar o rascunho, mas constraints, índices, regras de deleção e fronteiras de transaction precisam de revisão humana.
Prompt para Claude Code
Projete uma camada Prisma ORM para uma API de blog.
Contexto:
- TypeScript + Prisma ORM + SQLite local, com possível migração futura para PostgreSQL
- Modelos: User, Post, Category, Comment, Notification, AuditLog
- Post usa status string: DRAFT/PUBLISHED/ARCHIVED
- email e slug devem ser únicos
- A lista pública filtra por status, publishedAt, author e category
- Ao deletar um Post, Comment e linhas de junção são removidos em cascade
- Deletar User fica restrito; anonimização será outra migration
Entregue:
1. prisma/schema.prisma
2. Pontos de revisão da migration SQL
3. Código Prisma Client para create/list/publish
4. Comandos de seed e test
5. Checklist de produção
Um bom prompt descreve a operação real, não só os nomes das tabelas. Ele explica o que é único, o que não pode ser apagado e quais telas precisam de índices.
Setup local
{
"type": "module",
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "prisma db seed",
"dev": "tsx src/demo.ts",
"test": "vitest run"
},
"dependencies": {
"@prisma/client": "latest",
"dotenv": "latest"
},
"devDependencies": {
"prisma": "latest",
"tsx": "latest",
"typescript": "latest",
"vitest": "latest"
}
}
npm install
echo 'DATABASE_URL="file:./dev.db"' > .env
mkdir prisma src
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
});
schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
email String @unique
name String
role String @default("editor")
posts Post[]
comments Comment[]
notifications Notification[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([role])
}
model Post {
id String @id @default(cuid())
slug String @unique
title String
body String
status String @default("DRAFT")
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
categories CategoriesOnPosts[]
comments Comment[]
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([status, publishedAt])
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
posts CategoriesOnPosts[]
}
model CategoriesOnPosts {
postId String
categoryId String
assignedAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
}
model Comment {
id String @id @default(cuid())
body String
postId String
authorId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@index([postId, createdAt])
@@index([authorId])
}
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String
message String
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId, readAt])
}
model AuditLog {
id String @id @default(cuid())
action String
targetId String
metadata String?
createdAt DateTime @default(now())
@@index([action, createdAt])
}
Peça para Claude Code justificar cada índice. status + publishedAt atende a lista pública; postId + createdAt atende comentários. Índice sem query real por trás é custo.
Revisão de migration
npx prisma format
npx prisma migrate dev --create-only --name init_blog
# Revisar prisma/migrations/*/migration.sql no PR
npx prisma migrate dev
npx prisma generate
Em produção:
npx prisma migrate deploy
db push serve para protótipo, não para produção. Antes de publicar, reviseDROP, novosNOT NULL, unique constraints sobre dados existentes e cascade.
A orientação de rollback deve estar na PR antes do deploy. Se a migration já passou, em geral é melhor criar outra migration para desfazer o schema change. Se ela falhou no meio, siga o workflow oficial de down migrations: conferir backup, rodar migrate status, aplicar SQL revisado se necessário e marcar só a migration falha com migrate resolve.
npx prisma migrate status
npx prisma migrate resolve --rolled-back "20260603090000_failed_change"
Prisma Client e transactions
// src/db.ts
import { PrismaClient } from "./generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ["warn", "error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/posts.ts
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
export async function listPublishedPosts(params: {
page?: number;
perPage?: number;
categorySlug?: string;
search?: string;
}) {
const page = Math.max(params.page ?? 1, 1);
const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);
const where: Prisma.PostWhereInput = {
status: "PUBLISHED",
...(params.categorySlug
? { categories: { some: { category: { slug: params.categorySlug } } } }
: {}),
...(params.search
? { OR: [{ title: { contains: params.search } }, { body: { contains: params.search } }] }
: {}),
};
const [items, total] = await prisma.$transaction([
prisma.post.findMany({
where,
skip: (page - 1) * perPage,
take: perPage,
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
select: {
id: true,
slug: true,
title: true,
publishedAt: true,
author: { select: { name: true } },
_count: { select: { comments: true } },
},
}),
prisma.post.count({ where }),
]);
return { items, pagination: { page, perPage, total } };
}
export async function publishPost(postId: string) {
return prisma.$transaction(async (tx) => {
const post = await tx.post.findUnique({
where: { id: postId },
select: { id: true, title: true, status: true, authorId: true },
});
if (!post) throw new Error("Post not found");
if (post.status === "PUBLISHED") return post;
const published = await tx.post.update({
where: { id: post.id },
data: { status: "PUBLISHED", publishedAt: new Date() },
select: { id: true, title: true, status: true, publishedAt: true },
});
await tx.notification.create({
data: {
userId: post.authorId,
type: "POST_PUBLISHED",
message: `${post.title} was published`,
},
});
await tx.auditLog.create({
data: {
action: "POST_PUBLISHED",
targetId: post.id,
metadata: JSON.stringify({ title: post.title }),
},
});
return published;
});
}
Use$transaction([]) para leituras independentes, como lista e count. Use interactive transaction quando uma operação de negócio precisa alterar várias tabelas de forma atômica. Não faça email, webhook ou chamada lenta de rede dentro da transaction.
SQL seguro
Comece por Prisma Client queries. Quando uma consulta de relatório realmente precisa de SQL, o pitfall é concatenar strings, criando SQL-injection risk. A documentação de raw queries do Prisma recomenda tagged templates ou Prisma.sql.
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
export async function topAuthors(limit = 10) {
return prisma.$queryRaw<
{ authorId: string; postCount: bigint }[]
>(Prisma.sql`
SELECT "authorId", COUNT(*) AS "postCount"
FROM "Post"
WHERE "status" = ${"PUBLISHED"}
GROUP BY "authorId"
ORDER BY "postCount" DESC
LIMIT ${Math.min(limit, 50)}
`);
}
Peça para Claude Code justificar cada $queryRawUnsafe. Para nomes dinâmicos de tabela ou coluna, use uma allowlist revisada por uma pessoa.
Seed, test e checklist
// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma/client";
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.upsert({
where: { email: "editor@example.com" },
update: { name: "Editor" },
create: { email: "editor@example.com", name: "Editor", role: "admin" },
});
await prisma.post.upsert({
where: { slug: "claude-code-prisma-demo" },
update: { status: "PUBLISHED", publishedAt: new Date() },
create: {
slug: "claude-code-prisma-demo",
title: "Claude Code Prisma demo",
body: "Seeded article for local verification.",
status: "PUBLISHED",
publishedAt: new Date(),
authorId: user.id,
},
});
}
main().finally(async () => prisma.$disconnect());
npm run db:migrate -- --name init_blog
npm run db:generate
npm run db:seed
npm run test
Checklist: constraints e índices ligados a telas reais, SQL sem mudança destrutiva acidental, paginação com limite, transactions curtas, seed idempotente, testes de rollback ou repetição, produção commigrate deploy.
Casos de uso, erros e CTA
Três usos comuns: gestão editorial, painel SaaS multi-tenant e liberação de permissão após pagamento. Conteúdo precisa de slug e status confiáveis; SaaS precisa de tenant scope em toda query; pagamentos precisam de idempotência, audit log e transaction clara.
Em um teste com uma pequena API de blog, Masa pediu para Claude Code explicar os riscos de migration antes de escrever a aplicação. Isso revelou cedo um cascade amplo demais e retorno de campos desnecessários na lista pública. Esse é o melhor uso: acelerar com IA e verificar com tipos, SQL e testes.
Para estudo individual, comece em /products/. Para equipes que precisam de workflow de review de DB com Claude Code, veja /training/. Com um schema real ou PR gerado por IA, a conversa começa nos riscos concretos.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Checklist de auditoria inicial de repositório com Claude Code
Audite um repo em 20 minutos antes da primeira edição: escopo, riscos, provas e CTA de receita.
Claude Code Harness Lite: uma base pequena para mudanças seguras
Um fluxo iniciante que separa leitura, edição, prova, URL pública e CTA de receita no Claude Code.
Primeiro repo map com Claude Code: ler código existente sem gastar contexto
Fluxo seguro para ler um repositório com Claude Code antes de editar: mapa, tarefas pequenas, provas, PDF grátis, Gumroad e consultoria.