Api design

Escopo: transversal. Aplica-se a qualquer linguagem ou stack do projeto. Idiomas específicos em csharp/conventions/advanced/api-design.md e vbnet/conventions/advanced/api-design.md.

API (Application Programming Interface, Interface de Programação de Aplicações) é o contrato entre cliente e servidor. Um design bom padroniza quatro coisas: o pipeline de uma requisição, o contrato de entrada e saída, o shape (formato) da resposta e a semântica de verbos e status. Quando esses quatro pontos estão previsíveis, o cliente trata qualquer endpoint da mesma forma, e o servidor evolui sem quebrar integração.

Conceitos fundamentais

ConceitoO que é
BFF (Backend for Frontend, Backend para Frontend)Camada de borda que serve um cliente específico, traduz domínio em contrato de transporte e isola regras de UI do core
DTO (Data Transfer Object, Objeto de Transferência de Dados)Tipo dedicado ao contrato externo, distinto da entidade de domínio, usado para request e response
Envelope (envelope de resposta)Estrutura padrão { data, meta } que dá shape consistente a sucesso, erro, objeto único e coleção
Correlation ID (identificador de correlação)Id gerado na borda, propagado em meta e logs, que rastreia uma requisição ponta a ponta
Result (resultado)Tipo de domínio que carrega sucesso ou falha sem usar exceções; o controller traduz para HTTP no boundary
idempotency (operação repetível sem efeito adicional)Propriedade de uma operação que produz o mesmo estado quando repetida com os mesmos parâmetros

Pipeline de uma requisição

Toda requisição atravessa o mesmo caminho, do cliente até a persistência e de volta. O BFF é o boundary (limite) externo; o handler é o coração do caso de uso; o service concentra a lógica compartilhada; o repository isola o acesso a dados.

ClienteController thin → HandlerServiceRepositoryStorage
EnvelopeResult ← domínio ← entidade   ←

Cada camada tem uma responsabilidade única:

CamadaResponsabilidadeNão faz
ControllerExtrai input, chama handler, traduz Result em HTTP, monta envelopeRegra de negócio, acesso a banco
HandlerOrquestra o caso de uso, retorna Result com DTO de domínio ou de respostaConhecer HTTP, montar envelope
ServiceRegra de negócio, invariantes, coordenação entre repositóriosValidar input de transporte, falar HTTP
RepositoryLer e escrever no storage, devolver entidade ou primitivoRegra de negócio, tradução para contrato externo

A separação protege o domínio: o handler pode ser testado sem montar uma requisição HTTP (HyperText Transfer Protocol, Protocolo de Transferência de Hipertexto), o service pode ser reaproveitado por um job em background e o repository pode trocar de storage sem mexer no resto.

Para padrões de runtime além do pipeline síncrono (background jobs, webhooks, event-driven), veja Backend Flow.

BFF como boundary

O BFF (Backend for Frontend) é o único ponto que conhece HTTP. Qualquer coisa além dele, handler, service, repository, fala domínio. Isso vale mesmo quando o projeto não tem microsserviços: o BFF é uma disciplina de camadas, não um deploy separado.

O sinal de que o boundary foi respeitado é simples: se você renomeasse HttpContext para Envelope em todo o código e o handler continuasse funcionando, o boundary está no lugar.

❌ Ruim: controller com acesso a banco e regra de negócio
app.post('/api/orders', async (httpRequest, httpResponse) => {
  const { productId, quantity } = httpRequest.body;

  if (!productId) {
    return httpResponse.status(400).json({ message: 'Product required.' });
  }

  const product = await db.products.findById(productId);
  if (!product) {
    return httpResponse.status(404).json({ message: 'Product not found.' });
  }

  const total = product.price * quantity;
  const order = await db.orders.insert({ productId, quantity, total });

  return httpResponse.status(201).json(order);
});

Cada responsabilidade colada na próxima: o controller valida, lê banco, calcula e grava. Trocar o storage exige mexer no controller. Testar a regra de preço exige subir um servidor HTTP.

