API REST com MongoDB: validação, paginação e testes
Monte uma API completa com Node.js, TypeScript, Express 5 e MongoDB, com CRUD HTTP, schema, índice único, busca, paginação, aggregation e testes.
Neste tutorial você vai construir uma API REST de produtos que grava dados no MongoDB, valida entradas, executa CRUD, busca paginada e aggregation, e responde com erros HTTP previsíveis. No fim, typecheck, oito testes de integração e um smoke test comprovam o projeto rodando de verdade.
A base completa está em blog/examples/api-mongodb/. Ela usa Node 24 LTS,
TypeScript, Express 5 e o driver oficial do MongoDB, sem esconder o acesso ao
banco atrás de um ORM. Você precisa de Docker com Compose e de um terminal; a
execução principal acontece em contêiner, então a versão global do seu Node não
decide o resultado.
Se documentos, coleções e BSON ainda forem novidade, leia primeiro documentos e coleções no MongoDB. Aqui a gente parte dessa base para montar uma aplicação HTTP completa.
Modelo mental: balcão, conferência e estoque
Imagine uma loja com um balcão de atendimento e um estoque. O cliente faz um pedido no balcão; o atendente confere se o formulário está preenchido; só então o estoquista procura ou altera a ficha do produto. Se algo falha, o cliente recebe uma resposta que explica o tipo de problema sem abrir a sala do estoque.
Na API, a rota HTTP é o balcão, a validação TypeScript em tempo de execução
é a conferência e a coleção products é o estoque. O repositório concentra
as operações do driver. O middleware de erro traduz falhas para 400, 404,
409 ou 500. O índice único funciona como uma regra que proíbe duas fichas com o
mesmo código de catálogo.
O limite da analogia importa: o MongoDB não atende pedidos um por um como uma
pessoa. Ele pode usar índices, processar consultas concorrentes e executar um
pipeline sobre muitos documentos. Voltando ao termo técnico, nosso fluxo é
request → validation → repository → MongoDB → response, com contratos nas
duas extremidades da persistência.
cliente HTTP
↓
Express: rota + validação + status
↓
ProductStore: contrato da aplicação
↓
MongoProductStore: driver mongodb 7.5.0
↓
coleção products: JSON Schema + índicesEssa separação também deixa o erro 500 testável: no teste, trocamos somente o
ProductStore por uma versão que falha. Não precisamos criar uma rota secreta
que quebra a aplicação real.
Prepare o projeto com versões reproduzíveis
Entre na pasta do exemplo e confirme as imagens fixadas. Usamos uma versão
exata do Node 24, não a etiqueta móvel latest, e uma versão exata do MongoDB
8.0. Isso reduz a frase “na minha máquina funciona” a uma diferença que a gente
consegue investigar.
cd blog/examples/api-mongodb
docker --version
docker compose version
docker run --rm node:24.19.0-bookworm-slim node --version
docker run --rm mongo:8.0.28 mongod --version | head -n 1O package.json fixa também as bibliotecas. tsx executa TypeScript no modo de
desenvolvimento e nos testes; tsc verifica tipos e gera JavaScript para a
imagem de execução.
{
"name": "api-mongodb-devclub",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": "24.x" },
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit",
"test": "node --import tsx --test --test-reporter spec tests/**/*.test.ts",
"test:smoke": "node scripts/smoke.mjs",
"money:demo": "node scripts/money-demo.mjs"
},
"dependencies": { "express": "5.2.1", "mongodb": "7.5.0" },
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "24.13.3",
"@types/supertest": "7.2.1",
"supertest": "7.2.2",
"tsx": "4.23.12",
"typescript": "7.0.2"
}
}O lockfile já faz parte do exemplo. Por isso, a instalação reproduzível usa
npm ci: ela instala exatamente a árvore registrada e falha se o manifesto e o
lockfile divergirem.
npm ci
npm run check> api-mongodb-devclub@1.0.0 check > tsc -p tsconfig.json –noEmit
O TypeScript usa ESM com NodeNext, regras estritas e JavaScript compatível com
o Node 24. noUncheckedIndexedAccess obriga a considerar acessos que podem não
existir; exactOptionalPropertyTypes diferencia um campo ausente de um campo
presente com undefined.
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": ".",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}Suba um MongoDB local sem confundir laboratório com produção
O arquivo .env.example documenta a porta, o host, a URI e o database. Não há
senha porque este ambiente é deliberadamente local. O .gitignore exclui
.env, então uma configuração particular não entra no Git por acidente.
PORT=3000
HOST=0.0.0.0
MONGODB_URI=mongodb://127.0.0.1:27019
MONGODB_DATABASE=devclub_catalogNo Compose, o nome mongo vira o endereço interno usado pela API. A porta do
banco é publicada apenas em 127.0.0.1; uma máquina externa não deve alcançar
esse laboratório pela interface de rede. O healthcheck impede a API de partir
antes de o banco responder a ping.
name: devclub-api-mongodb
services:
mongo:
image: mongo:8.0.28
ports: ['127.0.0.1:${MONGO_PORT:-27019}:27017']
volumes: [mongo-data:/data/db]
healthcheck:
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand({ ping: 1 }).ok']
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
api:
build: { context: ., target: runtime }
environment:
HOST: 0.0.0.0
PORT: 3000
MONGODB_URI: mongodb://mongo:27017
MONGODB_DATABASE: devclub_catalog
ports: ['127.0.0.1:${API_PORT:-3000}:3000']
depends_on:
mongo: { condition: service_healthy }
volumes:
mongo-data:Suba somente o banco durante o desenvolvimento. docker compose ps precisa
mostrar healthy antes de rodarmos os testes.
docker compose up -d mongo
docker compose psModele o documento e valide em duas barreiras
O tipo ProductDocument descreve o documento dentro do código. A entrada de
criação não aceita datas nem _id; esses valores pertencem ao servidor. A
resposta troca ObjectId e Date por strings, que atravessam JSON sem uma
convenção escondida.
export type ProductDocument = {
_id?: ObjectId;
name: string;
slug: string;
description: string;
priceCents: number;
stock: number;
categories: string[];
active: boolean;
createdAt: Date;
updatedAt: Date;
};
export type CreateProductInput = Pick<ProductDocument,
'name' | 'slug' | 'description' | 'priceCents' | 'stock' | 'categories' | 'active'
>;
export type UpdateProductInput = Partial<CreateProductInput>;TypeScript não valida o JSON recebido: os tipos desaparecem quando o programa
vira JavaScript. Por isso parseCreateProduct() recebe unknown, rejeita
campos desconhecidos, procura os obrigatórios e valida cada valor em runtime.
export function parseCreateProduct(value: unknown): CreateProductInput {
const body = objectBody(value);
rejectUnknownFields(body);
const required = [...CREATE_FIELDS];
const missing = required.filter((field) => !(field in body));
if (missing.length > 0) {
throw new HttpError(400, 'Faltam campos obrigatórios.', missing);
}
return Object.fromEntries(
required.map((field) => [field, parseField(body, field)]),
) as CreateProductInput;
}Preço e estoque são inteiros seguros com limites de negócio. A mesma função
impede NaN, frações e números acima da faixa combinada.
function integerField(body: Record<string, unknown>, name: string,
minimum: number, maximum: number): number {
const value = body[name];
if (!Number.isSafeInteger(value) ||
(value as number) < minimum || (value as number) > maximum) {
throw new HttpError(400,
`${name} precisa ser um inteiro entre ${minimum} e ${maximum}.`);
}
return value as number;
}Pense nas duas validações como a conferência do balcão e a trava do estoque. A
primeira produz uma mensagem adequada ao cliente. A segunda protege a coleção
se um script, uma migração ou outro serviço contornar a API. No MongoDB, o nome
técnico dessa trava é schema validation com $jsonSchema.
const PRODUCT_VALIDATOR = {
$jsonSchema: {
bsonType: 'object',
required: [
'_id', 'name', 'slug', 'description', 'priceCents', 'stock',
'categories', 'active', 'createdAt', 'updatedAt',
],
additionalProperties: false,
properties: {
_id: { bsonType: 'objectId' },
name: { bsonType: 'string', minLength: 2, maxLength: 120 },
slug: { bsonType: 'string', pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' },
description: { bsonType: 'string', minLength: 10, maxLength: 500 },
priceCents: { bsonType: ['int', 'long'], minimum: 0, maximum: 2_000_000_000 },
stock: { bsonType: ['int', 'long'], minimum: 0, maximum: 1_000_000 },
categories: { bsonType: 'array', minItems: 1, maxItems: 10,
uniqueItems: true, items: { bsonType: 'string' } },
active: { bsonType: 'bool' },
createdAt: { bsonType: 'date' },
updatedAt: { bsonType: 'date' },
},
},
};Como usamos additionalProperties: false, _id precisa aparecer em
properties; o próprio MongoDB adiciona esse campo. initializeCatalog() cria
a coleção na primeira execução ou atualiza a regra com collMod nas próximas.
if (await db.listCollections({ name: 'products' }, { nameOnly: true }).hasNext()) {
await db.command({ collMod: 'products', validator: PRODUCT_VALIDATOR,
validationLevel: 'strict', validationAction: 'error' });
} else {
await db.createCollection('products', { validator: PRODUCT_VALIDATOR,
validationLevel: 'strict', validationAction: 'error' });
}Depois, criamos um índice único para o slug. Ele não acelera apenas a
consulta: unique: true impõe uma regra de unicidade. Se duas requisições
concorrentes tentarem reservar o mesmo slug, o banco decide qual vence. Fazer
apenas um findOne() antes do insert deixaria uma janela de corrida.
const products = db.collection<ProductDocument>('products');
await products.createIndex(
{ slug: 1 },
{ name: 'uq_products_slug', unique: true },
);
await products.createIndex(
{ active: 1, createdAt: -1 },
{ name: 'ix_products_active_created' },
);Para aprofundar a decisão, compare com a lição de índices no MongoDB. Índice melhora um caminho de leitura e cobra armazenamento e trabalho nas escritas; não é um adesivo para colocar em todo campo.
Implemente CRUD atrás de um repositório
ProductStore é o contrato que a camada HTTP conhece. MongoProductStore é a
implementação concreta com o driver. Isso não promete trocar de banco sem
custo; apenas evita espalhar collection.find() por todas as rotas.
export interface ProductStore {
ping(): Promise<void>;
create(input: CreateProductInput): Promise<ProductResponse>;
list(options: ListProductsOptions): Promise<ProductList>;
findById(id: ObjectId): Promise<ProductResponse | null>;
update(id: ObjectId, input: UpdateProductInput): Promise<ProductResponse | null>;
delete(id: ObjectId): Promise<boolean>;
reportByCategory(): Promise<CategoryReport[]>;
}No create, o servidor define as datas, insertOne() grava o documento e a
função serialize() prepara a resposta. O nome técnico serialização quer
dizer transformar os valores internos em uma forma que o cliente consegue
receber, neste caso JSON.
async create(input: CreateProductInput): Promise<ProductResponse> {
const now = new Date();
const document: ProductDocument = { ...input, createdAt: now, updatedAt: now };
const result = await this.collection.insertOne(document);
return serialize({ ...document, _id: result.insertedId });
}Read, update e delete delimitam o alvo pelo _id. findOneAndUpdate() devolve
o documento já alterado com returnDocument: 'after'; findOneAndDelete()
permite saber se existia algo para remover.
async update(id: ObjectId, input: UpdateProductInput) {
const document = await this.collection.findOneAndUpdate(
{ _id: id },
{ $set: { ...input, updatedAt: new Date() } },
{ returnDocument: 'after' },
);
return document ? serialize(document) : null;
}
async delete(id: ObjectId): Promise<boolean> {
return await this.collection.findOneAndDelete({ _id: id }) !== null;
}Esse é o mesmo vocabulário apresentado em CRUD com MongoDB e Node.js, agora aplicado a rotas, validação e códigos HTTP.
Adicione busca e paginação com limites
A rota aceita q, active, page e limit. A validação restringe limit a
50 e page a um inteiro positivo. Limitar não é apenas uma decisão visual: é
uma proteção contra respostas gigantes e consumo desnecessário de memória.
A busca escapa os caracteres especiais antes de criar a expressão regular.
Sem isso, uma entrada como .* mudaria o significado da consulta. Mesmo
escapada, busca por regex sem prefixo pode ficar cara em coleções grandes; para
um catálogo real, meça o plano e avalie recursos próprios de busca textual.
const filter: Filter<ProductDocument> = {};
if (options.active !== undefined) filter.active = options.active;
if (options.query) {
const pattern = escapeRegExp(options.query);
filter.$or = [
{ name: { $regex: pattern, $options: 'i' } },
{ description: { $regex: pattern, $options: 'i' } },
];
}
const skip = (options.page - 1) * options.limit;Consulta e contagem podem começar juntas com Promise.all(). A ordenação inclui
_id como desempate, o que evita ordem ambígua quando dois documentos têm a
mesma data. skip/limit é compreensível e suficiente para este laboratório; em
páginas muito profundas, paginação por cursor costuma escalar melhor.
const [documents, total] = await Promise.all([
this.collection.find(filter).sort({ createdAt: -1, _id: -1 })
.skip(skip).limit(options.limit).toArray(),
this.collection.countDocuments(filter),
]);
return { items: documents.map(serialize), page: options.page,
limit: options.limit, total, pages: total ? Math.ceil(total / options.limit) : 0 };Resuma categorias com um aggregation pipeline
Um aggregation pipeline é uma sequência de estágios: cada estágio recebe documentos, transforma ou reduz o conjunto e entrega o resultado ao próximo. Pense numa linha de separação de pedidos: primeiro ficam apenas os ativos, depois cada categoria de um produto vira uma unidade, e então unidades iguais são agrupadas. O limite é que isso acontece no mecanismo do banco, não numa esteira física e nem necessariamente documento por documento.
return this.collection.aggregate<CategoryReport>([
{ $match: { active: true } },
{ $unwind: '$categories' },
{ $group: {
_id: '$categories',
products: { $sum: 1 },
averagePriceCents: { $avg: '$priceCents' },
totalStock: { $sum: '$stock' },
} },
{ $project: { _id: 0, category: '$_id', products: 1,
averagePriceCents: { $round: ['$averagePriceCents', 0] }, totalStock: 1 } },
{ $sort: { products: -1, category: 1 } },
]).toArray();O exemplo calcula quantidade de produtos, preço médio em centavos e estoque total por categoria. A lição de aggregation pipeline explica como ler e testar cada estágio isoladamente.
Ligue as rotas e traduza erros para HTTP
As rotas chamam validação e repositório, mas não conhecem detalhes de índice ou
pipeline. Em Express 5, uma Promise rejeitada por um handler assíncrono é
encaminhada ao middleware de erro. Assim, throw dentro da rota chega ao mesmo
lugar sem um try/catch repetido em cada endpoint.
app.post('/products', async (request, response) => {
const product = await store.create(parseCreateProduct(request.body));
response.location(`/products/${product.id}`).status(201).json(product);
});
app.get('/products/:id', async (request, response) => {
const product = await store.findById(parseObjectId(request.params.id));
if (!product) throw new HttpError(404, 'Produto não encontrado.');
response.json(product);
});O mapa de erros tem quatro decisões: entrada inválida vira 400; recurso ausente,
404; colisão do índice, 409; falha não prevista, 500. Cada resposta recebe um
x-request-id. O servidor registra o erro inesperado com esse identificador,
mas não devolve stack trace, URI do banco ou mensagem interna ao cliente.
if (error instanceof HttpError) {
return response.status(error.status).json({ error: error.message,
...(error.details ? { details: error.details } : {}), requestId });
}
if (error instanceof MongoServerError && error.code === 11000) {
return response.status(409).json({
error: 'Já existe um produto com esse slug.', requestId,
});
}
logger.error(`[${requestId}]`, error);
return response.status(500).json({ error: 'Erro interno do servidor.', requestId });O servidor abre uma única conexão na inicialização, cria índices e regras e só
então começa a escutar. Nos sinais de encerramento, para de aceitar conexões e
fecha o MongoClient. Isso é graceful shutdown, ou encerramento coordenado.
const { client, db } = await connectDatabase(config.mongoUri, config.databaseName);
const app = createApp(new MongoProductStore(db.collection('products')));
const server = createServer(app);
server.listen(config.port, config.host);
process.once('SIGTERM', async () => {
await new Promise<void>((resolve, reject) =>
server.close((error) => error ? reject(error) : resolve()));
await client.close();
});Gere a imagem e execute a API
O Dockerfile separa dependências, compilação e runtime. A imagem final leva
somente dependências de produção e o JavaScript compilado; o processo roda como
o usuário não privilegiado node.
FROM node:24.19.0-bookworm-slim AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
COPY tests ./tests
COPY scripts ./scripts
RUN npm run build
FROM node:24.19.0-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist/src ./dist
COPY scripts ./scripts
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]Suba API e banco. Se a porta 3000 já estiver ocupada, use API_PORT=3019 como
abaixo; isso muda apenas a porta do computador, não a comunicação interna.
API_PORT=3019 docker compose up -d --build api
curl -sS http://127.0.0.1:3019/healthAgora crie um produto. O corpo usa centavos inteiros: 15990 representa R$
159,90. A resposta real abaixo mostra o ObjectId serializado e as datas
criadas pelo servidor.
curl -sS -X POST http://127.0.0.1:3019/products \
-H 'content-type: application/json' \
-d '{"name":"Suporte para notebook","slug":"suporte-notebook","description":"Suporte de alumínio para organizar a mesa.","priceCents":15990,"stock":12,"categories":["escritorio","acessorios"],"active":true}'Reproduza 400, 404 e 409 antes de corrigi-los
Um id curto não tem o formato de ObjectId. A rota recusa a entrada antes de
consultar o banco e devolve 400, que significa requisição inválida.
curl -sS -i http://127.0.0.1:3019/products/abcUm id bem formado pode não existir. Agora a requisição é válida, mas o recurso não foi encontrado; por isso o status correto muda para 404.
curl -sS -i http://127.0.0.1:3019/products/000000000000000000000000Repita o POST com slug: "suporte-notebook". O MongoDB acusa E11000 e código
11000; a API traduz a condição para 409 Conflict. O erro prova que a unicidade
está no banco, onde também protege requisições concorrentes.
curl -sS -i -X POST http://127.0.0.1:3019/products \
-H 'content-type: application/json' \
-d '{"name":"Suporte repetido","slug":"suporte-notebook","description":"Este produto repete o mesmo slug do anterior.","priceCents":1000,"stock":1,"categories":["acessorios"],"active":true}'Consulte, atualize, agregue e remova
Busca e paginação compõem a query string. O retorno inclui a página atual, o limite aplicado, o total e quantas páginas existem.
curl -sS 'http://127.0.0.1:3019/products?q=suporte&active=true&page=1&limit=5'Use o id devolvido pelo POST para alterar apenas o estoque. Depois, remova o
produto. 204 No Content não tem corpo: o próprio status confirma a operação.
curl -sS -X PATCH http://127.0.0.1:3019/products/6a8a60adc388121a9d996242 \
-H 'content-type: application/json' -d '{"stock":10}'
curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE \
http://127.0.0.1:3019/products/6a8a60adc388121a9d996242Com dois produtos de exemplo, o relatório agrupa categorias. Um produto pode
contribuir para mais de um grupo porque $unwind abre o array categories.
curl -sS http://127.0.0.1:3019/reports/categoriesEntenda Number, centavos e Decimal128
JavaScript number usa ponto flutuante binário. Alguns decimais comuns não têm
representação binária finita, então 0.1 + 0.2 não resulta exatamente em 0.3.
Além disso, inteiros deixam de ser todos representáveis depois de
Number.MAX_SAFE_INTEGER, que vale 9.007.199.254.740.991.
import { Decimal128 } from 'mongodb';
console.log(JSON.stringify({
binaryFloat: 0.1 + 0.2,
decimal128: Decimal128.fromString('0.30').toString(),
maxSafeInteger: Number.MAX_SAFE_INTEGER,
integersLosePrecisionAfterLimit:
Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2,
}));npm run money:demoNeste catálogo, priceCents é inteiro, tem teto de dois bilhões e portanto
fica muito abaixo do limite seguro. Essa estratégia funciona quando a menor
unidade é fixa. Decimal128 é um tipo BSON decimal de 128 bits, com 34 dígitos
decimais de precisão; ele é útil quando o domínio precisa preservar casas e
escala decimal. A aplicação deve converter sua entrada por string e definir
arredondamento, impostos e serialização. Trocar o tipo não decide essas regras
de negócio automaticamente.
Teste o banco, o HTTP e o erro 500
Os testes usam o runner nativo do Node e Supertest. Eles abrem um database
exclusivo, criam schema e índices, exercitam a aplicação sem reservar uma porta
e removem o database no after(). Um teste grava diretamente um documento
incompleto para provar que o MongoDB, não apenas a rota, devolve o código 121 de
falha de validação.
await assert.rejects(
db.collection('products').insertOne({ name: 'Documento incompleto' }),
(error: unknown) => error instanceof MongoServerError && error.code === 121,
);Para provocar 500 sem adicionar uma vulnerabilidade à API, o teste injeta um
ProductStore cuja listagem lança senha=nao-vazar. A resposta precisa ser
genérica e o teste confirma que esse detalhe não aparece no corpo.
const failingStore: ProductStore = {
ping: () => store.ping(),
create: (input) => store.create(input),
list: async () => { throw new Error('senha=nao-vazar'); },
findById: (id) => store.findById(id),
update: (id, input) => store.update(id, input),
delete: (id) => store.delete(id),
reportByCategory: () => store.reportByCategory(),
};
const response = await request(createApp(failingStore, { error() {} }))
.get('/products').expect(500);
assert.equal(response.body.error, 'Erro interno do servidor.');
assert.doesNotMatch(JSON.stringify(response.body), /senha/);Execute os testes dentro da imagem Node 24, ligados ao contêiner MongoDB. A saída abaixo é a execução real completa.
docker compose --profile test run --rm --build testO smoke test atravessa a imagem em execução pela rede: verifica saúde, cria, lista, atualiza, agrega, remove e confirma o 404 posterior. Ele testa menos casos que a suíte, mas prova que build, processo, porta e banco funcionam juntos.
API_URL=http://api:3000 docker compose --profile test run --rm test npm run test:smokePor fim, o audit examina a árvore travada no lockfile. Zero vulnerabilidades conhecidas não significa risco zero; significa apenas que o registro consultado não encontrou uma ocorrência conhecida nessa árvore e naquele momento.
docker compose --profile test run --rm test npm audit --audit-level=highO que ainda falta antes de produção
Este projeto é executável, mas não finge ser um deploy. O MongoDB local está
sem autenticação. O bind em 127.0.0.1 reduz exposição no computador, porém não
substitui controle de acesso. Em produção, habilite autenticação, crie um usuário
com menor privilégio, use TLS, mantenha o banco em rede privada, guarde segredos
fora do repositório e configure backups restauráveis.
Na API, acrescente autenticação e autorização, CORS conforme o cliente real, rate limiting, logs estruturados sem dados pessoais, métricas e rastreamento. Defina timeout, encerramento durante orquestração e limites de payload coerentes. O exemplo já usa JSON de 32 KB e oculta erros inesperados, mas esses controles são apenas uma parte da superfície.
Também revise índices com consultas reais e explain(). A busca com regex e a
paginação por skip foram escolhas pedagógicas; um catálogo grande pode pedir
busca dedicada e paginação por cursor. A
modelagem de documentos ajuda a decidir
quais dados pertencem ao mesmo agregado antes de otimizar as consultas.
Missão prática: adicione estoque baixo
Crie GET /reports/low-stock?limit=5. A rota deve aceitar um limite entre 1 e
20, listar apenas produtos ativos com stock menor ou igual a 5 e ordenar por
estoque crescente, depois por _id. Adicione um teste com produtos de estoque
0, 5 e 6.
const lowStock = await collection.find({ active: true, stock: { $lte: 5 } })
.sort({ stock: 1, _id: 1 })
.limit(limit)
.toArray();Seu critério de sucesso é objetivo: estoque 0 e 5 aparecem nessa ordem; estoque
6 não aparece; limit=0 devolve 400; a suíte continua com zero falhas. Depois,
explique qual índice atende essa consulta e use explain('executionStats') para
comparar antes e depois, em vez de assumir que o nome do índice prova ganho.
Quando terminar, remova somente os recursos deste laboratório. down -v
apaga o contêiner e o volume devclub-api-mongodb_mongo-data; portanto não use
esse comando num Compose que guarda dados importantes.
docker compose down -v --remove-orphansAgora você tem uma API pequena, mas com fronteiras que aparecem em aplicações reais: contrato HTTP, validação em runtime, regra no banco, índice único, paginação limitada, relatório agregado e testes que reproduzem os principais caminhos de erro. O próximo passo é fazer a missão, medir a consulta e então trocar uma escolha por vez, mantendo a saída observável como prova.
Perguntas frequentes
Preciso usar Mongoose para criar uma API com MongoDB?
Por que guardar o preço em centavos?
A validação da rota substitui a validação do MongoDB?
Por que um slug repetido responde 409?
Este Docker Compose está pronto para produção?
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, MongoDB Node.js Driver 7.5.0, MongoDB 8.0.28 e Docker 29.5.3, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Node.js — Previous Releases — nodejs.org
- TypeScript — Download — typescriptlang.org
- Express 5 — Error Handling — expressjs.com
- MongoDB — Node.js Driver — mongodb.com
- MongoDB — JSON Schema Validation — mongodb.com
- MongoDB — Unique Indexes — mongodb.com
- MongoDB Node.js Driver — Aggregation — mongodb.com
- MongoDB — Security Checklist — mongodb.com


