Pular para o conteúdo
TypeScript

CRUD com Node.js e TypeScript:

Construa um CRUD completo com Node.js, Express e TypeScript. Modelos tipados, camada de service, controllers organizados e rotas definidas. Do zero ao funcionando.

Por que isso é importante

CRUD com Node.js e TypeScript:. Construa um CRUD completo com Node.js, Express e TypeScript. Modelos tipados, camada de service, controllers organizados e rotas definidas. Do zero ao funcionando.

Arquitetura do CRUD: Camadas e Responsabilidades

Um CRUD bem feito não joga tudo num arquivo só. Cada camada tem uma responsabilidade clara. O Model define o formato dos dados. O Service contém a lógica de negócio. O Controller recebe a request e orquestra a resposta. As Routes conectam URLs aos controllers.

Essa separação parece burocracia no começo, mas salva o projeto quando ele cresce. Precisa trocar o banco de dados? Muda só o service. Precisa adicionar validação? Coloca um middleware antes do controller. Precisa mudar a URL? Mexe só nas routes.

Com TypeScript, cada camada tem interfaces que definem contratos. O service recebe e retorna tipos específicos. O controller recebe Request tipado e devolve Response tipado. Se alguém quebra um contrato, o compilador aponta na hora. Galera que trabalha em equipe sabe o quanto isso economiza reunião de debug.

Vamos construir um CRUD completo de produtos: criar, listar, buscar por ID, atualizar e deletar. Com Express, Prisma e TypeScript.

Passo a Passo: CRUD Completo com TypeScript

Vamos montar cada camada do CRUD na ordem certa.

  1. Passo 1 - Defina os tipos do domínio: Crie interfaces que representam seu modelo de dados: Product, CreateProductInput, UpdateProductInput. Esses tipos vão ser usados em todas as camadas.
  2. Passo 2 - Crie o service layer: O service contém toda a lógica de acesso a dados. Cada operação CRUD vira um método tipado: create, findAll, findById, update, delete.
  3. Passo 3 - Crie o controller layer: O controller recebe Request e Response do Express, chama o service e formata a resposta. Trate erros e retorne status codes corretos.
  4. Passo 4 - Defina as rotas: Conecte cada endpoint HTTP ao método correto do controller. GET, POST, PUT, DELETE com paths claros.
  5. Passo 5 - Adicione validação: Use Zod ou middleware customizado pra validar body antes do controller. Dados inválidos nunca chegam no service.
  6. Passo 6 - Conecte tudo no servidor: Registre rotas, middleware de erro e inicie o servidor. Teste cada endpoint com curl ou Insomnia.

Exemplos Práticos: CRUD de Produtos

Vamos construir cada camada com código real. Pronto pra copiar e adaptar pro seu projeto.

Tipos do Domínio

// src/types/product.ts
export interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  stock: number;
  category: string;
  active: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateProductInput {
  name: string;
  description: string;
  price: number;
  stock: number;
  category: string;
}

export interface UpdateProductInput {
  name?: string;
  description?: string;
  price?: number;
  stock?: number;
  category?: string;
  active?: boolean;
}

export interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  active?: boolean;
  page?: number;
  limit?: number;
}

export interface PaginatedResult<T> {
  data: T[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

Service Layer: Lógica de Negócio Tipada

// src/services/product.service.ts
import { prisma } from '../lib/prisma';
import {
  Product,
  CreateProductInput,
  UpdateProductInput,
  ProductFilters,
  PaginatedResult,
} from '../types/product';

export class ProductService {
  async create(data: CreateProductInput): Promise<Product> {
    return prisma.product.create({ data });
  }

  async findAll(filters: ProductFilters): Promise<PaginatedResult<Product>> {
    const { category, minPrice, maxPrice, active, page = 1, limit = 20 } = filters;

    const where = {
      ...(category && { category }),
      ...(active !== undefined && { active }),
      ...(minPrice || maxPrice) && {
        price: {
          ...(minPrice && { gte: minPrice }),
          ...(maxPrice && { lte: maxPrice }),
        },
      },
    };

    const [data, total] = await Promise.all([
      prisma.product.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: 'desc' },
      }),
      prisma.product.count({ where }),
    ]);

    return {
      data,
      total,
      page,
      limit,
      totalPages: Math.ceil(total / limit),
    };
  }

  async findById(id: string): Promise<Product | null> {
    return prisma.product.findUnique({ where: { id } });
  }

  async update(id: string, data: UpdateProductInput): Promise<Product> {
    return prisma.product.update({ where: { id }, data });
  }

  async delete(id: string): Promise<Product> {
    return prisma.product.delete({ where: { id } });
  }
}

Controller Layer: Orquestração de Request/Response

// src/controllers/product.controller.ts
import { Request, Response } from 'express';
import { ProductService } from '../services/product.service';
import { CreateProductInput, UpdateProductInput } from '../types/product';

const productService = new ProductService();

export class ProductController {
  async create(req: Request<{}, {}, CreateProductInput>, res: Response) {
    const product = await productService.create(req.body);
    res.status(201).json({ success: true, data: product });
  }

  async findAll(req: Request, res: Response) {
    const filters = {
      category: req.query.category as string | undefined,
      minPrice: req.query.minPrice ? Number(req.query.minPrice) : undefined,
      maxPrice: req.query.maxPrice ? Number(req.query.maxPrice) : undefined,
      active: req.query.active === 'true' ? true : req.query.active === 'false' ? false : undefined,
      page: req.query.page ? Number(req.query.page) : 1,
      limit: req.query.limit ? Number(req.query.limit) : 20,
    };

    const result = await productService.findAll(filters);
    res.json({ success: true, ...result });
  }

