Ir para o conteúdo
Backend

API REST Profissional com Fastify e Prisma

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustracao 3D de monolito de vidro fosco com camadas internas iluminadas em verde representando API REST profissional
Ouça este artigo
0:00API 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érioExpressFastify
Validação nativaNão tem. Precisa de middleware externo (express-validator, celebrate)JSON Schema nativo no core, com suporte a Zod via plugin
Serialização de respostaNão tem. res.json() faz JSON.stringify sem schemaSerialização com schema via fast-json-stringify, que pré-compila o serializer
LoggingNão tem. Você instala morgan ou winston por foraPino integrado no core, com request id automático
Sistema de pluginsMiddleware global com app.use(), sem encapsulamentoEncapsulamento por plugin com escopo isolado
TypeScript DXTipagem via @types/express, frequentemente defasadaTipagem 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

Bash
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 postgresql

A estrutura que funciona sem over-engineering para uma API de tamanho médio (10-30 endpoints):

Text
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.prisma

Essa 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
// 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.

TYPESCRIPT
// 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:

Bash
npx prisma migrate dev --name init

Se 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

TYPESCRIPT
// 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

TYPESCRIPT
// 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

TYPESCRIPT
// 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.

TYPESCRIPT
// 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

TYPESCRIPT
// 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

TYPESCRIPT
// 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

TYPESCRIPT
// 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.

TYPESCRIPT
// 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

TYPESCRIPT
// 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:

TYPESCRIPT
// 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

TYPESCRIPT
// 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:

Bash
npm install @fastify/rate-limit
TYPESCRIPT
// 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.

Marcos Soares

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.