Api design

Escopo: C#. Idiomas .NET deste arquivo. SSOT do pipeline, envelope, verbos, status codes e Result → HTTP: shared/platform/api-design.md.

O ASP.NET Core oferece dois jeitos de escrever uma API (Application Programming Interface · Interface de Programação de Aplicações): a Minimal API, que declara cada rota como uma função, e os Controllers, que herdam do pipeline de MVC (Model-View-Controller · Modelo-Visão-Controlador). Este guia adota a Minimal API, com o retorno tipado que traduz o Result do domínio em resposta HTTP. As regras que valem para qualquer linguagem (verbos, status, envelope) ficam no documento transversal; aqui está o que muda em C#.

Conceitos fundamentais

ConceitoO que é
API (Application Programming Interface · Interface de Programação de Aplicações)Contrato de comunicação entre serviços, tipicamente via HTTP
REST (Representational State Transfer · Transferência de Estado Representacional)Estilo arquitetural que usa verbos HTTP sobre recursos identificados por URL
HTTP (HyperText Transfer Protocol · Protocolo de Transferência de Hipertexto)Protocolo da web: verbos, status codes, headers, corpo
MVC (Model-View-Controller, Modelo-Visão-Controlador)Padrão que separa dados, apresentação e controle; em ASP.NET, o pipeline de Controllers
CQS (Command-Query Separation, Separação Comando-Consulta)Princípio: um método ou altera estado (command) ou retorna dado (query), nunca os dois
SSOT (Single Source of Truth · Fonte Única da Verdade)Um único lugar canônico para cada regra ou contrato; cross-links apontam para ele

Minimal API como padrão

A Minimal API combina com a organização por funcionalidade: tudo o que diz respeito a pedidos (rotas, handlers, DTOs, serviço) mora na pasta Features/Orders, em vez de espalhado entre uma pasta de controllers, uma de services e uma de models.

Uma rota sem dependência nenhuma cabe numa lambda:

app.MapGet("/health", () => TypedResults.Ok());

Quando a rota tem regra de negócio e precisa de serviços, escreva um handler (classe que atende a rota) por operação. As dependências entram pelo construtor, e o método recebe só o que veio na requisição:

Features/
└── Orders/
    ├── OrdersExtensions.cs      ← registro + mapeamento de rotas
    ├── CreateOrderHandler.cs    ← um handler por operação
    ├── FindOrderByIdHandler.cs
    ├── FindOrdersHandler.cs
    ├── OrderService.cs          ← lógica compartilhada entre handlers
    ├── OrderRequest.csDTO de entrada
    └── OrderResponse.csDTO de saída
❌ Ruim: lógica de negócio inline na rota
// ❌ rota grossa: DbContext injetado direto, regra de negócio inline, sem repository,
//    sem service, sem TypedResults: tudo junto na lambda
group.MapPost("/", async (OrderRequest request, AppDbContext db, CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(request.ProductId))
        return Results.BadRequest("Product ID required.");

    var product = await db.Products.FindAsync(request.ProductId, ct);
    if (product is null)
        return Results.NotFound("Product not found.");

    var order = new Order(request.ProductId, request.Quantity, product.Price * request.Quantity);

    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);

    return Results.Created($"/api/orders/{order.Id}", order);
});
❌ Ruim: handler que busca dependências via service locator
// Features/Orders/CreateOrderHandler.cs
public class CreateOrderHandler
{
    public async Task<IResult> HandleAsync(OrderRequest request, IServiceProvider services, CancellationToken ct)
    {
        // dependências resolvidas manualmente dentro do handler
        var orderService = services.GetRequiredService<OrderService>();
        var result = await orderService.CreateOrderAsync(request, ct);
        if (result.IsFailure)
            return Results.BadRequest(result.Error);

        var createdOrder = result.Value!;
        return Results.Created($"/api/orders/{createdOrder.Id}", createdOrder);
    }
}
✅ Bom: rotas mapeadas no extension method, handler injetado
// Features/Orders/OrdersExtensions.cs
public static class OrdersExtensions
{
    public static WebApplicationBuilder AddOrders(this WebApplicationBuilder builder)
    {
        // handlers registrados como Scoped: o container os injeta nas rotas automaticamente
        builder.Services.AddScoped<FindOrdersHandler>();
        builder.Services.AddScoped<FindOrderByIdHandler>();

        builder.Services.AddScoped<CreateOrderHandler>();
        builder.Services.AddScoped<OrderService>();

        return builder;
    }

