API Node com TypeScript, Express, Prisma e Docker
Crie uma API CRUD completa com Node 24, TypeScript 7, Express 5, Prisma 7, PostgreSQL 18, validação, migração e Docker Compose.
Neste tutorial, você vai construir uma API de livraria que cadastra, lista, busca, altera e remove livros. O projeto usa versões testadas de Node, TypeScript, Express, Prisma, PostgreSQL e Docker, com migração de banco, validação de entrada, erros HTTP e imagem de produção.
Não é uma coleção de trechos isolados. O código completo fica em
blog/examples/api-livraria, com lockfile e migração, e foi executado de ponta a
ponta. A verificação encontrou dois problemas que tutoriais desatualizados
costumam esconder: Prisma 7 exige driver adapter e PostgreSQL 18 mudou o ponto de
montagem recomendado do volume oficial. A gente vai corrigir ambos no caminho.
Você precisa reconhecer rota, método HTTP, JSON e variável de ambiente. Se essa base ainda for nova, leia o que é uma API REST, o guia de Node e depois volte. O projeto é intermediário porque junta várias peças; cada passo começa pelo problema que resolve.
O balcão, o estoque e o tradutor: o mapa do projeto
Imagine uma livraria física. Express é o balcão: recebe pedidos e devolve respostas. Zod é a ficha de conferência: rejeita pedido sem título ou preço. Prisma é o funcionário que traduz a operação para o banco. PostgreSQL é o estoque registrado. Docker é a planta que permite montar o mesmo conjunto em outra máquina.
Os nomes técnicos são camada HTTP, validação de runtime, ORM, banco relacional e containerização. A analogia mostra responsabilidade, mas não apaga as fronteiras. Prisma não é o banco, Express não valida sozinho e Docker não substitui deploy ou backup.
POST /books
-> Express lê o JSON
-> Zod valida e normaliza
-> Prisma Client monta a consulta tipada
-> adapter-pg usa o driver pg
-> PostgreSQL grava a linha
-> Express responde 201 + LocationQuando algo falhar, esse desenho ajuda a perguntar em qual balcão o pedido
parou. Erro 400 nasce da entrada; 404, da ausência do recurso; conexão
recusada acontece antes de a consulta chegar ao estoque.
1. Crie a pasta e fixe as versões
mkdir api-livraria
cd api-livraria
npm init -ynpm install express@5.2.1 zod@4.4.3 dotenv@17.2.3 @prisma/client@7.9.1 @prisma/adapter-pg@7.9.1 pg@8.23.0
npm install --save-dev prisma@7.9.1 typescript@7.0.2 tsx@4.23.12 @types/express@5.0.6 @types/node@24.10.13Versão fixa torna o tutorial repetível. Num projeto mantido, ferramentas de
atualização mostram correções posteriores; você atualiza uma família por vez,
roda testes e lê migrações. @latest em um texto eterno pode entregar outra API
meses depois.
Edite os campos centrais do package.json:
{
"name": "api-livraria-devclub",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"check": "tsc -p tsconfig.json --noEmit"
}
}type: module deixa o projeto em ESM. dev executa TypeScript durante o
desenvolvimento; build gera JavaScript; start executa apenas a saída pronta.
2. Configure TypeScript para Node ESM
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}strict faz o editor perguntar antes de você adivinhar. As duas opções seguintes
apertam arrays e propriedades opcionais. Elas causaram erros reais durante a
construção e revelaram pontos em que undefined podia chegar à consulta.
Em ESM com NodeNext, imports relativos escritos no TypeScript terminam em
.js, mesmo apontando para um arquivo .ts durante o build:
import { app } from './app.js';Parece estranho porque é novo: TypeScript encontra o fonte correspondente e emite um import que o Node entende depois. O guia de TypeScript aprofunda cada opção.
npm run checkNenhuma saída significa nenhum erro de tipo.
3. Suba somente o PostgreSQL
Crie compose.yaml primeiro com o banco:
services:
db:
image: postgres:18-alpine
ports:
- "55432:5432"
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devclub
POSTGRES_DB: livraria
volumes:
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d livraria"]
interval: 3s
timeout: 3s
retries: 10
volumes:
postgres-data:O lado 55432 evita disputar a porta local padrão; dentro da rede o banco
continua em 5432. A imagem PostgreSQL 18 deve montar o volume em
/var/lib/postgresql. Usar o antigo /var/lib/postgresql/data fez o contêiner
18.6 abortar no teste, com uma mensagem que protege a organização por versão.
Inicie e espere o healthcheck:
docker compose up -d --wait db
docker compose psO arquivo .env conecta ferramentas executadas no host:
PORT=3000
DATABASE_URL=postgresql://app:devclub@localhost:55432/livrariaAdicione .env ao .gitignore e publique somente .env.example. A senha deste
tutorial é descartável. Produção usa segredo gerenciado e uma credencial por
aplicação.
4. Modele o livro no Prisma 7
Crie prisma/schema.prisma:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
}
datasource db {
provider = "postgresql"
}
model Book {
id Int @id @default(autoincrement())
title String
author String
priceCents Int @map("price_cents")
stock Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([title])
@@map("books")
}Prisma 7 usa o gerador prisma-client e exige output explícito. @map mantém
o código em camelCase e o banco em snake_case. priceCents é inteiro: R$ 79,90
vira 7990, evitando arredondamento de ponto flutuante.
A URL saiu do schema.prisma. Ela mora em prisma.config.ts:
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') },
});Essa é uma diferença do Prisma 7 que exemplos antigos não mostram. Gere a migração:
npm run db:migrate -- --name initThe following migration(s) have been created and applied: prisma/migrations/ └─ …_init/ └─ migration.sql
Your database is now in sync with your schema.
Migração é histórico versionado da estrutura, não botão para sincronizar tudo. Leia o SQL gerado antes de enviar ao Git:
CREATE TABLE "books" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"price_cents" INTEGER NOT NULL,
"stock" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "books_pkey" PRIMARY KEY ("id")
);O guia de PostgreSQL explica tabela, índice e transação por baixo do ORM.
5. Conecte Prisma Client pelo adapter-pg
Crie src/db.ts:
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client.js';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL não foi definida');
}
const adapter = new PrismaPg({ connectionString });
export const prisma = new PrismaClient({ adapter });No Prisma 7, conexão direta exige adapter. PrismaPg liga o Client gerado ao
driver pg. O erro cedo para URL ausente é mais útil que uma falha obscura na
primeira requisição.
Não crie um novo PrismaClient dentro de cada rota. O pool de conexões pertence
à aplicação. Ambientes serverless podem exigir estratégia do provedor, mas um
servidor Node persistente reutiliza a instância.
6. Valide entrada com Zod
TypeScript não valida JSON vindo da rede. Quando a aplicação executa, o tipo foi
apagado. src/schemas.ts cria a catraca de runtime:
import { z } from 'zod';
export const idSchema = z.coerce.number().int().positive();
export const createBookSchema = z.object({
title: z.string().trim().min(2).max(120),
author: z.string().trim().min(2).max(100),
priceCents: z.number().int().nonnegative(),
stock: z.number().int().nonnegative().default(0),
});
export const updateBookSchema = createBookSchema.partial().refine(
(data) => Object.keys(data).length > 0,
{ message: 'envie pelo menos um campo' },
);coerce converte o parâmetro de URL, que chega como texto. Para o body, o
contrato exige número JSON de verdade; não converte "7990" silenciosamente.
partial permite alteração de um campo e refine rejeita objeto vazio.
7. Monte o Express e a rota de saúde
Comece src/app.ts:
import express from 'express';
import { z } from 'zod';
import { Prisma } from './generated/prisma/client.js';
import { prisma } from './db.js';
import { createBookSchema, idSchema, updateBookSchema } from './schemas.js';
export const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '32kb' }));O limite evita aceitar body ilimitado para registros pequenos. Desativar o cabeçalho não é barreira de segurança, apenas reduz informação desnecessária.
A saúde confirma processo e banco:
app.get('/health', async (_request, response) => {
await prisma.$queryRaw`SELECT 1`;
response.json({ status: 'ok' });
});$queryRaw com template parametrizado é seguro para esse SQL fixo. Não troque
por uma versão insegura concatenando entrada.
8. Liste e busque livros
A rota GET /books aceita ?search=:
app.get('/books', async (request, response) => {
const search = typeof request.query.search === 'string'
? request.query.search.trim()
: '';
const books = await prisma.book.findMany({
...(search
? { where: { title: { contains: search, mode: 'insensitive' as const } } }
: {}),
orderBy: { id: 'asc' },
});
response.json({ data: books, total: books.length });
});O spread condicional não é charme. Com exactOptionalPropertyTypes, enviar
where: undefined não equivale a omitir where. A primeira versão fez o
TypeScript 7 interromper o build; construir o objeto sem a propriedade resolveu
sem enfraquecer o compilador.
Para uma base grande, acrescente paginação e reveja o índice: busca contains
insensível não usa automaticamente um índice B-tree simples. ORM não elimina
plano de consulta.
9. Crie e leia um recurso
Criação responde 201 Created e informa a nova URL:
app.post('/books', async (request, response) => {
const input = createBookSchema.parse(request.body);
const book = await prisma.book.create({ data: input });
response
.status(201)
.location(`/books/${book.id}`)
.json({ data: book });
});Busca por id diferencia dado ausente de erro interno:
app.get('/books/:id', async (request, response) => {
const id = idSchema.parse(request.params.id);
const book = await prisma.book.findUnique({ where: { id } });
if (!book) {
return response.status(404).json({ error: 'livro não encontrado' });
}
return response.json({ data: book });
});/books/banana vira 400, pois o identificador tem formato inválido.
/books/999 válido mas inexistente vira 404. Essa precisão ajuda o front a
decidir o que mostrar.
10. Atualize sem enviar undefined ao Prisma
Valide e construa Prisma.BookUpdateInput explicitamente:
app.patch('/books/:id', async (request, response) => {
const id = idSchema.parse(request.params.id);
const input = updateBookSchema.parse(request.body);
const data: Prisma.BookUpdateInput = {};
if (input.title !== undefined) data.title = input.title;
if (input.author !== undefined) data.author = input.author;
if (input.priceCents !== undefined) data.priceCents = input.priceCents;
if (input.stock !== undefined) data.stock = input.stock;
const book = await prisma.book.update({ where: { id }, data });
response.json({ data: book });
});A versão ingênua passava o objeto parcial direto e falhou com
exactOptionalPropertyTypes. A correção torna visível quais campos a API permite
alterar. Em domínios sensíveis, essa lista também evita mass assignment.
Remoção responde sem body:
app.delete('/books/:id', async (request, response) => {
const id = idSchema.parse(request.params.id);
await prisma.book.delete({ where: { id } });
response.status(204).send();
});11. Traduza erros num único middleware
Express 5 encaminha rejeições de handlers assíncronos ao middleware de erro. Use a assinatura com quatro argumentos:
app.use((error: unknown, _request, response, _next) => {
if (error instanceof z.ZodError) {
return response.status(400).json({
error: 'dados inválidos',
fields: error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
})),
});
}
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
return response.status(404).json({ error: 'livro não encontrado' });
}
console.error(error);
return response.status(500).json({ error: 'erro interno' });
});O cliente não recebe stack trace, SQL ou segredo. O servidor registra o erro
inesperado. Produção deve adicionar id de correlação e logger estruturado;
console.error é o degrau mínimo.
12. Inicie e encerre sem cortar conexões
src/server.ts carrega .env, abre a porta e trata sinais:
import 'dotenv/config';
import { app } from './app.js';
import { prisma } from './db.js';
const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, '0.0.0.0', () => {
console.log(`API da livraria em http://localhost:${port}`);
});
async function shutdown(signal: string) {
console.log(`encerrando por ${signal}`);
server.close(async () => {
await prisma.$disconnect();
process.exit(0);
});
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));0.0.0.0 aceita a conexão encaminhada pelo Docker. No desligamento, o servidor
para de aceitar trabalho e desconecta o banco depois de fechar.
npm run db:generate
npm run check
npm run build
npm start13. Prove o CRUD com curl
Confira saúde:
curl -i http://localhost:3000/health{“status”:“ok”}
Cadastre:
curl -i -X POST http://localhost:3000/books -H 'content-type: application/json' -d '{"title":"JavaScript por Inteiro","author":"Ana Lima","priceCents":7990,"stock":12}'{“data”:{“id”:1,“title”:“JavaScript por Inteiro”,“author”:“Ana Lima”,“priceCents”:7990,“stock”:12,…}}
Busque e altere:
curl 'http://localhost:3000/books?search=javascript'
curl -X PATCH http://localhost:3000/books/1 -H 'content-type: application/json' -d '{"stock":10}'Agora provoque o erro de propósito:
curl -i -X POST http://localhost:3000/books -H 'content-type: application/json' -d '{"title":"A"}'{“error”:“dados inválidos”,“fields”:[ {“path”:“title”,“message”:“Too small: expected string to have >=2 characters”}, {“path”:“author”,“message”:“Invalid input: expected string, received undefined”}, {“path”:“priceCents”,“message”:“Invalid input: expected number, received undefined”} ]}
O erro é parte do contrato, não acidente escondido. O front consegue associar
path ao campo correto.
Remova e confirme o 404:
curl -i -X DELETE http://localhost:3000/books/1
curl -i http://localhost:3000/books/1HTTP/1.1 404 Not Found {“error”:“livro não encontrado”}
14. Construa uma imagem sem ferramentas de desenvolvimento
O Dockerfile usa dois estágios:
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json prisma.config.ts ./
COPY prisma ./prisma
COPY src ./src
RUN DATABASE_URL=postgresql://build:build@localhost:5432/build npm run db:generate && npm run build
FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --omit=optional && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]A URL falsa existe apenas durante prisma generate, que carrega a configuração
mas não conecta. Nenhum segredo real entra na imagem. O estágio final instala
somente runtime; --omit=optional também impede que o CLI Prisma, dependência
opcional do Client, chegue à API.
O comando npm audit --omit=dev --omit=optional retornou zero vulnerabilidades
no runtime testado. O estágio de build reportou um advisory transitivo no CLI
Prisma 7.9.1 sem correção compatível naquela data. Não usamos audit fix --force,
que sugeria voltar para Prisma 6 e quebraria o projeto; isolamos a ferramenta do
runtime e registramos o risco para atualizar quando o upstream corrigir.
Isso é uma decisão prática de segurança: scanner não é semáforo que autoriza mudança incompatível. Você identifica pacote, alcance, código que chega à produção, mitigação e prazo de revisão.
15. Una migração, API e banco com Compose
Complete compose.yaml:
services:
api:
build: .
environment:
PORT: 3000
DATABASE_URL: postgresql://app:devclub@db:5432/livraria
ports:
- "3010:3000"
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"]
interval: 5s
timeout: 3s
retries: 10
migrate:
build:
context: .
target: build
command: ["npx", "prisma", "migrate", "deploy"]
environment:
DATABASE_URL: postgresql://app:devclub@db:5432/livraria
depends_on:
db:
condition: service_healthy
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devclub
POSTGRES_DB: livraria
volumes:
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d livraria"]
interval: 3s
timeout: 3s
retries: 10
volumes:
postgres-data:Dentro da rede, db é o endereço do PostgreSQL; localhost apontaria para a
própria API. O serviço migrate usa o estágio com ferramentas, termina com
sucesso e só então libera a API. A imagem final continua sem compilador e CLI.
Suba tudo:
docker compose up -d --build --wait
docker compose ps
curl http://localhost:3010/health{“status”:“ok”}
O build real gerou Prisma Client 7.9.1, compilou TypeScript 7.0.2, aplicou a
migração no PostgreSQL 18.6 e iniciou a API como usuário node. O POST no
contêiner respondeu 201 e a busca retornou a linha persistida.
Para parar sem apagar dados:
docker compose downdocker compose down -v também remove o volume. Use somente quando tiver
confirmado que o banco é descartável ou possui backup.
16. Teste o sistema vazio, usado e reiniciado
Executar um POST uma vez prova o caminho feliz, não o projeto inteiro. A unidade real deste tutorial é API mais migração mais banco. O teste que dá confiança começa com um banco vazio, aplica o histórico versionado e percorre o contrato HTTP como um cliente faria.
Pense numa vistoria de apartamento. Olhar a torneira aberta não confirma ralo, registro, vazamento e retorno da água depois de uma manutenção. Cada cenário força uma parte do encanamento. Na API, os cenários mínimos são saúde, criação, consulta, validação, ausência, alteração, remoção e reinício.
Crie um projeto Compose separado para o teste, para nunca apontar sem querer ao banco de desenvolvimento:
docker compose -p livraria-teste up -d --build --wait
curl -f http://localhost:3010/healthO nome após -p isola rede, contêineres e volume. Em uma esteira concorrente,
publique a API numa porta dinâmica ou execute as chamadas de dentro da rede,
evitando que dois jobs disputem 3010.
Monte uma tabela de casos antes de automatizar:
| Caso | Ação | Resultado esperado |
|---|---|---|
| banco pronto | GET /health |
200 e status: ok |
| livro válido | POST /books |
201, Location e id |
| body incompleto | POST /books |
400 e campos inválidos |
| id inválido | GET /books/abc |
400 |
| id ausente | GET /books/999 |
404 |
| estoque parcial | PATCH /books/1 |
200 e demais campos intactos |
| remoção | DELETE /books/1 |
204 |
| recurso removido | GET /books/1 |
404 |
Automação pode usar o test runner do Node e uma biblioteca de requisição, mas preserve a observação externa. Testar diretamente uma função do controller não prova parser JSON, rota, middleware de erro nem status.
O segundo grupo de testes verifica persistência. Cadastre um livro, recrie
somente a API e busque o mesmo id. Depois execute docker compose down, suba
novamente sem -v e repita a busca. O dado deve continuar porque mora no volume,
não na camada gravável do contêiner.
O terceiro grupo verifica evolução. Acrescente uma migração que permita ISBN nulo, aplique num banco com livros existentes e só depois passe a exigir ISBN na API. Mudança compatível em etapas evita que código novo dependa de coluna ainda ausente ou que migração rígida falhe nos dados antigos.
Há uma regra de ouro: migrate dev cria e ajusta migrações no ambiente de
desenvolvimento; migrate deploy aplica arquivos já revisados no ambiente de
entrega. Produção não deve inventar migração interativamente.
Finalize limpando apenas o projeto de teste:
docker compose -p livraria-teste down -vO -v está autorizado aqui porque o nome identifica um laboratório descartável.
Não transforme esse comando em script apontado para qualquer projeto. Backup e
restauração precisam de um exercício separado com dados que importam.
Quando automatizar, guarde logs de migração, versão da imagem e respostas que falharam. “Job vermelho” informa pouco; um artefato com etapa, status e corpo reduz o tempo até a causa.
17. Checklist antes de chamar de produção
Este projeto ensina a fundação, não finge encerrar operação. Antes de publicar:
- autentique e autorize cada ação;
- substitua credenciais didáticas por segredos gerenciados;
- defina CORS para origens conhecidas quando houver navegador;
- adicione rate limit e limite de conexão;
- registre método, rota, status, duração e id de correlação;
- crie testes de integração num banco isolado;
- execute migração numa etapa controlada, com backup e retorno planejado;
- configure TLS, domínio, healthcheck e encerramento gradual;
- acompanhe erros, latência, pool e consultas lentas;
- teste restauração do backup, não apenas a criação dele.
O guia de Docker aprofunda imagem, volume e segurança. O guia de Prisma acompanha schema, relações e migrações além deste CRUD.
Missão: transforme a livraria em um catálogo seu
Adicione isbn único e uma rota GET /books/:id. Depois tente cadastrar o
mesmo ISBN duas vezes. Seu middleware deve traduzir o erro conhecido do Prisma
para 409 Conflict, sem mostrar SQL.
O critério de sucesso tem cinco provas:
npm run checktermina sem erro;- a migração contém coluna e índice único;
- o primeiro POST responde
201; - o segundo responde
409com JSON estável; docker compose downseguido deuppreserva o primeiro livro.
Se você consegue apontar qual camada produz cada resultado, explicar por que o adapter existe e reconstruir tudo em outra máquina, deixou de copiar uma API e passou a entender o sistema.
Perguntas frequentes
Posso fazer este tutorial sem Docker?
Por que o tutorial usa Prisma 7 e não Prisma 8?
Por que o Prisma 7 precisa de adapter-pg?
Esta API está pronta para receber tráfego público?
Por que o preço é armazenado em centavos?
Onde está o projeto completo do tutorial?
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.19.0, TypeScript 7.0.2, Express 5.2.1, Prisma 7.9.1, PostgreSQL 18.6, Docker 29.5.3 e Compose 5.1.4, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Node.js Docs — Environment variables — nodejs.org
- TypeScript Handbook — typescriptlang.org
- Express 5 — Migrating — expressjs.com
- Prisma ORM 7 — Get started — prisma.io
- npm — versões do Prisma — npmjs.com
- Prisma ORM — Database drivers — prisma.io
- PostgreSQL 18 — Tutorial — postgresql.org
- Docker Docs — Node.js guide — docs.docker.com


