API NestJS: CRUD de tarefas com Prisma e PostgreSQL
Implemente e teste o CRUD HTTP de uma agenda com NestJS 11, Prisma 7 e PostgreSQL 18, incluindo DTO, migration, erros e smoke test real.
Você vai construir a Agenda Clara, uma API REST que grava tarefas no PostgreSQL e responde corretamente quando o JSON é inválido, o id não existe ou o mesmo compromisso é cadastrado duas vezes. Migration, typecheck, build e um smoke test com oito verificações foram executados de verdade.
O projeto completo está em blog/examples/api-nestjs/. Ele usa NestJS 11.2.1,
Nest CLI 11.0.24, TypeScript 5.9.3, Prisma 7.9.1 com @prisma/adapter-pg e a
imagem postgres:18.6-alpine. Se essas peças ainda parecem nomes isolados, o
guia de NestJS mostra primeiro o mapa do framework; aqui a
gente monta o produto inteiro.
A requisição atravessa uma esteira com postos definidos
Imagine uma central de encomendas. O pacote chega com uma etiqueta, passa por uma triagem, vai ao balcão responsável, recebe a decisão operacional e só então entra no estoque. Cada posto sabe fazer uma coisa e entrega o resultado ao seguinte.
Na API, a requisição HTTP é o pacote; o DTO descreve a etiqueta permitida; o
ValidationPipe faz a triagem; o controller recebe a rota; o service decide a
regra; o PrismaService conversa com o banco; e PostgreSQL guarda a versão
durável. Na volta, o resultado percorre o caminho inverso até virar JSON.
A analogia termina aí. Não existem caixas físicas viajando pelo Nest. O framework chama funções, resolve objetos no contêiner de dependências e usa o adapter HTTP do Express. O modelo mental serve para localizar responsabilidades, não para substituir o fluxo técnico:
cliente HTTP
↓
Express → ValidationPipe → TasksController → TasksService
↓
PrismaService → adapter-pg → PostgreSQL
↓
objeto ou exceção HTTP na respostaEssa separação responde uma pergunta importante: “por que não colocar Prisma direto no controller?”. Porque o controller deve traduzir HTTP; a regra de negócio precisa continuar testável e reutilizável sem depender da rota. A lição de módulos, controllers e providers aprofunda o trio.
Prepare um projeto reproduzível, sem instalação global
Use Node 24.x. O exemplo traz versões exatas e package-lock.json, por isso
npm ci reproduz o conjunto testado:
cd blog/examples/api-nestjs
cp .env.example .env
npm ci91 packages are looking for funding 3 high severity vulnerabilities
Não execute npm audit fix --force no impulso. A seção de auditoria mostra a
origem e a troca incompatível que o npm propõe. Primeiro vamos provar o
comportamento pedido.
As dependências centrais do package.json estão fixadas assim:
{
"engines": { "node": "24.x" },
"dependencies": {
"@nestjs/common": "11.2.1",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.2.1",
"@nestjs/mapped-types": "2.1.1",
"@nestjs/platform-express": "11.2.1",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"class-transformer": "0.5.1",
"class-validator": "0.15.1"
},
"devDependencies": {
"@nestjs/cli": "11.0.24",
"prisma": "7.9.1",
"typescript": "5.9.3"
}
}O CLI fica local. npx nest --version usa a cópia registrada no projeto, sem
depender do que outra aplicação instalou globalmente:
node --version
npm --version
npx nest --version
npx prisma --versionSistema operacional e arquitetura variam; as versões acima são as partes que precisam bater para comparar resultados.
Suba PostgreSQL 18.6 no destino correto
O .env.example usa credenciais conhecidas somente para o laboratório. O
arquivo .env está no .gitignore; em produção, substitua essa cópia por
segredos entregues pelo ambiente ou por um gerenciador apropriado.
POSTGRES_USER=agenda
POSTGRES_PASSWORD=troque-esta-senha-local
POSTGRES_DB=agenda
POSTGRES_PORT=55442
DATABASE_URL=postgresql://agenda:troque-esta-senha-local@127.0.0.1:55442/agenda?schema=public
PORT=3100O Compose publica o banco apenas em 127.0.0.1 e usa um volume nomeado. Para
PostgreSQL 18, o destino é /var/lib/postgresql:
services:
postgres:
image: postgres:18.6-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:?defina POSTGRES_USER no .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?defina POSTGRES_PASSWORD no .env}
POSTGRES_DB: ${POSTGRES_DB:?defina POSTGRES_DB no .env}
ports:
- "127.0.0.1:${POSTGRES_PORT:-55442}:5432"
volumes:
- agenda_pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
agenda_pg_data:Na linha 18, a imagem oficial mudou PGDATA para um diretório específico da
versão e passou a declarar o volume pai. Copiar o mount antigo
/var/lib/postgresql/data não é uma variação inocente: o container 18 pode
abortar para proteger a organização dos dados.
Inicie e espere o healthcheck:
docker compose up -d --wait
docker compose exec -T postgres \
psql -U agenda -d agenda -tAc 'SHOW server_version;'No teste, o mount inspecionado foi volume /var/lib/postgresql e a imagem
resolveu para o digest
sha256:d3e1620b530c944afa6e887d22eb899824da68e19c52024bf98f5220c88a65b2.
Digest prova o artefato usado; a tag continua sendo a interface legível do
Compose.
Descreva a tabela e gere um Client compatível com Nest
Prisma 7 separa a configuração da CLI do schema. prisma.config.ts carrega a
URL do ambiente e aponta para o histórico de migrations:
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: env('DATABASE_URL') },
});O schema modela uma tarefa e duas regras do banco. O índice ajuda a leitura por status e horário; a constraint única impede título e início repetidos, mesmo se duas requisições chegarem quase juntas.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
enum StatusTarefa {
PENDENTE
CONCLUIDA
}
model Tarefa {
id Int @id @default(autoincrement())
titulo String @db.VarChar(100)
descricao String? @db.VarChar(500)
inicio DateTime @db.Timestamptz(3)
status StatusTarefa @default(PENDENTE)
criadaEm DateTime @default(now()) @map("criada_em") @db.Timestamptz(3)
atualizadaEm DateTime @updatedAt @map("atualizada_em") @db.Timestamptz(3)
@@unique([titulo, inicio], map: "tarefa_titulo_inicio_unico")
@@index([status, inicio], map: "tarefa_status_inicio_idx")
@@map("tarefas")
}O moduleFormat = "cjs" não é nostalgia. O Nest CLI compila este projeto como
CommonJS e a receita oficial do Nest pede que o Client gerado use o mesmo
formato. Sem alinhar os dois lados, a aplicação pode compilar um import que o
runtime não consegue carregar.
Migration é histórico executável, não sincronização mágica
Durante o desenvolvimento do modelo, o comando que cria uma migration é:
npx prisma migrate dev --name initEle foi executado sobre um banco vazio e gerou
prisma/migrations/20260823025213_init/migration.sql. O trecho decisivo é SQL
normal, revisável:
CREATE TYPE "StatusTarefa" AS ENUM ('PENDENTE', 'CONCLUIDA');
CREATE TABLE "tarefas" (
"id" SERIAL NOT NULL,
"titulo" VARCHAR(100) NOT NULL,
"descricao" VARCHAR(500),
"inicio" TIMESTAMPTZ(3) NOT NULL,
"status" "StatusTarefa" NOT NULL DEFAULT 'PENDENTE',
"criada_em" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"atualizada_em" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "tarefas_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "tarefa_titulo_inicio_unico"
ON "tarefas"("titulo", "inicio");Como o exemplo já inclui o arquivo, você aplica o histórico com:
npm run prisma:migrateApplying migration 20260823025213_init
The following migration(s) have been applied: migrations/ └─ 20260823025213_init/ └─ migration.sql
All migrations have been successfully applied.
migrate dev altera o ciclo de desenvolvimento e usa mecanismos próprios para
detectar mudanças; migrate deploy apenas aplica arquivos pendentes. Em
produção, o segundo pertence ao pipeline. O guia de Prisma
mostra por que db push não substitui um histórico revisado.
Falhe cedo quando o ambiente estiver incompleto
ConfigModule lê .env, mas “ler” não significa “validar”. A função abaixo
recusa URL ausente, protocolo errado e porta impossível antes de aceitar
requisições:
export function validarAmbiente(
configuracao: Record<string, unknown>,
) {
const databaseUrl = configuracao.DATABASE_URL;
if (typeof databaseUrl !== 'string' || databaseUrl.trim() === '') {
throw new Error('DATABASE_URL é obrigatória. Copie .env.example para .env.');
}
const url = new URL(databaseUrl);
if (!['postgresql:', 'postgres:'].includes(url.protocol)) {
throw new Error('DATABASE_URL precisa usar o protocolo postgresql://.');
}
const port = Number(configuracao.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT precisa ser um número inteiro entre 1 e 65535.');
}
return { ...configuracao, DATABASE_URL: databaseUrl, PORT: port };
}Sem DATABASE_URL, o processo realmente encerrou com código 1:
env -u DATABASE_URL -u PORT node dist/main.jsEsse é um erro saudável: a API não fica “de pé” fingindo que consegue persistir. Nunca coloque o valor real da URL na mensagem ou em um commit.
Injete um PrismaService em vez de abrir conexões espalhadas
Injeção de dependência, ou DI, significa que a classe declara do que precisa e o contêiner do Nest fornece a instância. Pense numa empresa que entrega a ferramenta oficial a cada setor: o setor não compra um equipamento escondido, e a empresa controla montagem e encerramento. No código, o constructor é o pedido e o módulo é o cadastro que permite atendê-lo.
PrismaService cria o adapter obrigatório, estende o Client gerado e fecha o
pool quando a aplicação termina:
import { Injectable, OnModuleDestroy } 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 OnModuleDestroy {
constructor(configuracao: ConfigService) {
const connectionString =
configuracao.getOrThrow<string>('DATABASE_URL');
const adapter = new PrismaPg({ connectionString });
super({ adapter, log: ['warn'] });
}
async onModuleDestroy() {
await this.$disconnect();
}
}O módulo registra e exporta esse provider. Exportar é abrir uma porta explícita
para outros módulos do Nest; não tem relação com o export de arquivo do
TypeScript.
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}TasksModule importa PrismaModule, então TasksService pode declarar
constructor(private readonly prisma: PrismaService). O service não cria
new PrismaService() e não precisa conhecer senha, host ou pool. A lição de
Prisma com NestJS isola essa integração passo a
passo.
DTO e ValidationPipe protegem a fronteira HTTP
TypeScript não valida JSON recebido da internet porque seus tipos desaparecem
na execução. Um DTO, Data Transfer Object, é uma classe que descreve a
entrada; os decorators deixam metadados que ValidationPipe consegue verificar
em runtime.
export class CreateTaskDto {
@IsString()
@MinLength(3)
@MaxLength(100)
titulo!: string;
@IsOptional()
@IsString()
@MaxLength(500)
descricao?: string;
@IsISO8601({ strict: true })
inicio!: string;
@IsOptional()
@IsEnum(StatusTarefa)
status?: StatusTarefa;
}Atualização aceita parte dos campos, e o filtro aceita apenas o enum conhecido:
export class UpdateTaskDto extends PartialType(CreateTaskDto) {}
export class ListTasksDto {
@IsOptional()
@IsEnum(StatusTarefa)
status?: StatusTarefa;
}O pipe global atende todos os controllers. whitelist identifica propriedades
decoradas; forbidNonWhitelisted devolve 400 em vez de ignorar um campo
surpresa; transform cria instâncias dos DTOs e converte parâmetros suportados.
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.enableShutdownHooks();Para completar o bootstrap local, a aplicação escuta apenas em 127.0.0.1 e
remove o cabeçalho que anunciaria Express:
app.getHttpAdapter().getInstance().disable('x-powered-by');
await app.listen(port, '127.0.0.1');Essas decisões reduzem exposição acidental; não transformam o laboratório em produção. A lição de DTO e ValidationPipe explica transformação, whitelist e regras que ainda pertencem ao service.
Controller traduz HTTP; service decide e persiste
O controller define método, caminho, origem dos dados e status da resposta. Ele não sabe qual código Prisma representa conflito:
@Controller('tarefas')
export class TasksController {
constructor(private readonly tarefas: TasksService) {}
@Post()
create(@Body() dto: CreateTaskDto) {
return this.tarefas.create(dto);
}
@Get()
findAll(@Query() filtros: ListTasksDto) {
return this.tarefas.findAll(filtros.status);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.tarefas.findOne(id);
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateTaskDto) {
return this.tarefas.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number) {
return this.tarefas.remove(id);
}
}ParseIntPipe impede que abc chegue como id ao banco. No service, texto é
normalizado na borda da regra e a data ISO vira Date:
@Injectable()
export class TasksService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateTaskDto) {
try {
return await this.prisma.tarefa.create({
data: {
titulo: dto.titulo.trim(),
descricao: dto.descricao?.trim(),
inicio: new Date(dto.inicio),
status: dto.status,
},
});
} catch (erro) {
this.tratarErroPrisma(erro);
}
}
findAll(status?: StatusTarefa) {
return this.prisma.tarefa.findMany({
where: status ? { status } : undefined,
orderBy: [{ inicio: 'asc' }, { id: 'asc' }],
});
}
}Consulta, atualização e remoção compartilham a regra de existência:
async findOne(id: number) {
const tarefa = await this.prisma.tarefa.findUnique({ where: { id } });
if (!tarefa) {
throw new NotFoundException(`Tarefa ${id} não encontrada.`);
}
return tarefa;
}
async update(id: number, dto: UpdateTaskDto) {
await this.findOne(id);
return this.prisma.tarefa.update({
where: { id },
data: {
...dto,
titulo: dto.titulo?.trim(),
descricao: dto.descricao?.trim(),
inicio: dto.inicio ? new Date(dto.inicio) : undefined,
},
});
}
async remove(id: number): Promise<void> {
await this.findOne(id);
await this.prisma.tarefa.delete({ where: { id } });
}O projeto completo mantém try/catch também em update e delete para cobrir uma
remoção concorrente entre a consulta e a escrita. O recorte acima deixa o fluxo
principal visível.
Traduza o banco para HTTP sem vazar detalhes internos
PostgreSQL é quem garante a unicidade. Prisma representa a violação com P2002;
o service traduz isso para ConflictException, que vira HTTP 409. P2025
representa o registro ausente durante uma operação:
private tratarErroPrisma(erro: unknown): never {
if (erro instanceof Prisma.PrismaClientKnownRequestError) {
if (erro.code === 'P2002') {
throw new ConflictException(
'Já existe uma tarefa com este título neste horário.',
);
}
if (erro.code === 'P2025') {
throw new NotFoundException('Tarefa não encontrada.');
}
}
throw erro;
}O cliente recebe uma decisão de domínio, não nome de tabela, stack trace ou SQL. Isso não quer dizer que todo erro de banco deve virar 409: conexão perdida, permissão e falha inesperada continuam sendo erros do servidor.
Registre as peças no grafo de módulos
TasksModule reúne a funcionalidade. Ele importa acesso ao banco, registra o
service e expõe o controller:
@Module({
imports: [PrismaModule],
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}O módulo raiz configura o ambiente uma vez e importa tarefas:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
validate: validarAmbiente,
}),
TasksModule,
],
controllers: [AppController],
})
export class AppModule {}Aqui aparece a DI de verdade. O Nest lê os metadados dos módulos, cria
ConfigService, cria PrismaService, entrega esse provider a TasksService e
entrega o service ao controller. Se PrismaModule não exportasse o provider ou
TasksModule não o importasse, o bootstrap falharia antes da primeira rota.
Execute o CRUD e leia cada resposta
Gere o Client, compile e inicie:
npm run build
npm startO Nest mapeou seis rotas: saúde, criar/listar e consultar/atualizar/remover por id. Confira a primeira:
curl -i http://127.0.0.1:3100/api/saude{“status”:“ok”,“servico”:“agenda-clara-api”}
Agora envie um corpo com título curto, data que não é ISO e campo desconhecido:
curl -i -X POST http://127.0.0.1:3100/api/tarefas \
-H 'content-type: application/json' \
--data '{"titulo":"x","inicio":"amanha","invasor":true}'{“message”:[“property invasor should not exist”,“titulo must be longer than or equal to 3 characters”,“inicio must be a valid ISO 8601 date string”],“error”:“Bad Request”,“statusCode”:400}
O 400 nasceu no pipe; TasksService nem foi chamado. Envie uma tarefa válida:
curl -i -X POST http://127.0.0.1:3100/api/tarefas \
-H 'content-type: application/json' \
--data '{
"titulo":"Planejar retrospectiva",
"descricao":"Separar aprendizados do ciclo",
"inicio":"2026-09-18T13:30:00.000Z"
}'{“id”:4,“titulo”:“Planejar retrospectiva”,“descricao”:“Separar aprendizados do ciclo”,“inicio”:“2026-09-18T13:30:00.000Z”,“status”:“PENDENTE”,“criadaEm”:“2026-08-23T03:00:19.226Z”,“atualizadaEm”:“2026-08-23T03:00:19.226Z”}
O banco preencheu id, status e datas de auditoria. Repetir título e início aciona a constraint, e o service devolve o significado HTTP:
curl -i -X POST http://127.0.0.1:3100/api/tarefas \
-H 'content-type: application/json' \
--data '{
"titulo":"Planejar retrospectiva",
"inicio":"2026-09-18T13:30:00.000Z"
}'{“message”:“Já existe uma tarefa com este título neste horário.”,“error”:“Conflict”,“statusCode”:409}
Um id que não existe percorre controller e service, mas para antes da escrita:
curl -i http://127.0.0.1:3100/api/tarefas/999999{“message”:“Tarefa 999999 não encontrada.”,“error”:“Not Found”,“statusCode”:404}
Filtre a agenda e depois conclua a tarefa:
curl 'http://127.0.0.1:3100/api/tarefas?status=PENDENTE'
curl -i -X PATCH http://127.0.0.1:3100/api/tarefas/4 \
-H 'content-type: application/json' \
--data '{"status":"CONCLUIDA"}'{“id”:4,“titulo”:“Planejar retrospectiva”,“descricao”:“Separar aprendizados do ciclo”,“inicio”:“2026-09-18T13:30:00.000Z”,“status”:“CONCLUIDA”,“criadaEm”:“2026-08-23T03:00:19.226Z”,“atualizadaEm”:“2026-08-23T03:00:31.965Z”}
Por fim, DELETE devolve 204 e nenhum corpo:
curl -i -X DELETE http://127.0.0.1:3100/api/tarefas/4Prove que a linha chegou ao PostgreSQL
Antes da remoção, a mesma tarefa apareceu diretamente no psql:
docker compose exec -T postgres \
psql -U agenda -d agenda \
-c 'SELECT id, titulo, status, inicio FROM tarefas ORDER BY id;'Isso fecha o circuito: não foi um array em memória nem um mock. A requisição
atravessou Nest, Prisma, adapter pg e PostgreSQL. O
guia de PostgreSQL explica constraint, índice e
persistência pelo ponto de vista do banco.
Automatize o caminho feliz e os três erros
O smoke test inicia dist/main.js numa porta separada, usa fetch e valida as
respostas contra o banco real. Este é o núcleo da função auxiliar:
async function requisitar(caminho, opcoes = {}, statusEsperado) {
const resposta = await fetch(`${baseUrl}${caminho}`, {
...opcoes,
headers: {
'content-type': 'application/json',
...opcoes.headers,
},
});
if (resposta.status !== statusEsperado) {
const corpo = await resposta.text();
throw new Error(
`${opcoes.method ?? 'GET'} ${caminho}: esperado ${statusEsperado}, recebido ${resposta.status}: ${corpo}`,
);
}
return resposta.status === 204 ? null : resposta.json();
}O roteiro automatizado é: saúde 200, entrada ruim 400, criação 201, duplicata
409, filtro 200, id ausente 404, atualização 200 e remoção 204. O finally
remove a tarefa se uma asserção intermediária falhar e encerra o servidor.
Execute a mesma sequência:
npm run typecheck
npm run build
npm testTypecheck e build terminaram sem diagnóstico. O smoke imprimiu:
O smoke não substitui toda a pirâmide de testes. Num produto, acrescente testes unitários para regras densas, e2e isolados por banco e contrato para clientes externos. Aqui ele prova o objetivo do tutorial com o mínimo de infraestrutura paralela.
Leia o audit sem aceitar uma troca cega
Rode a auditoria, mas não permita correção automática forçada:
npm audit
npm ls prisma @prisma/config deepmerge-ts --all@prisma/client@7.9.1 └── prisma@7.9.1 └── @prisma/config@7.9.1 └── deepmerge-ts@7.1.5
DeepmergeTS has stack exhaustion when merging recursive object graphs GHSA-ggr8-5vv4-36mx
fixAvailable: prisma@6.12.0 (SemVer major)
O alerta é real e não deve ser escondido. Ao mesmo tempo, a sugestão automática
voltaria do Prisma 7.9.1 solicitado para 6.12.0, mudando configuração, geração e
adapter ensinados aqui. Não aplicamos downgrade nem override sem a equipe do
Prisma declarar compatibilidade. A decisão responsável é registrar a cadeia,
limitar quem controla configuração, acompanhar o advisory e atualizar para uma
correção compatível quando publicada.
O risco não some porque o projeto é didático. Também não autoriza quebrar a arquitetura para produzir um relatório verde artificial.
O que ainda falta antes de abrir esta agenda na internet
Este laboratório já faz algumas escolhas defensivas: valida ambiente e JSON,
rejeita campo inesperado, usa constraint no banco, não retorna erro Prisma,
prende banco e API ao loopback, remove X-Powered-By, não registra credenciais e
fecha o pool no desligamento.
Ainda faltam controles que dependem do produto:
- autenticar a pessoa e autorizar quais tarefas ela pode ler ou alterar;
- armazenar usuário ou organização em cada linha e filtrar todas as consultas;
- aplicar rate limiting, tamanho de body e política explícita de CORS;
- usar TLS, rede privada, backup e segredo rotacionável no ambiente real;
- registrar eventos sem corpo sensível e observar latência e falhas;
- revisar paginação:
findManysem limite não serve para uma agenda enorme; - definir fuso horário e regras de conflito mais ricas que título e instante.
JWT foi deixado fora de propósito: autenticação completa desviaria o foco do
fluxo Nest–Prisma–PostgreSQL. Quando o CRUD estiver firme, siga
guards e autenticação no NestJS e faça
o service receber a identidade validada, não um userId confiado ao body.
Missão: impeça duas tarefas no mesmo horário
Hoje a constraint rejeita apenas a mesma combinação de titulo e inicio.
Sua missão é impedir qualquer segunda tarefa no mesmo instante, mesmo com
título diferente. Para esta regra deliberadamente simples, transforme inicio
em campo único e remova a unicidade composta.
Faça a mudança em quatro passos:
- altere o schema para criar a regra de unicidade que representa sua decisão;
- rode
npm run prisma:migrate:dev -- --name horario_unicoe revise o SQL; - tente cadastrar dois títulos diferentes com o mesmo
inicioe espere 409; - acrescente esse cenário ao smoke test.
O critério de conclusão é objetivo: banco vazio recebe as migrations com
npm run prisma:migrate, typecheck e build passam, o primeiro POST devolve 201,
o segundo devolve 409 e o teste remove seus dados. Quando isso acontecer, você
terá alterado contrato, banco, tratamento de erro e prova automatizada sem
misturar as camadas da API.
Finalize o laboratório removendo somente os recursos deste Compose:
docker compose down -vO -v apaga o volume local da Agenda Clara. Não use esse comando num projeto
com dados que precisam permanecer; em produção, migration e limpeza são
operações separadas, revisadas e recuperáveis por backup.
Perguntas frequentes
O que esta API NestJS faz?
Prisma 7 precisa de adapter para PostgreSQL?
Quando a API devolve 400, 404 e 409?
Por que usar migrate deploy neste projeto local?
O volume do PostgreSQL 18 deve montar em qual caminho?
Esta API já está segura para publicar na internet?
Por que npm audit aponta três vulnerabilidades altas?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 · npm 11.13.0 · NestJS 11.2.1 · Nest CLI 11.0.24 · TypeScript 5.9.3 · Prisma 7.9.1 · PostgreSQL 18.6 · Docker 29.5.3 · Compose 5.1.4, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- NestJS — First steps — docs.nestjs.com
- NestJS — Providers e injeção de dependência — docs.nestjs.com
- NestJS — ValidationPipe — docs.nestjs.com
- NestJS — receita oficial com Prisma — docs.nestjs.com
- Prisma ORM — driver adapters — prisma.io
- Prisma Migrate — desenvolvimento e produção — prisma.io
- Docker — imagem oficial do PostgreSQL — hub.docker.com
- PostgreSQL — release 18.6 — postgresql.org