✅ Bom: controller fino, handler orquestra, service e repository isolados
// features/orders/ordersController.js
export function registerOrdersController(app, { createOrder }) {
  app.post('/api/orders', async (httpRequest, httpResponse) => {
    const result = await createOrder.handle(httpRequest.body);

    if (result.isFailure) {
      const badRequest = httpResponse.status(400).json({ message: result.error });
      return badRequest;
    }

    const apiResponse = buildEnvelope(result.value, httpRequest);
    const created = httpResponse.status(201).json(apiResponse);
    return created;
  });
}
// features/orders/createOrderHandler.js
export function createOrderHandler({ orderService }) {
  async function handle(request) {
    const serviceResult = await orderService.createOrder(request);

    if (serviceResult.isFailure) {
      const failure = Result.fail(serviceResult.error);
      return failure;
    }

    const order = serviceResult.value;
    const orderResponse = {
      id: order.id,
      productId: order.productId,
      quantity: order.quantity,
      total: order.total,
      createdAt: order.createdAt,
    };

    const success = Result.ok(orderResponse);
    return success;
  }

  return { handle };
}

O handler não conhece res, status ou headers. Testar a regra de criação não exige nenhum mock de HTTP.

Contrato de Request

DTOs de request definem o formato esperado do input. São tipos próprios da API, validados no boundary, nunca entidades de domínio reaproveitadas.

Dois sinais de um contrato de request saudável: campos com nome de domínio (productId, não product_id_str) e validação centralizada antes do handler receber o objeto.

❌ Ruim: objeto mutável montado ad-hoc, sem validação explícita
app.post('/api/orders', async (httpRequest, httpResponse) => {
  const request = httpRequest.body;
  request.quantity = parseInt(request.quantity);

  const order = await createOrder.handle(request);
  const response = httpResponse.status(201).json(order);

  return response;
});

O handler recebe o que vier no body. Campo faltando, tipo errado e formato inválido só aparecem depois, em runtime, com stack trace confuso.

✅ Bom: schema de validação no boundary, DTO tipado para o handler
// features/orders/orderRequest.js
import { z } from 'zod';

export const orderRequestSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive(),
});

export function parseOrderRequest(body) {
  const parsed = orderRequestSchema.safeParse(body);

  if (!parsed.success) {
    const validation = Result.fail(parsed.error.issues);
    return validation;
  }

  const request = Result.ok(parsed.data);
  return request;
}
app.post('/api/orders', async (httpRequest, httpResponse) => {
  const parsed = parseOrderRequest(httpRequest.body);

  if (parsed.isFailure) {
    const badRequest = httpResponse.status(400).json({ errors: parsed.error });
    return badRequest;
  }

  const result = await createOrder.handle(parsed.value);
  // ...
});

A validação acontece uma vez, na borda. O handler recebe um objeto já com tipos corretos e garante que qualquer objeto que chegue nele é válido.

Contrato de Response

Response DTO é o tipo público que o cliente conhece. A entidade de domínio é privada: ela tem invariantes, comportamentos e campos que não devem vazar (hash de senha, flags internas, ids de controle interno).

❌ Ruim: entidade de domínio retornada direto
async function handle(id) {
  const order = await orderService.findById(id);
  const success = Result.ok(order);

  return success;
}

Qualquer campo novo em Order vaza automaticamente para o cliente. O contrato externo cresce sem ninguém revisar.

✅ Bom: DTO de resposta explícito, montado a partir do domínio
async function handle(id) {
  const serviceResult = await orderService.findById(id);

  if (serviceResult.isFailure) {
    const failure = Result.fail(serviceResult.error);
    return failure;
  }

  const order = serviceResult.value;
  const orderResponse = {
    id: order.id,
    productId: order.productId,
    quantity: order.quantity,
    total: order.total,
    createdAt: order.createdAt,
  };

  const success = Result.ok(orderResponse);
  return success;
}

O DTO lista, um por um, os campos que fazem parte do contrato. Adicionar campo novo em Order não muda a resposta até que alguém decida expor.

Response Envelope