  async findById(req: Request<{ id: string }>, res: Response) {
    const product = await productService.findById(req.params.id);

    if (!product) {
      return res.status(404).json({
        success: false,
        error: 'Produto nao encontrado',
      });
    }

    res.json({ success: true, data: product });
  }

  async update(req: Request<{ id: string }, {}, UpdateProductInput>, res: Response) {
    const product = await productService.update(req.params.id, req.body);
    res.json({ success: true, data: product });
  }

  async delete(req: Request<{ id: string }>, res: Response) {
    await productService.delete(req.params.id);
    res.status(204).send();
  }
}

Rotas: Conectando URLs aos Controllers

// src/routes/product.routes.ts
import { Router } from 'express';
import { ProductController } from '../controllers/product.controller';
import { asyncHandler } from '../middleware/async-handler';
import { validate } from '../middleware/validate';
import { createProductSchema, updateProductSchema } from '../schemas/product.schema';

const productRouter = Router();
const controller = new ProductController();

// GET /products - Listar com filtros
productRouter.get('/',
  asyncHandler(controller.findAll.bind(controller))
);

// GET /products/:id - Buscar por ID
productRouter.get('/:id',
  asyncHandler(controller.findById.bind(controller))
);

// POST /products - Criar
productRouter.post('/',
  validate(createProductSchema),
  asyncHandler(controller.create.bind(controller))
);

// PUT /products/:id - Atualizar
productRouter.put('/:id',
  validate(updateProductSchema),
  asyncHandler(controller.update.bind(controller))
);

// DELETE /products/:id - Deletar
productRouter.delete('/:id',
  asyncHandler(controller.delete.bind(controller))
);

export { productRouter };

Schemas de Validação com Zod

// src/schemas/product.schema.ts
import { z } from 'zod';

export const createProductSchema = z.object({
  name: z.string().min(2).max(200),
  description: z.string().min(10).max(2000),
  price: z.number().positive(),
  stock: z.number().int().min(0),
  category: z.string().min(2).max(50),
});

export const updateProductSchema = z.object({
  name: z.string().min(2).max(200).optional(),
  description: z.string().min(10).max(2000).optional(),
  price: z.number().positive().optional(),
  stock: z.number().int().min(0).optional(),
  category: z.string().min(2).max(50).optional(),
  active: z.boolean().optional(),
});

// Os tipos TypeScript podem ser derivados do schema:
export type CreateProductInput = z.infer<typeof createProductSchema>;
export type UpdateProductInput = z.infer<typeof updateProductSchema>;

Servidor: Juntando Tudo

// src/server.ts
import express from 'express';
import { productRouter } from './routes/product.routes';
import { errorHandler } from './middleware/error-handler';

const app = express();

app.use(express.json());

// Rotas
app.use('/api/products', productRouter);

// Error handler no final
app.use(errorHandler);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API rodando na porta ${PORT}`);
});

Essa estrutura de pastas fica assim: src/types/ pro domínio, src/services/ pra lógica, src/controllers/ pra orquestração, src/routes/ pros endpoints, src/schemas/ pra validação, src/middleware/ pra middleware compartilhado. Cada arquivo tem responsabilidade clara e tipos definidos.

Erros Comuns ao Criar CRUD com TypeScript

Armadilhas que travam projetos

Jogar toda a lógica no controller: o controller deve ser fino. Ele recebe a request, chama o service e formata a resposta. Lógica de negócio no controller torna o código impossível de testar e reutilizar.

Não validar entrada antes do service: dados inválidos que chegam no service causam erros genéricos do banco. Valide com Zod na camada de middleware. O service recebe dados já limpos.

Esquecer o .bind(controller) nas rotas: quando você passa controller.create como callback, o this se perde. Use .bind(controller) ou arrow functions pra manter o contexto.

Não tratar erros do Prisma nos controllers: Prisma lança erros específicos (P2002, P2025). Sem tratamento, o usuário recebe um erro 500 genérico. Mapeie erros do Prisma pra status HTTP corretos.

Duplicar tipos entre Zod e interfaces: use z.infer pra derivar tipos TypeScript a partir do schema Zod. Assim você tem validação e tipagem num lugar só. Muda o schema, atualiza o tipo automaticamente.

Checklist de CRUD Completo

  • Tipos do domínio definidos em src/types/ (Product, CreateInput, UpdateInput)
  • Service layer com métodos tipados pra cada operação CRUD
  • Controller layer fino que orquestra service e response
  • Rotas definidas com Router e verbos HTTP corretos
  • Validação com Zod em todo body de entrada (POST/PUT)
  • asyncHandler wrapper em todos os handlers async
  • Error handler global registrado no final
  • Paginação implementada na listagem (page, limit, total)
  • Status codes corretos: 201 create, 204 delete, 404 not found
  • Tipos derivados do Zod com z.infer (sem duplicação)

Construa um CRUD de Verdade

CRUD é o começo, mas o CrazyStack te leva muito além. Você constrói um SaaS completo com autenticação, CRUD avançado, relações entre entidades, paginação, filtros, upload de arquivo e deploy em produção. Tudo com TypeScript, Express e Prisma.

Se você quer sair do tutorial básico e montar um projeto que impressiona em entrevista ou vira um produto real, esse é o caminho.