Pular para o conteúdo principal

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 depoisOnde
Modelar relacionamentos obrigatórios e opcionaisAula 8 — Modelagem de relacionamentos
Consultas, transações e seedAula 9 — Consultas, transações e seed
Autenticação, guards e proteção de rotasAula 10 — Autenticação e autorização
Consumo desta API pelo cliente webMódulo 3 — Next.js
Consumo da mesma API pelo cliente mobileMó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 dev de migrate deploy, migrate reset e db 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​

FerramentaVersãoObservação
PostgreSQL16 ou superior, em localhost:5432instalação local; a aula assume que o serviço já está no ar
Prisma ORM7.xprisma (CLI) e @prisma/client
Adaptador@prisma/adapter-pgobrigatório na versão 7
Node.js22 LTS ou superiorrequisito do Prisma 7 e do NestJS 11
A versão 7 do Prisma mudou bastante

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:

  1. O gerador passou a ser prisma-client (e não prisma-client-js), com output obrigatório: o cliente não é mais gerado dentro de node_modules, e a importação deixa de ser @prisma/client.
  2. Criar o PrismaClient exige um driver adapter — para PostgreSQL, @prisma/adapter-pg.
  3. O arquivo .env não é lido automaticamente; a configuração passou para prisma.config.ts.
  4. migrate dev não roda mais generate nem 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 resolveO que ele custa
Tipagem de ponta a ponta entre banco e códigoUma camada a mais para aprender e depurar
Migrations versionadas junto com o códigoConsultas muito específicas ficam difíceis de expressar
Proteção contra injeção de SQL por parametrizaçãoO SQL gerado nem sempre é o que você escreveria
Portabilidade parcial entre bancosRisco real de escrever consultas ineficientes sem perceber
ORM não dispensa saber SQL

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.

Quatro camadas empilhadas — controller, service, PrismaService e PostgreSQL — com a indicação, para cada uma, do vocabulário que ela fala e do que ela não conhece
Trocar o armazenamento deve alterar apenas o service e a camada Prisma — se o controller precisar mudar, a infraestrutura vazou para o contrato.

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ó:

Fluxo que parte de prisma/schema.prisma e chega ao comando prisma migrate dev, do qual saem por setas cheias duas saídas — o SQL versionado em prisma/migrations e as tabelas aplicadas no banco local — e, por seta tracejada rotulada como passo separado, uma terceira saída: o Prisma Client regenerado, que exige rodar prisma generate
As duas primeiras saídas são automáticas; regenerar o cliente virou comando à parte na versão 7, e é justamente o passo que se esquece.
npx prisma migrate dev --name criar_livro
npx prisma generate
Na versão 7, generate é um passo separado

Até 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.

ComandoOnde usarO que faz
migrate devSó em desenvolvimentoCompara, gera a migration e aplica; pode propor recriar o banco
migrate deployAmbiente compartilhado e produçãoApenas aplica as pendentes; nunca recria
migrate resetDesenvolvimentoApaga tudo e reaplica do zero
db pushProtótipo descartávelSincroniza sem gerar migration — não use no projeto
migrate dev pode apagar seus dados

Se 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.

src/prisma/prisma.service.ts
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 em output, e é de lá que ele vem.
  • ConfigService em vez de process.env direto. getOrThrow falha no boot se a variável não existir, em vez de produzir um erro de conexão obscuro na primeira requisição.
  • OnModuleInit e OnModuleDestroy. Conectar na subida e desconectar no encerramento evita conexões penduradas a cada reinício do start:dev.

O módulo, marcado como global para não precisar ser importado em toda parte:

src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@Global() é uma exceção deliberada

A 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ódigoSignificadoStatus
P2002Violação de restrição única (ex.: ISBN repetido)409 Conflict
P2025Registro não encontrado para a operação404 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;
}
Deixar a restrição no banco não é preguiça

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​

ErroSintomaCorreção
Importar de @prisma/clientModule not found ou tipos ausentesImportar do caminho em output
Esquecer o driver adapterErro ao construir o PrismaClientPassar adapter: new PrismaPg(...)
Alterar o schema e não rodar generateEditor não reconhece o campo novonpx prisma generate
Rodar generate e esquecer a migrationCódigo compila, consulta falha: a coluna não existenpx prisma migrate dev
.env ausente ou sem DATABASE_URLFalha no boot com getOrThrowCriar o .env
Gerar o cliente dentro de src/ e versioná-loConflitos absurdos no GitIgnorar 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
PacotePapel
@prisma/clientRuntime do cliente gerado
@prisma/adapter-pgDriver adapter para PostgreSQL, obrigatório na v7
@nestjs/configLeitura de variáveis de ambiente na aplicação
dotenvLeitura do .env pela CLI do Prisma e pelo seed
prismaCLI (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.

Se você preferir tsx para rodar o seed

Muitos 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:

.env
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:

prisma7.config.ts
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.

prisma/schema.prisma
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:

.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:

  1. o CREATE TABLE;
  2. o índice único criado por @unique;
  3. o tipo escolhido para id e 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:

src/app.module.ts
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:

src/livros/livros.service.ts
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.

Confira o que você NÃO precisou tocar

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:

  1. Acrescente ao modelo Livro um campo escalar novo (por exemplo, edicao Int?), gere uma segunda migration nomeada e leia o ALTER TABLE produzido — compare com o CREATE TABLE da primeira migration.
  2. Rode npx prisma studio e confirme visualmente as linhas criadas pelos seus testes do passo 8.
  3. 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 de P2002;
  • livros.controller.ts e os três DTOs não mudaram nesta aula;
  • LivrosStore foi 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)​

  1. Explique o que um ORM resolve e cite duas situações concretas em que escrever SQL direto ($queryRaw) seria a escolha melhor.

  2. 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 PrismaService que não deveria exigir tocar no controller.

  3. Descreva as três saídas de prisma migrate dev e explique por que a pasta prisma/migrations entra no Git enquanto src/generated fica de fora.

  4. Diferencie migrate dev de migrate deploy e descreva um cenário concreto em que usar o primeiro no lugar do segundo causaria perda de dados.

  5. Explique por que @Global() no PrismaModule é uma exceção à regra de módulos da aula 5, e descreva um sintoma que apareceria se ele não fosse global.

  6. Justifique por que verificar a unicidade do ISBN em código, antes de gravar, não substitui a restrição @unique no banco. Descreva a sequência de eventos que produz um ISBN duplicado apesar da verificação.


Referências​

Principais​

Aprofundamento​