Respostas sem envelope têm shapes inconsistentes: sucesso retorna objeto nu, erro retorna string, coleção retorna array. Cada shape exige tratamento separado no cliente.

Um envelope { data, meta } garante contrato previsível. O campo meta carrega apenas o que ajuda na observabilidade e paginação, sem inflar o payload (corpo da mensagem). A montagem do envelope pertence ao Controller (boundary HTTP). O handler continua devolvendo Result com DTO de domínio.

CampoConteúdoQuando
dataDTO de resposta (objeto, array ou null em delete)Sempre presente em sucesso
meta.correlationIdId propagado nos logs para rastreamento ponta a pontaSempre
meta.requestedAtTimestamp ISO 8601 UTC da requisiçãoSempre
meta.pagination{ page, pageSize, total }Apenas em coleções paginadas
error.codeCódigo estável do erro (ex: ORDER_NOT_FOUND)Apenas em falha
error.messageMensagem legível, sem detalhes internosApenas em falha
error.detailsLista de issues de validaçãoApenas em 400 Bad Request
❌ Ruim: shapes inconsistentes entre sucesso e erro
// 200: { "id": "01HV...", "productId": "...", "quantity": 3 }
// 404: "Order not found."
// 400: { "field": "quantity", "problem": "must be positive" }

O cliente precisa de três parsers diferentes para três tipos de resposta do mesmo endpoint.

✅ Bom: envelope consistente em sucesso e erro
// shared/envelope.js
export function buildEnvelope(data, httpRequest) {
  const correlationId = httpRequest.headers['x-correlation-id'] ?? crypto.randomUUID();
  const meta = {
    correlationId,
    requestedAt: new Date().toISOString(),
  };

  const envelope = { data, meta };
  return envelope;
}

export function buildErrorEnvelope(code, message, httpRequest, details) {
  const correlationId = httpRequest.headers['x-correlation-id'] ?? crypto.randomUUID();
  const error = { code, message };
  if (details) error.details = details;

  const meta = {
    correlationId,
    requestedAt: new Date().toISOString(),
  };

  const envelope = { error, meta };
  return envelope;
}
// 200: { "data": { "id": "01HV...", ... }, "meta": { "correlationId": "abc-123", "requestedAt": "2026-04-23T14:32:00Z" } }
// 404: { "error": { "code": "ORDER_NOT_FOUND", "message": "Order not found." }, "meta": { ... } }
// 400: { "error": { "code": "INVALID_INPUT", "message": "Validation failed.", "details": [...] }, "meta": { ... } }

O correlationId em meta é o mesmo propagado nos logs da requisição. Veja Correlation ID para o fluxo completo.

Verbos REST e rotas

REST (Representational State Transfer, Transferência de Estado Representacional) usa verbos HTTP com semântica definida. O mesmo verbo deve significar a mesma coisa em qualquer endpoint.

VerboSemânticaIdempotenteExemplo
GETLeitura sem efeito colateralSimGET /api/orders, GET /api/orders/{id}
POSTCriação de recursoNãoPOST /api/orders
PUTSubstituição completaSimPUT /api/orders/{id}
PATCHAtualização parcialNãoPATCH /api/orders/{id}
DELETERemoçãoSimDELETE /api/orders/{id}

Convenções de rota:

  • Kebab-case na URL (Uniform Resource Locator, Localizador Uniforme de Recurso): /api/order-items, não /api/orderItems
  • Plural para coleções: /api/orders, não /api/order
  • Sem verbo na URL: POST /api/orders, não POST /api/create-order
  • Recurso aninhado quando há relação clara: /api/orders/{id}/items
  • Query string para filtro e paginação: /api/orders?status=pending&page=2

Verbos customizados (/cancel, /approve) entram como sub-recurso de ação quando a operação não se encaixa nos cinco verbos padrão: POST /api/orders/{id}/cancel.

Status codes

Status code é o primeiro nível de contrato: antes de ler o body, o cliente já sabe se a requisição deu certo, se o erro é dele ou do servidor, e se vale tentar de novo.

