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.
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
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.
Continue lendo
Como Tipar Express com TypeScript: Guia Completo
Instale @types/express, configure tipos e organize seu servidor Express com TypeScript.
Como Usar Prisma com TypeScript
Schema, tipos gerados, CRUD tipado e relações com Prisma Client.
Como Usar Zod com TypeScript para Validação
Valide dados em runtime e gere tipos TypeScript automaticamente com Zod.
Interface no TypeScript
Quando usar interface