    public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders").WithTags("Orders");

        group.MapGet("/", (FindOrdersHandler handler, CancellationToken ct)
            => handler.HandleAsync(ct));

        group.MapGet("/{id:guid}", (Guid id, FindOrderByIdHandler handler, CancellationToken ct)
            => handler.HandleAsync(id, ct));

        group.MapPost("/", (OrderRequest request, CreateOrderHandler handler, CancellationToken ct)
            => handler.HandleAsync(request, ct));

        return app;
    }
}
✅ Bom: handler com dependências no construtor, request como parâmetro
// Features/Orders/CreateOrderHandler.cs
public class CreateOrderHandler(OrderService orderService)
{
    public async Task<IResult> HandleAsync(OrderRequest request, CancellationToken ct)
    {
        var result = await orderService.CreateOrderAsync(request, ct);
        if (result.IsFailure)
            return TypedResults.BadRequest(result.Error);

        var createdOrder = result.Value!;
        var orderLocation = $"/api/orders/{createdOrder.Id}";
        return TypedResults.Created(orderLocation, createdOrder);
    }
}

Agrupar os parâmetros do handler

Um handler que precisa do request, de dois repositórios, de um leitor e do token de cancelamento fica com cinco parâmetros, e a lista cresce a cada dependência nova. [AsParameters] deixa juntar todos num record de contexto. O framework olha dentro do record e resolve cada propriedade pelo container, como se fossem parâmetros soltos, e o handler passa a receber um só.

❌ Ruim: assinatura longa, dependências espalhadas no handler
// ❌ cada dependência é um parâmetro avulso: cresce a cada nova injeção
app.MapPost("/orders", async (
    OrderCreateRequest request,
    IOrderRepository repository,
    IOrderBusinessRules businessRules,
    IOrderReader reader,
    CancellationToken cancellationToken) =>
{
    // ...
});
✅ Bom: context record agrupa dependências, handler recebe um parâmetro
// Features/Orders/OrderContexts.cs
public sealed record OrderCreateContext(
    OrderCreateRequest Request,
    IOrderRepository Repository,
    IOrderBusinessRules BusinessRules,
    IOrderReader Reader,
    CancellationToken CancellationToken);
// Features/Orders/Create.cs: handler recebe um parâmetro; DI resolve cada propriedade
public static async Task<OrderCreateResult> Handle(
    [AsParameters] OrderCreateContext context)
{
    var (request, repository, businessRules, reader, cancellationToken) = context;

    // ...
}

Controller em projetos MVC

Controllers continuam fazendo sentido no projeto que já os usa ou que depende de recursos do MVC, como filtros globais e model binding por atributo. A regra é a mesma da Minimal API: o controller recebe a requisição, chama o serviço e traduz o resultado em resposta. Regra de negócio, cálculo e acesso ao banco ficam de fora dele.

❌ Ruim: controller com lógica de negócio
// ❌ controller gordo: DbContext no construtor, cálculo de preço na lambda,
//    interpolação no return. Mesma regra que Minimal API, diferente só na sintaxe
[ApiController]
[Route("api/orders")]
public class OrdersController(AppDbContext db) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Create(OrderRequest request, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(request.ProductId))
            return BadRequest("Product ID required.");

        var product = await db.Products.FindAsync(request.ProductId, ct);
        if (product is null)
            return NotFound();

        var order = new Order(request.ProductId, request.Quantity, product.Price * request.Quantity);

        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);

        return Created($"/api/orders/{order.Id}", order);
    }
}
✅ Bom: controller thin, delega para o service
[ApiController]
[Route("api/orders")]
public class OrdersController(OrderService orderService) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Create(OrderRequest request, CancellationToken ct)
    {
        var result = await orderService.CreateOrderAsync(request, ct);
        if (result.IsFailure)
            return BadRequest(result.Error);

        var createdOrder = result.Value!;
        var orderLocation = $"/api/orders/{createdOrder.Id}";
        var response = Created(orderLocation, createdOrder);
        return response;
    }
}

