Aula 7: Acesso a bases de dados com Prisma e PostgreSQL
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 vai verificar, antes de acrescentar qualquer funcionalidade nova.
| O que vem depois | Onde |
|---|---|
| Autenticação, guards e proteção de rotas | Aula 8 — 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.
- Modelar um domínio com relacionamentos 1:N, N:N implícito e junção explícita no schema do Prisma.
- Gerar e aplicar migrations, e distinguir
migrate devdemigrate deploy. - Integrar o Prisma ao NestJS por meio de um provider injetável.
- Escrever consultas com filtro, ordenação, paginação,
includeeselect, e reconhecer o problema N+1. - Usar transações para operações que alteram mais de uma tabela.
- Traduzir erros do banco em códigos de status HTTP corretos.
- Popular o banco com um script de seed reproduzível.
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, e o desenho abaixo é 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.
Parte 3 — Modelando o domínio
O domínio-guia dos laboratórios é uma biblioteca. Ele foi escolhido pequeno o bastante para caber em uma aula e completo o bastante para exigir os três tipos de relacionamento que o Prisma trata de formas diferentes.
O schema completo:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
model Autor {
id Int @id @default(autoincrement())
nome String @db.VarChar(120)
nacionalidade String? @db.VarChar(60)
livros Livro[]
criadoEm DateTime @default(now())
}
model Livro {
id Int @id @default(autoincrement())
titulo String @db.VarChar(200)
isbn String @unique @db.VarChar(20)
ano Int
autorId Int
autor Autor @relation(fields: [autorId], references: [id], onDelete: Restrict)
categorias Categoria[]
exemplares Exemplar[]
criadoEm DateTime @default(now())
atualizadoEm DateTime @updatedAt
@@index([autorId])
}
model Categoria {
id Int @id @default(autoincrement())
nome String @unique @db.VarChar(60)
livros Livro[]
}
enum SituacaoExemplar {
DISPONIVEL
EMPRESTADO
EM_REPARO
BAIXADO
}
model Exemplar {
id Int @id @default(autoincrement())
tombo String @unique @db.VarChar(20)
situacao SituacaoExemplar @default(DISPONIVEL)
livroId Int
livro Livro @relation(fields: [livroId], references: [id], onDelete: Cascade)
emprestimos Emprestimo[]
@@index([livroId])
}
model Leitor {
id Int @id @default(autoincrement())
nome String @db.VarChar(120)
email String @unique @db.VarChar(180)
ativo Boolean @default(true)
emprestimos Emprestimo[]
}
model Emprestimo {
id Int @id @default(autoincrement())
exemplarId Int
exemplar Exemplar @relation(fields: [exemplarId], references: [id])
leitorId Int
leitor Leitor @relation(fields: [leitorId], references: [id])
retiradoEm DateTime @default(now())
previstoPara DateTime
devolvidoEm DateTime?
@@index([leitorId])
@@index([exemplarId])
}
As decisões que esse schema carrega
1:N — a chave estrangeira mora no lado N. Livro guarda autorId; Autor
tem a lista livros[], que não existe como coluna: é a relação vista do outro
lado. A escolha de onDelete é de negócio, não técnica:
| Estratégia | Efeito ao apagar o autor | Quando usar |
|---|---|---|
Restrict | A operação falha se houver livros | O registro dependente tem valor próprio |
Cascade | Os livros são apagados junto | O dependente não faz sentido sozinho |
SetNull | autorId vira null (exige campo opcional) | O vínculo é acessório |
Aqui, apagar um autor não pode apagar a obra dele — daí Restrict. Já apagar um
livro deve apagar seus exemplares, que não existem sem ele — daí Cascade.
N:N implícito — o Prisma cuida da tabela de junção. Livro.categorias e
Categoria.livros bastam: o Prisma cria e mantém a tabela intermediária sozinho.
Isso só funciona porque a relação não tem atributos próprios.
Junção explícita — quando a relação vira entidade. Assim que a ligação entre
Exemplar e Leitor ganhou datas próprias, ela deixou de caber numa tabela
automática e virou Emprestimo.
Pergunte se você consegue imaginar um atributo da própria relação — data, quantidade, situação, nota, quem autorizou. Se sim, ela é uma entidade. Descobrir isso depois de gravar dados custa uma migration de estrutura e de conteúdo.
Índices. Todo campo usado como filtro frequente merece índice. @unique já
cria um; @@index([autorId]) cobre a busca de livros por autor, que é uma consulta
esperada. Sem índice, o PostgreSQL varre a tabela inteira — imperceptível com 20
registros de seed, fatal com 200 mil.
Parte 4 — Migrations
Uma migration é a diferença entre o estado atual do banco e o schema desejado, gravada como SQL versionado. O comando que a produz faz três coisas:
npx prisma migrate dev --name criar_acervo
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_acervo/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 5 — 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 6 — Consultando
Filtro, ordenação e paginação
const livros = await this.prisma.livro.findMany({
where: {
ano: { gte: 1900 },
titulo: { contains: 'sertão', mode: 'insensitive' },
},
orderBy: { titulo: 'asc' },
skip: (pagina - 1) * tamanho,
take: tamanho,
});
mode: 'insensitive' resolve, no banco, o mesmo problema que na aula 6 era
resolvido com .toLowerCase() em JavaScript — e com uma diferença importante:
agora o filtro roda antes da paginação, sobre a tabela inteira. Filtrar em
memória depois de paginar dá resultados errados, e é um erro comum.
Trazendo relacionamentos: include e select
// include: o registro completo + as relações pedidas
const comAutor = await this.prisma.livro.findUnique({
where: { id },
include: { autor: true, categorias: true },
});
// select: exatamente os campos listados, e nada mais
const resumo = await this.prisma.livro.findMany({
select: {
id: true,
titulo: true,
autor: { select: { nome: true } },
_count: { select: { exemplares: true } },
},
});
include | select | |
|---|---|---|
| Traz os campos escalares do modelo | Sim, todos | Só os listados |
| Serve para relações | Sim | Sim |
| Podem ser usados juntos no mesmo nível | Não | Não |
| Quando preferir | Você quer o registro inteiro mais as relações | Você quer economizar banda ou esconder campos |
select é a resposta direta ao overfetching discutido na aula 4: a tela do
aplicativo mobile que mostra só título e autor não precisa carregar o ISBN, as
datas e a observação interna de cada livro.
O problema N+1
Este é o erro de desempenho mais comum com ORM, e ele não dá nenhum sinal em desenvolvimento:
// Errado: 1 consulta para a lista + 1 por livro = N+1 consultas
const livros = await this.prisma.livro.findMany();
for (const livro of livros) {
livro.autor = await this.prisma.autor.findUnique({ where: { id: livro.autorId } });
}
// Certo: 1 consulta
const livros = await this.prisma.livro.findMany({ include: { autor: true } });
Com 3 livros de seed, a diferença é imperceptível. Com 500, são 501 idas ao banco
para montar uma tela. A regra prática: se você escreveu uma consulta dentro de
um laço, quase certamente ela deveria ser um include.
Parte 7 — O service, reescrito
Este é o service da aula 6, com a mesma assinatura pública e o mesmo comportamento externo:
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: { nome: { 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,
include: { autor: true },
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 },
include: { autor: true, categorias: true },
});
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, include: { autor: true } });
} catch (erro) {
this.traduzirErro(erro, dto.isbn);
}
}
async atualizar(id: number, dto: AtualizarLivroDto) {
try {
return await this.prisma.livro.update({
where: { id },
data: dto,
include: { autor: true },
});
} 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());
case 'P2003':
throw new ConflictException('Autor informado não existe');
}
}
throw erro;
}
}
Compare com a versão da aula 6 e repare no que mudou:
- os métodos viraram
asynce devolvemPromise; LivrosStoredesapareceu — 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
@uniquea faz e sinaliza comP2002.
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.
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.
Parte 8 — Erros do banco viram status HTTP
| Código | Significado | Status | Mensagem ao cliente |
|---|---|---|---|
P2002 | Violação de restrição única | 409 | Qual valor já existe |
P2025 | Registro não encontrado para a operação | 404 | Qual recurso não existe |
P2003 | Violação de chave estrangeira | 409 ou 400 | Qual vínculo é inválido |
P2000 | Valor longo demais para a coluna | 400 | Qual campo excedeu |
P1001 | Não foi possível alcançar o banco | 503 | Mensagem genérica |
A mensagem original do P2002 inclui o nome da tabela e da coluna
(Unique constraint failed on the fields: (isbn)). Isso descreve a estrutura
interna do banco, que não faz parte do contrato e não deveria ser conhecida por
quem consome a API. Traduza para o vocabulário do domínio — foi o que a Parte 7
fez, e é a mesma regra do filtro de exceções da aula 6.
Parte 9 — Transações
Algumas operações só fazem sentido inteiras. Emprestar um exemplar é uma delas: grava o empréstimo e muda a situação do exemplar. Se a segunda falhar depois da primeira, o acervo fica com um exemplar emprestado que consta como disponível.
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
const PRAZO_EM_DIAS = 14;
@Injectable()
export class EmprestimosService {
constructor(private readonly prisma: PrismaService) {}
async emprestar(exemplarId: number, leitorId: number) {
return this.prisma.$transaction(async (tx) => {
const exemplar = await tx.exemplar.findUnique({ where: { id: exemplarId } });
if (!exemplar) {
throw new NotFoundException(`Exemplar ${exemplarId} não encontrado`);
}
if (exemplar.situacao !== 'DISPONIVEL') {
throw new BadRequestException(`Exemplar ${exemplarId} não está disponível`);
}
const previstoPara = new Date();
previstoPara.setDate(previstoPara.getDate() + PRAZO_EM_DIAS);
const emprestimo = await tx.emprestimo.create({
data: { exemplarId, leitorId, previstoPara },
include: { exemplar: { include: { livro: true } }, leitor: true },
});
await tx.exemplar.update({
where: { id: exemplarId },
data: { situacao: 'EMPRESTADO' },
});
return emprestimo;
});
}
}
Duas formas de transação, com usos diferentes:
| Forma | Sintaxe | Quando usar |
|---|---|---|
| Em lote | $transaction([consultaA, consultaB]) | As operações são independentes e conhecidas de antemão |
| Interativa | $transaction(async (tx) => ...) | Uma operação depende do resultado da anterior |
tx, não this.prismaUma consulta feita com this.prisma dentro do bloco roda fora da transação —
ela não enxerga as alterações pendentes e não é desfeita no rollback. O erro é
silencioso e só aparece sob concorrência. Se a variável se chama tx, use tx.
Transações interativas seguram uma conexão aberta e têm tempo limite. Nunca
coloque dentro delas chamadas a serviços externos — envio de e-mail, requisição
HTTP, upload. Faça isso depois do commit.
Parte 10 — Seed
Um banco vazio impede de testar listagem, filtro e paginação. O script de seed resolve isso de forma reproduzível — qualquer pessoa que clonar o projeto obtém o mesmo conjunto de dados.
import { PrismaPg } from '@prisma/adapter-pg';
import 'dotenv/config';
import { PrismaClient } from '../src/generated/prisma/client';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
async function main() {
// Ordem importa: apagar primeiro quem depende dos outros.
await prisma.emprestimo.deleteMany();
await prisma.exemplar.deleteMany();
await prisma.livro.deleteMany();
await prisma.categoria.deleteMany();
await prisma.autor.deleteMany();
await prisma.leitor.deleteMany();
const romance = await prisma.categoria.create({ data: { nome: 'Romance' } });
const classico = await prisma.categoria.create({ data: { nome: 'Clássico brasileiro' } });
// Escrita aninhada: autor, livro, categorias e exemplares num comando só.
await prisma.autor.create({
data: {
nome: 'Machado de Assis',
nacionalidade: 'Brasileira',
livros: {
create: [
{
titulo: 'Dom Casmurro',
isbn: '9788525406958',
ano: 1899,
categorias: { connect: [{ id: romance.id }, { id: classico.id }] },
exemplares: {
create: [{ tombo: 'DC-001' }, { tombo: 'DC-002' }, { tombo: 'DC-003' }],
},
},
{
titulo: 'Memórias Póstumas de Brás Cubas',
isbn: '9788535914849',
ano: 1881,
categorias: { connect: [{ id: classico.id }] },
exemplares: { create: [{ tombo: 'MP-001' }] },
},
],
},
},
});
await prisma.autor.create({
data: {
nome: 'Clarice Lispector',
nacionalidade: 'Brasileira',
livros: {
create: [
{
titulo: 'A Hora da Estrela',
isbn: '9788520925829',
ano: 1977,
categorias: { connect: [{ id: romance.id }] },
exemplares: { create: [{ tombo: 'HE-001' }, { tombo: 'HE-002' }] },
},
],
},
},
});
await prisma.leitor.createMany({
data: [
{ nome: 'Ana Souza', email: 'ana@exemplo.com' },
{ nome: 'Bruno Lima', email: 'bruno@exemplo.com' },
],
});
console.log('Seed concluído.');
}
main()
.catch((erro) => {
console.error(erro);
process.exit(1);
})
.finally(() => prisma.$disconnect());
O trecho mais instrutivo é a escrita aninhada: create dentro de create
grava autor, livros, vínculos de categoria e exemplares em uma única operação, e
o Prisma resolve as chaves estrangeiras sozinho. connect liga a um registro que
já existe; create cria um novo.
Até a versão 6, migrate dev e migrate reset executavam o seed automaticamente.
Na versão 7 é preciso chamar npx prisma db seed explicitamente. O comando que
ele executa é declarado em prisma.config.ts.
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 |
| Consulta dentro de laço | Lento sob volume, imperceptível em desenvolvimento | Trocar por include |
| Filtrar em memória depois de paginar | Página com menos itens que o esperado | Filtrar no where |
Usar this.prisma dentro de $transaction | A operação não é desfeita no rollback | Usar o tx recebido |
| Repassar a mensagem do Prisma | Vaza nome de tabela e coluna | Traduzir em traduzirErro |
Listagem sem take | Uma requisição carrega a tabela inteira | Paginar sempre |
deleteMany do seed na ordem errada | Violação de chave estrangeira | Apagar primeiro quem depende |
Gerar o cliente dentro de src/ e versioná-lo | Conflitos absurdos no Git | Ignorar o diretório gerado |
Laboratório 7 — Persistência de verdade
Continuação direta do laboratório 6. O laboratório tem duas metades com propósitos diferentes:
- Passos 1 a 8: trocar o armazenamento sem mudar o contrato. Nenhuma funcionalidade nova — o objetivo é verificar a arquitetura.
- Passos 9 a 14: usar o que só um banco relacional oferece — relacionamentos, transações e consultas agregadas.
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 @prisma/adapter-pg @nestjs/config dotenv
npm i -D prisma
| 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) |
O seed.ts será executado por ts-node, que o Nest CLI já instalou junto com o
projeto — não é preciso acrescentar nada para isso.
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
Isso cria prisma/schema.prisma e um .env. 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.
Crie a configuração da CLI na raiz do projeto:
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
Nesta metade do laboratório 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
nest g module prisma
nest g service prisma --no-spec
Implemente PrismaService e PrismaModule conforme a Parte 5, 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
Substitua LivrosService pela versão da Parte 7, adaptada ao schema deste
momento — aqui autor ainda é um texto, então o filtro é
{ autor: { contains: autor, mode: 'insensitive' } }.
Depois, 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 9 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á.
Passo 9 — Ampliar o modelo
Agora o domínio cresce. Substitua o schema pelo modelo completo da Parte 3 e gere a segunda migration:
npx prisma migrate dev --name relacionamentos_do_acervo
npx prisma generate
O Prisma vai avisar que a coluna autor está sendo trocada por autorId e que
os dados existentes serão perdidos. Em desenvolvimento, aceite.
Trocar autor (texto) por autorId (referência) muda o corpo aceito no POST e
no PATCH. Pela aula 4, isso é uma mudança incompatível: um cliente já
publicado quebraria. Num sistema em produção, o caminho seria versionar a API ou
aceitar as duas formas por um período de transição.
Aqui a mudança é aceitável porque nenhum cliente foi publicado ainda — e o momento de normalizar o modelo é justamente antes disso. Registre a lição: o custo de uma decisão de modelagem cresce depois que existem clientes.
Ajuste os DTOs para o novo contrato:
// substitua o campo `autor` por:
@ApiProperty({ example: 1, description: 'Identificador do autor já cadastrado' })
@IsInt()
@Min(1)
autorId!: number;
E ajuste o filtro de listar para atravessar a relação, como na Parte 7:
{ autor: { nome: { contains: autor, mode: 'insensitive' } } }.
Passo 10 — Popular o banco
Crie prisma/seed.ts conforme a Parte 10 e execute:
npx prisma db seed
Confirme com uma listagem:
curl -s "http://localhost:3000/livros?tamanho=10" | head -40
Cada livro deve vir com o objeto autor aninhado — efeito do include.
Passo 11 — Inspecionar com o Prisma Studio
npx prisma studio
Abre uma interface em http://localhost:5555. Percorra as seis tabelas e
confirme visualmente:
- os três livros e seus
autorId; - a tabela de junção que o Prisma criou sozinho para
Livro–Categoria(procure pelo nome começando com_); - os exemplares, todos com situação
DISPONIVEL.
Encontrar a tabela de junção com os próprios olhos é o que torna concreto o que "N:N implícito" significa.
Passo 12 — Exercitar include, select e agregação
Acrescente ao LivrosService um método que devolve o acervo resumido, com a
contagem de exemplares por livro:
async resumo() {
return this.prisma.livro.findMany({
select: {
id: true,
titulo: true,
autor: { select: { nome: true } },
_count: { select: { exemplares: true } },
},
orderBy: { titulo: 'asc' },
});
}
Exponha em GET /livros/resumo — antes de @Get(':id') no controller, pelo
motivo visto na aula 5. Compare o tamanho da resposta com a de GET /livros:
essa diferença é o overfetching que a aula 4 descreveu, agora medida.
Passo 13 — Implementar o empréstimo com transação
nest g module emprestimos
nest g service emprestimos --no-spec
nest g controller emprestimos --no-spec
Implemente emprestar conforme a Parte 9 e exponha:
@Post()
@HttpCode(HttpStatus.CREATED)
emprestar(@Body() dto: CriarEmprestimoDto) {
return this.emprestimosService.emprestar(dto.exemplarId, dto.leitorId);
}
Crie o DTO com @IsInt() e @Min(1) nos dois campos. Depois teste os três
cenários:
# 1. empréstimo válido — 201, e a situação do exemplar muda
curl -i -X POST http://localhost:3000/emprestimos \
-H 'Content-Type: application/json' -d '{"exemplarId":1,"leitorId":1}'
# 2. o mesmo exemplar de novo — 400, porque não está mais disponível
curl -i -X POST http://localhost:3000/emprestimos \
-H 'Content-Type: application/json' -d '{"exemplarId":1,"leitorId":2}'
# 3. exemplar inexistente — 404
curl -i -X POST http://localhost:3000/emprestimos \
-H 'Content-Type: application/json' -d '{"exemplarId":9999,"leitorId":1}'
Confirme no Prisma Studio que o exemplar 1 está EMPRESTADO e que existe um
registro em Emprestimo.
Agora prove que a transação funciona: inverta temporariamente a ordem dentro
do bloco, colocando o update da situação antes do create do empréstimo, e
force um erro no create usando um leitorId inexistente. A operação deve falhar
com erro de chave estrangeira e o exemplar deve continuar DISPONIVEL — se
ele tiver mudado, a transação não está envolvendo as duas operações. Desfaça a
inversão em seguida.
Identifique no seu domínio uma operação que altere duas tabelas e não faça
sentido pela metade: dar baixa em estoque ao confirmar um pedido, ocupar uma vaga
ao efetivar uma matrícula, reservar um assento ao emitir um bilhete. Implemente-a
com $transaction interativa. Se você não encontrar nenhuma, o seu modelo
provavelmente ainda está raso demais — reveja os relacionamentos.
Passo 14 — Verificar a documentação
Abra http://localhost:3000/docs e confirme:
- as operações de livros continuam documentadas, agora com
autorId; - a operação de empréstimo aparece com o corpo correto;
- os exemplos dos DTOs foram atualizados.
Critérios de conclusão
Primeira metade — a troca não quebrou nada:
- 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 DTOs não mudaram nesta metade; -
LivrosStorefoi removido do projeto.
Segunda metade — o que só o banco permite:
- as seis tabelas existem, e a de junção foi criada pelo Prisma;
-
npx prisma db seedpopula o banco de forma reproduzível; - a listagem traz o autor aninhado com uma única consulta;
-
GET /livros/resumousaselecte_count; - emprestar um exemplar indisponível devolve
400; - a transação foi verificada: em caso de erro, nada é gravado pela metade;
- o seu projeto tem os mesmos elementos: relacionamentos, seed e uma operação transacional.
Fechamento
Com esta aula o serviço fica completo em suas três camadas: contrato HTTP, regra de negócio e persistência. E o resultado mais importante do laboratório não é o banco funcionando — é a primeira metade, em que o armazenamento foi inteiramente substituído sem que uma linha do controller ou dos DTOs mudasse.
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 aqui, e vão assumir que ele é estável. Uma arquitetura em que a troca de banco obriga a mexer nas rotas transforma qualquer evolução do backend em retrabalho nos dois clientes ao mesmo tempo.
Na aula 8, essa mesma base ganha autenticação e autorização — e o lugar onde elas entram, o guard, você já conhece desde a aula 5.
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. -
Sobre modelagem:
a. Justifique por que
Emprestimoé uma entidade e a relaçãoLivro–Categorianão precisa ser, e descreva o que aconteceria se um requisito novo pedisse a data em que a categoria foi atribuída.b. Escolha a estratégia de
onDeletepara cada relação do modelo da Parte 3, justificando em termos de negócio. -
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. -
Identifique o problema no trecho a seguir, explique por que ele não aparece em desenvolvimento e reescreva-o:
const livros = await prisma.livro.findMany();for (const livro of livros) {livro.autor = await prisma.autor.findUnique({ where: { id: livro.autorId } });} -
Compare
includeeselecte decida qual usar em cada caso, justificando: (a) tela do aplicativo que lista título e nome do autor; (b) tela de detalhe do livro com categorias e exemplares; (c) exportação completa do acervo para um relatório. -
Explique 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. -
Mapeie cada código de erro do Prisma (
P2002,P2025,P2003) para um status HTTP e escreva a mensagem que você devolveria ao cliente — sem revelar nome de tabela ou coluna. -
Analise o que aconteceria se, dentro de
$transaction(async (tx) => ...), uma das consultas usassethis.prismaem vez detx. Descreva o comportamento observável sob duas requisições concorrentes. -
Projete o schema do seu estudo de caso com pelo menos: um relacionamento 1:N, um N:N e uma entidade de junção com atributos próprios. Para cada relação, indique a estratégia de
onDeletee os índices que você criaria, justificando.
Referências
Principais
- Prisma — Documentação — visão geral do ORM
- Prisma — Prisma Schema — modelos, campos, atributos e relações
- Prisma — Relações — 1:1, 1:N, N:N e junção explícita
- Prisma — Prisma Migrate — migrations em desenvolvimento e em produção
- Prisma — Prisma Client — consultas, filtros,
includeeselect - 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 — Transações — em lote, interativas e níveis de isolamento
- Prisma — Referência de erros — significado de cada código
P#### - Prisma — Raw queries — quando a abstração não basta
- PostgreSQL — Índices — quando e por que criar