API REST Profissional com Fastify e Prisma

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
A maioria dos tutoriais de API REST para com o CRUD funcionando. Você tem quatro rotas, um prisma.user.findMany() direto no handler, zero validação, zero tratamento de erro. Funciona no Insomnia. Quebra no primeiro request malformado.
Este post monta uma API REST com Fastify e Prisma que trata os problemas que aparecem quando o código vai para staging: validação de payload, error handling centralizado, separação entre handler e lógica de negócio, e logging estruturado. O resultado é uma base que você consegue estender sem reescrever.
Por que Fastify e não Express
A escolha entre Fastify e Express não é sobre "qual é mais rápido" em benchmark sintético. A diferença prática está em três pontos que afetam o dia a dia de desenvolvimento:
| Critério | Express | Fastify |
|---|---|---|
| Validação nativa | Não tem. Precisa de middleware externo (express-validator, celebrate) | JSON Schema nativo no core, com suporte a Zod via plugin |
| Serialização de resposta | Não tem. res.json() faz JSON.stringify sem schema | Serialização com schema via fast-json-stringify, que pré-compila o serializer |
| Logging | Não tem. Você instala morgan ou winston por fora | Pino integrado no core, com request id automático |
| Sistema de plugins | Middleware global com app.use(), sem encapsulamento | Encapsulamento por plugin com escopo isolado |
| TypeScript DX | Tipagem via @types/express, frequentemente defasada | Tipagem first-class, generics nos handlers para schema tipado |
Se a sua API tem menos de cinco rotas e você já conhece Express, não vale a migração. Se você está começando um projeto novo com mais de dez endpoints, Fastify entrega mais infraestrutura pronta e menos dependências externas.
Setup inicial: projeto, Prisma e estrutura de pastas
mkdir api-produtos && cd api-produtos
npm init -y
npm install fastify @fastify/cors @prisma/client zod
npm install -D typescript tsx prisma @types/node
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir dist --rootDir src --strict
npx prisma init --datasource-provider postgresqlA estrutura que funciona sem over-engineering para uma API de tamanho médio (10-30 endpoints):
src/
server.ts
app.ts
modules/
product/
product.routes.ts
product.service.ts
product.schema.ts
lib/
prisma.ts
errors/
app-error.ts
prisma/
schema.prismaEssa organização por módulo (feature) escala melhor que a separação por tipo (controllers/, services/, routes/) porque mantém tudo que pertence a um domínio no mesmo diretório. Se você quer ir além nessa direção, o post sobre Clean Architecture com TypeScript e Node.js detalha a separação em camadas com inversão de dependência.
Schema do Prisma e cliente singleton
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Product {
id String @id @default(cuid())
name String @db.VarChar(255)
description String? @db.Text
priceInCents Int // armazenar preço em centavos evita problemas de ponto flutuante
stock Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("products")
}O cliente do Prisma precisa ser singleton. Se você instancia new PrismaClient() em cada arquivo, cada instância abre seu próprio connection pool. Em desenvolvimento com hot reload (tsx watch, nodemon), isso esgota conexões do PostgreSQL em minutos.
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
// Reutiliza a instância entre hot reloads em desenvolvimento.
// Em produção, o processo inicia uma vez e isso é irrelevante.
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "warn", "error"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}Execute a migration inicial:
npx prisma migrate dev --name initSe você trabalha com migrations em time, o post sobre zero-downtime migrations cobre como alterar tabelas sem derrubar a aplicação, e o de como reverter migrations com segurança resolve o cenário de rollback.
Validação com Zod e schemas tipados
// src/modules/product/product.schema.ts
import { z } from "zod";
export const createProductSchema = z.object({
name: z.string().min(3).max(255),
description: z.string().max(2000).optional(),
priceInCents: z
.number()
.int()
.positive("Preço precisa ser positivo"),
stock: z.number().int().min(0).default(0),
});
export const updateProductSchema = createProductSchema.partial();
export const productParamsSchema = z.object({
id: z.string().cuid(),
});
export const listProductsQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
perPage: z.coerce.number().int().min(1).max(100).default(20),
active: z.coerce.boolean().optional(),
});
// Tipos derivados dos schemas: uma única fonte de verdade
export type CreateProductInput = z.infer<typeof createProductSchema>;
export type UpdateProductInput = z.infer<typeof updateProductSchema>;
export type ListProductsQuery = z.infer<typeof listProductsQuerySchema>;Derivar tipos do schema Zod elimina a duplicação entre "tipo TypeScript" e "regra de validação". Quando você adiciona um campo no schema, o tipo atualiza automaticamente.
Service: lógica de negócio separada do HTTP
// src/modules/product/product.service.ts
import { prisma } from "../../lib/prisma.js";
import { AppError } from "../../errors/app-error.js";
import type {
CreateProductInput,
UpdateProductInput,
ListProductsQuery,
} from "./product.schema.js";
export async function createProduct(data: CreateProductInput) {
return prisma.product.create({ data });
}
export async function listProducts(query: ListProductsQuery) {
const { page, perPage, active } = query;
const where = active !== undefined ? { active } : {};
const [products, total] = await prisma.$transaction([
prisma.product.findMany({
where,
skip: (page - 1) * perPage,
take: perPage,
orderBy: { createdAt: "desc" },
}),
prisma.product.count({ where }),
]);
return {
data: products,
meta: {
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
},
};
}
export async function getProductById(id: string) {
const product = await prisma.product.findUnique({ where: { id } });
if (!product) {
throw new AppError("Produto não encontrado", 404);
}
return product;
}
export async function updateProduct(id: string, data: UpdateProductInput) {
// Verifica existência antes de atualizar para retornar 404 claro
await getProductById(id);
return prisma.product.update({ where: { id }, data });
}
export async function deleteProduct(id: string) {
await getProductById(id);
// Soft delete: marca como inativo em vez de remover o registro.
// Produtos podem ter referências em pedidos futuros.
return prisma.product.update({
where: { id },
data: { active: false },
});
}O service não sabe nada sobre HTTP. Não recebe request nem reply. Recebe dados já validados e retorna dados ou lança AppError. Isso permite reutilizar a mesma lógica em workers, scripts de migração ou processamento assíncrono com BullMQ.
Error handling centralizado
// src/errors/app-error.ts
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number = 400,
public readonly code?: string
) {
super(message);
this.name = "AppError";
}
}O error handler global no Fastify intercepta tudo: erros de validação Zod, AppError do domínio e erros inesperados.
// src/app.ts
import Fastify from "fastify";
import cors from "@fastify/cors";
import { ZodError } from "zod";
import { AppError } from "./errors/app-error.js";
import { productRoutes } from "./modules/product/product.routes.js";
export function buildApp() {
const app = Fastify({
logger: {
transport:
process.env.NODE_ENV === "development"
? { target: "pino-pretty" }
: undefined,
},
});
app.register(cors, { origin: true });
// Error handler centralizado: um único lugar para formatar todas as respostas de erro
app.setErrorHandler((error, request, reply) => {
if (error instanceof ZodError) {
return reply.status(422).send({
error: "Validation Error",
issues: error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
});
}
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: error.message,
code: error.code,
});
}
// Erros inesperados: loga o stack completo, retorna mensagem genérica.
// Nunca vaze stack trace para o cliente.
request.log.error(error);
return reply.status(500).send({
error: "Internal Server Error",
});
});
app.register(productRoutes, { prefix: "/api/products" });
return app;
}Rotas: finas, sem lógica de negócio
// src/modules/product/product.routes.ts
import type { FastifyPluginAsync } from "fastify";
import {
createProductSchema,
updateProductSchema,
productParamsSchema,
listProductsQuerySchema,
} from "./product.schema.js";
import * as service from "./product.service.js";
export const productRoutes: FastifyPluginAsync = async (app) => {
app.post("/", async (request, reply) => {
const body = createProductSchema.parse(request.body);
const product = await service.createProduct(body);
return reply.status(201).send(product);
});
app.get("/", async (request) => {
const query = listProductsQuerySchema.parse(request.query);
return service.listProducts(query);
});
app.get("/:id", async (request) => {
const { id } = productParamsSchema.parse(request.params);
return service.getProductById(id);
});
app.put("/:id", async (request) => {
const { id } = productParamsSchema.parse(request.params);
const body = updateProductSchema.parse(request.body);
return service.updateProduct(id, body);
});
app.delete("/:id", async (request, reply) => {
const { id } = productParamsSchema.parse(request.params);
await service.deleteProduct(id);
return reply.status(204).send();
});
};Cada handler faz três coisas: valida, delega ao service, retorna status code. Se um handler tem mais de 10 linhas, provavelmente está absorvendo lógica que pertence ao service.
Entrypoint
// src/server.ts
import { buildApp } from "./app.js";
const app = buildApp();
const PORT = Number(process.env.PORT) || 3333;
app.listen({ port: PORT, host: "0.0.0.0" }, (err, address) => {
if (err) {
app.log.error(err);
process.exit(1);
}
app.log.info(`Servidor rodando em ${address}`);
});Para rodar em desenvolvimento: npx tsx watch src/server.ts. Para produção, compile com tsc e rode node dist/server.js. Se o deploy envolve containers, o post sobre Docker para devs cobre do Dockerfile ao docker-compose.
O que NÃO fazer
Anti-pattern 1: Prisma direto no handler
// ERRADO: lógica de negócio e acesso a dados acoplados ao HTTP
app.get("/products/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const product = await prisma.product.findUnique({ where: { id } });
if (!product) {
return reply.status(404).send({ error: "Not found" });
}
if (!product.active) {
return reply.status(410).send({ error: "Gone" });
}
return product;
});O problema: quando você precisa da mesma regra ("produto precisa existir e estar ativo") em outro contexto (worker, script, outro endpoint), copia e cola. Quando a regra muda, esquece de atualizar um dos lugares.
// CORRETO: handler delega para o service
app.get("/:id", async (request) => {
const { id } = productParamsSchema.parse(request.params);
return service.getProductById(id);
});Anti-pattern 2: validação manual de campos
// ERRADO: validação manual espalhada, incompleta e sem tipagem
app.post("/products", async (request, reply) => {
const { name, priceInCents } = request.body as any;
if (!name) return reply.status(400).send({ error: "name é obrigatório" });
if (typeof priceInCents !== "number")
return reply.status(400).send({ error: "priceInCents deve ser número" });
// esqueceu de validar se priceInCents é inteiro positivo
// esqueceu de validar tamanho máximo de name
// tipo de retorno de request.body é any
});O as any elimina qualquer ajuda do TypeScript. Cada campo precisa de validação manual, e é garantido que algum vai faltar. Com Zod, a validação é declarativa, completa e gera o tipo:
// CORRETO: schema declara todas as regras, tipo é derivado
const body = createProductSchema.parse(request.body);
// body tem tipo { name: string; priceInCents: number; stock: number; description?: string }Anti-pattern 3: swallow de erros do Prisma
// ERRADO: engole o erro e retorna 500 genérico sem log
app.post("/products", async (request, reply) => {
try {
const product = await prisma.product.create({ data: request.body as any });
return product;
} catch {
return reply.status(500).send({ error: "Algo deu errado" });
}
});Sem logging, você não sabe se foi constraint violation, connection timeout ou campo inválido. O error handler centralizado resolve isso: loga o erro completo no servidor e retorna mensagem segura ao cliente.
Adicionando rate limiting e autenticação
Para APIs públicas, rate limiting é obrigatório. O plugin @fastify/rate-limit resolve sem código custom:
npm install @fastify/rate-limit// dentro de buildApp(), antes de registrar as rotas
import rateLimit from "@fastify/rate-limit";
app.register(rateLimit, {
max: 100, // 100 requests
timeWindow: "1 minute",
keyGenerator: (request) => request.ip,
});Para autenticação com JWT, o pattern de API Gateway com rate limiting e caching detalha a implementação completa. Se a API serve um frontend Next.js, a integração com NextAuth.js e middleware cobre o fluxo de ponta a ponta.
Quando Fastify + Prisma NÃO é a escolha certa
Fastify roda sobre o runtime Node.js (V8 + libuv). O event loop é single-threaded para JavaScript. Se a sua API faz processamento pesado de CPU (geração de relatórios complexos, transformação de imagens, cálculos científicos), o event loop bloqueia e todas as requests enfileiram.
Para esse cenário, existem duas saídas: delegar o trabalho pesado para uma fila de processamento assíncrono (o post sobre mensageria com BullMQ cobre isso) ou usar worker threads. Se 80% da sua API é I/O (queries no banco, chamadas a serviços externos, leitura de cache), Fastify + Prisma funciona bem até dezenas de milhares de requests por segundo com hardware modesto.
Sobre o Prisma especificamente: ele adiciona overhead de abstração. Para queries simples de CRUD, o custo é irrelevante. Para queries analíticas complexas com múltiplos JOINs, CTEs ou busca full-text com tsvector, use prisma.$queryRaw ou considere um query builder como Kysely para essas rotas específicas. Não é tudo ou nada: Prisma para 90% das queries e raw SQL para os 10% que precisam de controle fino.
FAQ
Preciso usar Zod? O Fastify já tem validação com JSON Schema.
Não precisa. O Fastify usa JSON Schema nativamente e compila os schemas com Ajv, o que é mais performático que Zod em runtime. A vantagem do Zod é a ergonomia de TypeScript: z.infer gera o tipo direto do schema. Se performance de validação é crítica (acima de 10k req/s com payloads grandes), use JSON Schema nativo. Para a maioria das APIs, a diferença é irrelevante e o Zod oferece DX melhor.
Como faço testes nessa estrutura?
O Fastify tem app.inject() para testes de integração sem subir servidor HTTP. Chame buildApp(), injete a request e asserteie a resposta. Para testes unitários dos services, mocke o Prisma Client com vitest ou jest. A separação handler/service torna os testes unitários triviais: o service recebe dados tipados e retorna dados, sem dependência de HTTP.
Devo usar Fastify com Next.js ou separar os projetos?
Depende da complexidade da API. Se a API tem menos de 15 endpoints e serve exclusivamente o frontend Next.js, as Route Handlers do App Router resolvem sem projeto separado. Se a API serve múltiplos clientes (mobile, outros serviços, webhooks) ou tem lógica de negócio complexa, separe. APIs separadas escalam, deployam e monitoram de forma independente.
Prisma ou TypeORM?
Prisma tem DX superior para TypeScript: schema declarativo, migrations automáticas, tipos gerados. TypeORM oferece mais controle sobre queries complexas e suporta patterns como Active Record. Se o projeto é greenfield com PostgreSQL e TypeScript, Prisma. Se você precisa de suporte a múltiplos bancos ou herda um schema legado com relações complexas, avalie TypeORM. O post sobre migrations com TypeORM cobre o workflow de versionamento de schema com ele.
Como organizo quando a API cresce para 50+ endpoints?
A estrutura por módulo (modules/product/, modules/order/) escala até 30-40 módulos sem problemas. Acima disso, considere separar em serviços ou usar monorepo com Turborepo para manter builds rápidos com código compartilhado.
Posição final
Fastify com Prisma é a combinação mais produtiva para APIs REST em TypeScript quando o domínio é predominantemente CRUD com regras de negócio moderadas. A validação com Zod no lugar de JSON Schema é um trade-off consciente: você troca performance de validação (que raramente é gargalo) por ergonomia de tipos. A separação handler/service/schema não é arquitetura astronauta: são três arquivos por módulo que evitam que a lógica de negócio grude no framework. Quando precisar trocar Fastify por outro framework, o service continua intacto. Quando precisar chamar a mesma lógica de um worker, o service já está pronto.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