StatusQuando usar
200 OKLeitura ou operação bem-sucedida com corpo de resposta
201 CreatedRecurso criado; incluir id ou header Location
202 AcceptedAceito para processamento assíncrono; cliente consulta depois
204 No ContentOperação bem-sucedida sem corpo (ex: DELETE, PUT sem retorno)
400 Bad RequestInput inválido: JSON malformado, campo faltando, tipo errado
401 UnauthorizedNão autenticado, credencial ausente ou inválida
403 ForbiddenAutenticado, mas sem permissão para o recurso
404 Not FoundRecurso não encontrado
409 ConflictEstado incompatível: duplicata, versão obsoleta
422 Unprocessable EntityInput válido, mas regra de negócio violada
429 Too Many RequestsRate limit atingido
500 Internal Server ErrorFalha inesperada; nunca expor detalhes ao cliente

A distinção entre 400 e 422 é sutil mas útil: 400 é erro de forma (o servidor não entendeu), 422 é erro de regra (o servidor entendeu, mas rejeitou). Cliente com validação local evita 400; 422 sempre vem do servidor.

Result para HTTP no boundary

O handler devolve Result (tipo de domínio com sucesso ou falha). O controller traduz para HTTP. Essa tradução acontece em um único lugar, perto da porta, para que a regra de mapeamento fique visível e não espalhada pelo handler.

❌ Ruim: handler constrói resposta HTTP, mistura domínio e transporte
async function handle(id, res) {
  const order = await orderService.findById(id);
  if (!order) {
    return httpResponse.status(404).json({ error: 'Not found' });
  }

  return httpResponse.status(200).json(order);
}

Handler (manipulador) acoplado a res. Não dá para reaproveitar em um worker (trabalhador) que lê da fila e não tem res.

✅ Bom: handler retorna Result, controller traduz no boundary
// features/orders/findOrderByIdHandler.js
export function findOrderByIdHandler({ orderService }) {
  async function handle(id) {
    const serviceResult = await orderService.findById(id);

    if (serviceResult.isFailure) {
      const failure = Result.fail(serviceResult.error);
      return failure;
    }

    const order = serviceResult.value;
    const orderResponse = {
      id: order.id,
      productId: order.productId,
      quantity: order.quantity,
      total: order.total,
      createdAt: order.createdAt,
    };

    const success = Result.ok(orderResponse);
    return success;
  }

  return { handle };
}
// features/orders/ordersController.js
app.get('/api/orders/:id', async (httpRequest, httpResponse) => {
  const result = await findOrderById.handle(httpRequest.params.id);

  if (result.isFailure) {
    const httpStatus = mapErrorToStatus(result.error);
    const envelope = buildErrorEnvelope(result.error.code, result.error.message, httpRequest);
    const errorResponse = httpResponse.status(httpStatus).json(envelope);
    return errorResponse;
  }

  const envelope = buildEnvelope(result.value, httpRequest);
  const okResponse = httpResponse.status(200).json(envelope);
  return okResponse;
});
// shared/errorMapping.js
const errorStatusByCode = {
  ORDER_NOT_FOUND: 404,
  ORDER_ALREADY_CANCELLED: 409,
  INVALID_INPUT: 400,
  RULE_VIOLATION: 422,
};

export function mapErrorToStatus(error) {
  const status = errorStatusByCode[error.code] ?? 500;
  return status;
}

O handler volta para ser testável como função pura de domínio. A tabela de mapeamento fica em um só lugar, versionada e auditável.

  • Backend Flow: jobs, webhooks, event-driven além do pipeline síncrono
  • Observability: correlationId, logs estruturados, níveis
  • Security: autenticação, autorização e blindagem de cookies no boundary
  • Integrations: contratos com sistemas externos (GraphQL, XML/SOAP, HMAC)
  • Messaging: filas, DLQ e entrega quando a API dispara trabalho assíncrono
  • C# API Design: Minimal API, TypedResults, [AsParameters]
  • VB.NET API Design: Web API 2, roteamento por atributo, async sem deadlock

Desenvolvido por @thiagocajadev · Fork baseado no repositório pmndrs/docs · Poimandres.