TypedResults e Results

A Minimal API tem duas famílias de retorno. Results é a mais antiga, TypedResults chegou no .NET 7. As duas produzem a mesma resposta HTTP; o que muda é o que o compilador enxerga.

Results.Ok(order) devolve IResult, um tipo que não guarda informação sobre o conteúdo. O Swagger não consegue descobrir o status nem o formato do payload (corpo da mensagem), e você precisa declarar isso à mão com [ProducesResponseType]. O teste precisa converter o resultado antes de ler.

TypedResults.Ok(order) devolve Ok<Order>, um tipo concreto. O Swagger lê dali o status 200 e o formato Order sem ajuda. O teste acessa result.Value direto. A assinatura do handler passa a declarar o que ele responde.

Quando usar qual

ContextoPreferir
Minimal API, endpoint novoTypedResults.*
Endpoint com múltiplos status (ex: 200 ou 404)Results<Ok<T>, NotFound> como tipo de retorno
MVC Controller (ControllerBase)métodos da base class (Ok(), NotFound(), Created(...)), não Results.*
Código legado usando Results.*manter até a próxima refatoração natural

Assinatura que enumera os status possíveis

O tipo Results<,> lista na própria assinatura todos os retornos que o handler pode dar. O Swagger documenta cada um, e o compilador recusa um retorno que não esteja na lista.

❌ Ruim: Results apaga o tipo de retorno
// ❌ IResult é opaco: Swagger não sabe se é 200 ou 404 sem [ProducesResponseType]
app.MapGet("/orders/{id}", async (Guid id, OrderService orderService, CancellationToken ct) =>
{
    var result = await orderService.FindByIdAsync(id, ct);
    if (result.IsFailure) return Results.NotFound();

    var response = OrderResponse.From(result.Value!);
    return Results.Ok(response);
});
✅ Bom: TypedResults + union type na assinatura
app.MapGet("/orders/{id}", FindOrder);

static async Task<Results<Ok<OrderResponse>, NotFound>> FindOrder(
    Guid id, OrderService orderService, CancellationToken ct)
{
    var result = await orderService.FindByIdAsync(id, ct);
    if (result.IsFailure) return TypedResults.NotFound();

    var response = OrderResponse.From(result.Value!);
    return TypedResults.Ok(response);
}

Header Location sem lógica no return

TypedResults.Created recebe a URL (Uniform Resource Locator · Localizador Uniforme de Recurso) do recurso criado, que vai no header Location. Monte essa URL numa variável com nome antes da última linha, e deixe o return só entregando o resultado.

❌ Ruim: interpolação no return
// ❌ URL construída na linha do return: lógica inline, difícil inspecionar no debugger
return TypedResults.Created($"/api/orders/{createdOrder.Id}", createdOrder);
✅ Bom: URL em variável nomeada
var orderLocation = $"/api/orders/{createdOrder.Id}";
var response = TypedResults.Created(orderLocation, createdOrder);
return response;

Apelido para a assinatura de retorno

Enumerar os status na assinatura deixa o contrato explícito e produz um tipo comprido, com o namespace inteiro repetido a cada handler. Um global using batiza esse tipo uma vez e vale para o assembly todo. O handler passa a declarar Task<OrderCreateResult>, e o Swagger continua enxergando cada status que o apelido representa.

