Aula 7: Persistência com Prisma — ORM, arquitetura e migrations
Na aula 6 o CRUD ficou completo, validado e documentado — e todo o acervo desaparecia a cada reinício da aplicação. Esta aula troca o array em memória por um banco relacional real.
A troca é também um teste de arquitetura. Se as aulas 5 e 6 separaram corretamente transporte, regra e dados, então substituir a infraestrutura não deve exigir tocar em rotas, DTOs ou códigos de status. É exatamente isso que o laboratório desta aula vai verificar — com um schema deliberadamente simples, de uma entidade só. O modelo cresce para o acervo completo, com todos os seus relacionamentos, na aula 8.
| O que vem depois | Onde |
|---|---|
| Modelar relacionamentos obrigatórios e opcionais | Aula 8 — Modelagem de relacionamentos |
| Consultas, transações e seed | Aula 9 — Consultas, transações e seed |
| Autenticação, guards e proteção de rotas | Aula 10 — Autenticação e autorização |
| Consumo desta API pelo cliente web | Módulo 3 — Next.js |
| Consumo da mesma API pelo cliente mobile | Módulo 4 — Flutter |
Objetivos
Ao final desta aula, você deve ser capaz de:
- Explicar o que um ORM resolve, o que ele custa e quando SQL direto é preferível.
- Descrever em que camada da arquitetura o Prisma entra, e o que uma troca de armazenamento deve — e não deve — afetar.
- Gerar e aplicar migrations, e distinguir
migrate devdemigrate deploy,migrate resetedb push. - Integrar o Prisma ao NestJS por meio de um provider injetável.
- Reconhecer os dois primeiros erros do Prisma que toda API precisa tratar (
P2002,P2025) e traduzi-los para status HTTP.
Ambiente sugerido
| Ferramenta | Versão | Observação |
|---|---|---|
| PostgreSQL | 16 ou superior, em localhost:5432 | instalação local; a aula assume que o serviço já está no ar |
| Prisma ORM | 7.x | prisma (CLI) e @prisma/client |
| Adaptador | @prisma/adapter-pg | obrigatório na versão 7 |
| Node.js | 22 LTS ou superior | requisito do Prisma 7 e do NestJS 11 |
Material sobre Prisma escrito para as versões 5 e 6 — que ainda é a maior parte do que se encontra na web — não funciona sem adaptação. As quatro mudanças que mais afetam este laboratório:
- O gerador passou a ser
prisma-client(e nãoprisma-client-js), comoutputobrigatório: o cliente não é mais gerado dentro denode_modules, e a importação deixa de ser@prisma/client. - Criar o
PrismaClientexige um driver adapter — para PostgreSQL,@prisma/adapter-pg. - O arquivo
.envnão é lido automaticamente; a configuração passou paraprisma.config.ts. migrate devnão roda maisgeneratenem o seed automaticamente — os dois viraram comandos explícitos.
Ao consultar a documentação, confirme que a página se refere à versão 7.
Parte 1 — O que um ORM resolve
Sem ORM, buscar um livro é escrever a consulta, mapear o resultado e cuidar dos tipos na mão:
const resultado = await pool.query('SELECT id, titulo, ano FROM livro WHERE id = $1', [id]);
const linha = resultado.rows[0]; // tipo: any
O problema não é a consulta — é tudo em volta dela: linha não tem tipo, o nome
das colunas só é verificado em tempo de execução, e um SELECT que esqueceu uma
coluna só falha quando alguém acessa o campo faltante.
Um ORM (Object-Relational Mapper) faz a ponte entre tabelas e objetos. O Prisma faz isso de um jeito específico, que é o motivo de ele ter sido escolhido para a disciplina: a partir de um schema declarativo, ele gera um cliente TypeScript em que cada modelo, campo e relação existe como tipo.
const livro = await prisma.livro.findUnique({ where: { id } });
// ^? Livro | null — o editor conhece todos os campos
| O que o ORM resolve | O que ele custa |
|---|---|
| Tipagem de ponta a ponta entre banco e código | Uma camada a mais para aprender e depurar |
| Migrations versionadas junto com o código | Consultas muito específicas ficam difíceis de expressar |
| Proteção contra injeção de SQL por parametrização | O SQL gerado nem sempre é o que você escreveria |
| Portabilidade parcial entre bancos | Risco real de escrever consultas ineficientes sem perceber |
A consulta ineficiente que o ORM gera continua sendo uma consulta ineficiente — e
quem vai diagnosticá-la é quem entende de índice, plano de execução e JOIN. O
Prisma tem uma saída para os casos em que a abstração atrapalha: $queryRaw
executa SQL parametrizado e devolve o resultado tipado. Usá-lo não é derrota; é
escolher a ferramenta certa para o caso.
Parte 2 — Onde o banco entra na arquitetura
A camada de persistência entra abaixo do service. A Figura 1 é menos sobre o que cada camada faz do que sobre o que ela deliberadamente ignora.
O critério de que a separação está correta é objetivo: trocar o armazenamento deve alterar apenas o service e a camada Prisma. Se o controller, os DTOs ou as rotas precisarem mudar, a infraestrutura vazou para o contrato — e os clientes web e mobile, que já foram escritos contra ele, quebram. É esse critério, e não o banco funcionando, que o laboratório desta aula verifica.
Parte 3 — Migrations
Uma migration é a diferença entre o estado atual do banco e o schema desejado, gravada como SQL versionado. Para o schema virar tabela no banco e tipo no editor, três coisas precisam acontecer — mas, na versão 7, não por um comando só:
npx prisma migrate dev --name criar_livro
npx prisma generate
generate é um passo separadoAté a versão 6, migrate dev regenerava o cliente automaticamente. Isso mudou. Se
você alterar o schema, rodar a migration e o editor continuar sem reconhecer o
campo novo, quase sempre falta rodar prisma generate.
O SQL gerado fica em prisma/migrations/<timestamp>_criar_livro/migration.sql.
Ele entra no Git — é o histórico do banco, e é o que permite a outra pessoa
reproduzir a mesma estrutura a partir de um banco vazio.
| Comando | Onde usar | O que faz |
|---|---|---|
migrate dev | Só em desenvolvimento | Compara, gera a migration e aplica; pode propor recriar o banco |
migrate deploy | Ambiente compartilhado e produção | Apenas aplica as pendentes; nunca recria |
migrate reset | Desenvolvimento | Apaga tudo e reaplica do zero |
db push | Protótipo descartável | Sincroniza sem gerar migration — não use no projeto |
migrate dev pode apagar seus dadosSe o Prisma detectar que o banco divergiu do histórico — porque alguém alterou uma
tabela pelo psql, por exemplo — ele propõe recriar o banco. Em desenvolvimento
isso é aceitável; num banco compartilhado com a turma, não. Leia o que o comando
pergunta antes de confirmar.
Parte 4 — O Prisma dentro do NestJS
O Prisma Client é uma dependência como qualquer outra — e por isso entra pelo mesmo mecanismo de todas as outras: um provider injetável.
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor(config: ConfigService) {
super({
adapter: new PrismaPg({
connectionString: config.getOrThrow<string>('DATABASE_URL'),
}),
});
}
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
Três pontos merecem atenção:
- A importação não é
@prisma/client. Na versão 7 o cliente é gerado no caminho declarado emoutput, e é de lá que ele vem. ConfigServiceem vez deprocess.envdireto.getOrThrowfalha no boot se a variável não existir, em vez de produzir um erro de conexão obscuro na primeira requisição.OnModuleIniteOnModuleDestroy. Conectar na subida e desconectar no encerramento evita conexões penduradas a cada reinício dostart:dev.
O módulo, marcado como global para não precisar ser importado em toda parte:
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@Global() é uma exceção deliberadaA aula 5 insistiu que módulos são fronteiras e que exports/imports devem ser
explícitos. @Global() fura essa regra de propósito, e só se justifica para
infraestrutura transversal — conexão de banco, configuração, log. Use com
parcimônia: um projeto em que tudo é global perdeu as fronteiras que o módulo
existia para criar.
Parte 5 — Erros do Prisma: uma primeira tradução
Um @unique violado ou um registro que não existe não devem virar 500. O
service reconhece o código do Prisma e lança a exceção do NestJS correspondente
— quem já escreve 409 e 404 como número é o filtro de exceções da aula 6.
| Código | Significado | Status |
|---|---|---|
P2002 | Violação de restrição única (ex.: ISBN repetido) | 409 Conflict |
P2025 | Registro não encontrado para a operação | 404 Not Found |
private traduzirErro(erro: unknown, isbn?: string, id?: number): never {
if (erro instanceof Prisma.PrismaClientKnownRequestError) {
switch (erro.code) {
case 'P2002':
throw new ConflictException(`ISBN ${isbn ?? ''} já cadastrado`.trim());
case 'P2025':
throw new NotFoundException(`Livro ${id ?? ''} não encontrado`.trim());
}
}
throw erro;
}
Verificar unicidade em código exige duas idas ao banco (uma para conferir, outra
para gravar) e ainda assim não garante nada: entre as duas, outra requisição
pode ter gravado o mesmo ISBN. A restrição UNIQUE no banco é atômica. Verificar
antes é útil para dar uma mensagem melhor; tratar P2002 é o que garante a
integridade.
Esta tabela tem só dois códigos porque o schema desta aula tem só uma entidade,
sem chave estrangeira. A aula 9 completa o mapeamento com P2003 (chave
estrangeira), P2000 (valor longo demais) e P1001 (banco inalcançável) —
todos ligados a situações que só existem quando há relacionamento.
Erros comuns
| Erro | Sintoma | Correção |
|---|---|---|
Importar de @prisma/client | Module not found ou tipos ausentes | Importar do caminho em output |
| Esquecer o driver adapter | Erro ao construir o PrismaClient | Passar adapter: new PrismaPg(...) |
Alterar o schema e não rodar generate | Editor não reconhece o campo novo | npx prisma generate |
Rodar generate e esquecer a migration | Código compila, consulta falha: a coluna não existe | npx prisma migrate dev |
.env ausente ou sem DATABASE_URL | Falha no boot com getOrThrow | Criar o .env |
Gerar o cliente dentro de src/ e versioná-lo | Conflitos absurdos no Git | Ignorar o diretório gerado |
Laboratório 7 — Trocando o armazenamento sem quebrar o contrato
Continuação direta do laboratório 6. O objetivo aqui não é nenhuma
funcionalidade nova — é provar que a arquitetura das aulas 5 e 6 está correta.
O modelo continua sendo só Livro, exatamente como a aula 6 o deixou; ele
cresce para o acervo completo na aula 8.
Passo 1 — Preparar o banco
A aula assume um PostgreSQL local respondendo em localhost:5432. Crie a base:
createdb biblioteca
Se o comando não existir no seu PATH, use o cliente psql:
psql -U postgres -c "CREATE DATABASE biblioteca;"
Confirme o acesso antes de seguir — resolver problema de conexão agora é bem mais simples do que no meio da primeira migration:
psql -U postgres -d biblioteca -c "SELECT version();"
Passo 2 — Instalar as dependências
npm i @prisma/client@7 @prisma/adapter-pg@7 @nestjs/config dotenv
npm i -D prisma@7
| Pacote | Papel |
|---|---|
@prisma/client | Runtime do cliente gerado |
@prisma/adapter-pg | Driver adapter para PostgreSQL, obrigatório na v7 |
@nestjs/config | Leitura de variáveis de ambiente na aplicação |
dotenv | Leitura do .env pela CLI do Prisma e pelo seed |
prisma | CLI (migrations, generate, studio) |
Fixar @7 é importante: sem essa indicação, o npm instala a versão principal
mais recente. Se você já executou os comandos sem a versão e instalou o Prisma
8, execute novamente os dois comandos acima para alinhar os pacotes à versão 7
usada nesta aula.
O seed.ts, que só aparece na aula 9, será executado por ts-node, que o Nest
CLI já instalou junto com o projeto.
tsx para rodar o seedMuitos tutoriais usam tsx no lugar do ts-node. Ele funciona, mas exige um
ajuste: o cliente gerado usa a extensão .js nos próprios imports (convenção
ESM) e o tsx não consegue resolvê-la para os arquivos .ts em disco,
produzindo Cannot find module './internal/class.js'. A correção é declarar
importFileExtension = "ts" no bloco generator — o que, por sua vez, quebra o
nest build. Usar ts-node evita o conflito inteiro.
Passo 3 — Inicializar o Prisma
npx prisma init --datasource-provider postgresql
Essa opção pertence à CLI do Prisma 7. O erro
No flag registered for --datasource-provider indica que o npx encontrou a
CLI do Prisma 8; volte ao Passo 2 e confirme a versão com npx prisma -v antes
de repetir a inicialização.
No Prisma 7.10 ou mais recente, isso cria prisma/schema.prisma, .env e
prisma7.config.ts. O nome específico permite que a configuração da versão 7
coexista com um eventual prisma.config.ts do Prisma 8. Ajuste a URL de conexão
com o usuário e a senha do seu PostgreSQL:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/biblioteca?schema=public"
Confirme que .env está no .gitignore — o Nest CLI já o coloca lá, mas vale
verificar. Senha de banco não entra no repositório, mesmo sendo postgres numa
máquina local: o hábito é o que importa.
Substitua o conteúdo da configuração gerada na raiz do projeto para também declarar o comando de seed usado posteriormente:
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'ts-node prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
});
Passo 4 — Escrever o primeiro schema
O modelo é só o livro, com os mesmos campos que a aula 6 já expunha. Isso é proposital: mantém o contrato intacto e permite verificar a arquitetura antes de acrescentar complexidade.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
model Livro {
id Int @id @default(autoincrement())
titulo String @db.VarChar(200)
autor String @db.VarChar(120)
isbn String @unique @db.VarChar(20)
ano Int
criadoEm DateTime @default(now())
atualizadoEm DateTime @updatedAt
}
Acrescente o diretório gerado ao .gitignore:
/src/generated
Passo 5 — Primeira migration
npx prisma migrate dev --name criar_livro
npx prisma generate
Abra o arquivo em prisma/migrations/<timestamp>_criar_livro/migration.sql e
leia o SQL gerado. Localize:
- o
CREATE TABLE; - o índice único criado por
@unique; - o tipo escolhido para
ide o mecanismo de autoincremento.
Ler esse SQL é o que impede o ORM de virar caixa-preta.
Passo 6 — Criar o PrismaService
npx nest g module prisma
npx nest g service prisma --no-spec
O npx usa a versão local de @nestjs/cli instalada no projeto e não exige
que o comando nest esteja instalado globalmente.
Implemente PrismaService e PrismaModule conforme a Parte 4, e registre o
ConfigModule no módulo raiz:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { LivrosModule } from './livros/livros.module';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
LivrosModule,
],
})
export class AppModule {}
Passo 7 — Reescrever o service sobre o Prisma
Substitua LivrosService inteiro pela versão abaixo — o mesmo CRUD da aula 6,
agora sobre o Prisma Client em vez do array em memória:
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CriarLivroDto } from './dto/criar-livro.dto';
import { AtualizarLivroDto } from './dto/atualizar-livro.dto';
import { ConsultarLivrosDto } from './dto/consultar-livros.dto';
@Injectable()
export class LivrosService {
constructor(private readonly prisma: PrismaService) {}
async listar(consulta: ConsultarLivrosDto) {
const { autor, pagina, tamanho } = consulta;
const where: Prisma.LivroWhereInput = autor
? { autor: { contains: autor, mode: 'insensitive' } }
: {};
// Uma transação de leitura: os dois resultados enxergam o mesmo estado.
const [dados, total] = await this.prisma.$transaction([
this.prisma.livro.findMany({
where,
orderBy: { titulo: 'asc' },
skip: (pagina - 1) * tamanho,
take: tamanho,
}),
this.prisma.livro.count({ where }),
]);
return {
dados,
pagina,
tamanho,
total,
totalDePaginas: Math.ceil(total / tamanho) || 1,
};
}
async buscarPorId(id: number) {
const livro = await this.prisma.livro.findUnique({ where: { id } });
if (!livro) {
throw new NotFoundException(`Livro ${id} não encontrado`);
}
return livro;
}
async criar(dto: CriarLivroDto) {
try {
return await this.prisma.livro.create({ data: dto });
} catch (erro) {
this.traduzirErro(erro, dto.isbn);
}
}
async atualizar(id: number, dto: AtualizarLivroDto) {
try {
return await this.prisma.livro.update({ where: { id }, data: dto });
} catch (erro) {
this.traduzirErro(erro, dto.isbn, id);
}
}
async remover(id: number): Promise<void> {
try {
await this.prisma.livro.delete({ where: { id } });
} catch (erro) {
this.traduzirErro(erro, undefined, id);
}
}
private traduzirErro(erro: unknown, isbn?: string, id?: number): never {
if (erro instanceof Prisma.PrismaClientKnownRequestError) {
switch (erro.code) {
case 'P2002':
throw new ConflictException(`ISBN ${isbn ?? ''} já cadastrado`.trim());
case 'P2025':
throw new NotFoundException(`Livro ${id ?? ''} não encontrado`.trim());
}
}
throw erro;
}
}
Compare com a versão da aula 6 e repare no que mudou: os métodos viraram
async e devolvem Promise; LivrosStore desapareceu — o Prisma Client ocupa
o lugar dele; a verificação de existência antes de atualizar e remover sumiu,
porque o próprio banco a faz e o Prisma sinaliza com P2025; a checagem de
ISBN duplicado sumiu, porque a restrição @unique a faz e sinaliza com
P2002.
E no que não mudou: as rotas, os DTOs, os códigos de status e o formato da resposta. É esse conjunto que os clientes web e mobile enxergam.
Depois de colar o service, apague src/livros/livros-store.ts e remova-o de
providers no LivrosModule. Se algo mais reclamar da remoção, isso indica um
acoplamento que não deveria existir — investigue antes de contornar.
Antes de seguir, abra livros.controller.ts e os três DTOs e confirme que estão
exatamente como estavam no fim do laboratório 6. Essa é a verificação central da
aula: a arquitetura das aulas 5 e 6 está correta se, e somente se, esses arquivos
não mudaram.
Passo 8 — Repetir os testes da aula 6
Rode os mesmos comandos do passo 8 do laboratório 6, sem alterar nenhum:
curl -i -X POST http://localhost:3000/livros \
-H 'Content-Type: application/json' \
-d '{"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012348","ano":1938}'
curl -i "http://localhost:3000/livros?autor=graciliano"
curl -i http://localhost:3000/livros/9999
curl -i http://localhost:3000/livros/abc
Os status devem ser idênticos aos da aula 6 — 201, 200, 404, 400. Rode o
POST duas vezes e confirme que o segundo devolve 409, agora vindo de P2002
em vez de uma verificação em memória.
E a diferença que justifica a aula: reinicie a aplicação e repita o GET. Os
dados continuam lá.
Atividades propostas
Extensões curtas sobre o que você acabou de escrever, para fixar o ciclo de migration antes de a aula 8 acrescentar relacionamentos:
- Acrescente ao modelo
Livroum campo escalar novo (por exemplo,edicao Int?), gere uma segunda migration nomeada e leia oALTER TABLEproduzido — compare com oCREATE TABLEda primeira migration. - Rode
npx prisma studioe confirme visualmente as linhas criadas pelos seus testes do passo 8. - No seu projeto, repita os passos 1 a 8 com o recurso principal do seu domínio: um schema de uma entidade só, PrismaService, service reescrito e os mesmos testes de contrato que você já tinha antes do banco entrar em cena.
Critérios de conclusão
- a aplicação sobe conectada ao PostgreSQL, sem erro no boot;
- os cinco endpoints devolvem os mesmos status da aula 6;
- os dados sobrevivem ao reinício da aplicação;
- ISBN repetido devolve
409, vindo deP2002; -
livros.controller.tse os três DTOs não mudaram nesta aula; -
LivrosStorefoi removido do projeto; - o seu projeto tem os mesmos elementos: schema mínimo,
PrismaService, service reescrito, contrato preservado.
Fechamento
O armazenamento foi inteiramente substituído sem que uma linha do controller ou dos DTOs mudasse — esse é o resultado que importa desta aula, mais do que o banco em si. Esse desacoplamento é o que torna viável o resto do semestre: os clientes web (Módulo 3) e mobile (Módulo 4) serão escritos contra o contrato publicado na aula 6, e vão assumir que ele é estável.
O modelo de dados desta aula, porém, ainda não representa o domínio de verdade — é um só campo de texto fazendo as vezes de autor, sem categorias, sem exemplares, sem empréstimos. A aula 8 substitui esse modelo pelo acervo completo, com os relacionamentos que uma biblioteca de verdade precisa.
Exercícios (Checkpoints)
-
Explique o que um ORM resolve e cite duas situações concretas em que escrever SQL direto (
$queryRaw) seria a escolha melhor. -
Descreva, em termos do critério da Parte 2, o que significa a infraestrutura "vazar para o contrato" — e dê um exemplo de mudança no
PrismaServiceque não deveria exigir tocar no controller. -
Descreva as três saídas de
prisma migrate deve explique por que a pastaprisma/migrationsentra no Git enquantosrc/generatedfica de fora. -
Diferencie
migrate devdemigrate deploye descreva um cenário concreto em que usar o primeiro no lugar do segundo causaria perda de dados. -
Explique por que
@Global()noPrismaModuleé uma exceção à regra de módulos da aula 5, e descreva um sintoma que apareceria se ele não fosse global. -
Justifique por que verificar a unicidade do ISBN em código, antes de gravar, não substitui a restrição
@uniqueno banco. Descreva a sequência de eventos que produz um ISBN duplicado apesar da verificação.
Referências
Principais
- Prisma — Documentação — visão geral do ORM
- Prisma — Prisma Schema — modelos, campos e atributos
- Prisma — Prisma Migrate — migrations em desenvolvimento e em produção
- NestJS — Prisma — integração recomendada
Aprofundamento
- Prisma — Guia de atualização para a v7 — as mudanças que invalidam material antigo
- Prisma — Generators — o gerador
prisma-cliente suas opções - Prisma — Raw queries — quando a abstração não basta