❌ Ruim: tipo union verboso repetido em cada handler
// ❌ namespaces completos repetidos a cada handler que retorna esse tipo
public static async Task<Results<
    Created<OrderResponse>,
    BadRequest<string>,
    ProblemHttpResult>> Handle([AsParameters] OrderCreateContext context)
{
    // ...
}
✅ Bom: alias declarado uma vez, handler usa nome semântico
// Features/Orders/OrderAliases.cs: um arquivo por feature, uma linha por status possível
global using OrderCreateResult = Microsoft.AspNetCore.Http.HttpResults.Results<
    Microsoft.AspNetCore.Http.HttpResults.Created<Features.Orders.OrderResponse>,
    Microsoft.AspNetCore.Http.HttpResults.BadRequest<string>,
    Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult>;

global using OrderGetResult = Microsoft.AspNetCore.Http.HttpResults.Results<
    Microsoft.AspNetCore.Http.HttpResults.Ok<Features.Orders.OrderResponse>,
    Microsoft.AspNetCore.Http.HttpResults.NotFound>;
// handler: tipo de retorno expressivo, sem repetição de namespace
public static async Task<OrderCreateResult> Handle(
    [AsParameters] OrderCreateContext context)
{
    // ...
}

O comando grava e a consulta lê

SaveAsync grava e devolve void. Quando ele também devolve a entidade salva, o método passa a fazer as duas coisas, e quem lê a chamada não sabe mais se aquele valor veio do banco ou do objeto que acabou de ser enviado. Esse é o princípio CQS (Command-Query Separation · Separação Comando-Consulta). Depois de gravar, um IOrderReader lê o registro, e aí o dado devolvido é o que está no banco de verdade, com os campos que ele mesmo preencheu.

❌ Ruim: SaveAsync retorna entidade (CQS violado)
// ❌ salva e retorna: command e query no mesmo método
var saved = await repository.SaveAsync(order, cancellationToken);

return TypedResults.Created($"/orders/{saved.Id}", saved);
✅ Bom: SaveAsync void, IOrderReader separado para leitura
// IOrderRepository: contrato de persistência (command)
public interface IOrderRepository
{
    Task SaveAsync(Order order, CancellationToken cancellationToken);
}

// IOrderReader: contrato de leitura (query)
public interface IOrderReader
{
    Task<Result<Order>> FindByIdAsync(Guid id, CancellationToken cancellationToken);
}
// no handler: save void, read com interface separada
await repository.SaveAsync(order, cancellationToken);

var saved = await reader.FindByIdAsync(order.Id, cancellationToken);
if (!saved.IsSuccess)
    return TypedResults.Problem(saved.Error!.Message);

var orderLocation = $"/orders/{order.Id}";
var orderResponse = OrderResponseFilterOutput.Apply(saved.Value!);

var response = TypedResults.Created(orderLocation, orderResponse);
return response;

Contrato, envelope, verbos e status codes

O desenho do pipeline, os DTOs de request e response, o envelope { data, meta }, os verbos REST, os status codes e a tradução de Result para HTTP valem para qualquer linguagem, e a fonte deles é shared/platform/api-design.md.

Em C#, esses contratos viram record com required init, que obriga o preenchimento na criação e impede a alteração depois:

public record OrderRequest(string ProductId, int Quantity);

public record OrderResponse
{
    public required Guid Id { get; init; }
    public required string ProductId { get; init; }
    public required int Quantity { get; init; }
    public required decimal Total { get; init; }
    public required DateTimeOffset CreatedAt { get; init; }
}

public record ApiMeta
{
    public required string CorrelationId { get; init; }
    public required DateTimeOffset RequestedAt { get; init; }
}

public record ApiResponse<T>
{
    public required T Data { get; init; }
    public required ApiMeta Meta { get; init; }
}

A tradução de Result para HTTP acontece no handler ou numa extensão sobre o Result, com TypedResults (veja TypedResults e Results).

Versionamento, o verbo QUERY e o formato de erro Problem Details também são agnósticos e vivem na SSOT. Do lado do ASP.NET Core: o versionamento de rota usa o pacote Asp.Versioning.Http, que serve /api/v1 e /api/v2 lado a lado; um verbo fora do conjunto padrão entra por app.MapMethods("/reports", ["QUERY"], ...); e o corpo de erro no formato Problem Details sai pronto de TypedResults.Problem(...), que já preenche type, title, status e detail.

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.