Vertical slice

Numa fatia vertical (vertical slice), tudo o que uma funcionalidade precisa mora na mesma pasta: as rotas, os contratos, as regras de negócio e o acesso ao banco. A pasta Features/Orders guarda a feature de pedidos inteira, e mexer nela não obriga a abrir mais nada. Isso substitui a divisão por camadas, em que alterar um campo do pedido significa editar um arquivo na pasta de controllers, outro na de services e outro na de repositories.

Este documento acompanha uma requisição do começo ao fim, do Program.cs até a resposta.

Conceitos fundamentais

ConceitoO que é
Vertical Slice (fatia vertical)Organização por feature onde rota, contrato, regra e acesso a dados ficam colocalizados
DTO (Data Transfer Object · Objeto de Transferência de Dados)Contrato de entrada ou saída da feature; não muda depois de criado
CQS (Command-Query Separation, Separação Comando-Consulta)Handler altera estado OU retorna dado, nunca os dois
HTTP (HyperText Transfer Protocol · Protocolo de Transferência de Hipertexto)Protocolo da fatia: verbo, status, envelope no boundary
I/O (Input/Output · Entrada/Saída)Operação que atravessa o limite do processo: banco, rede, arquivo; sempre assíncrona

Onde cada arquivo mora

src/
├── Program.cs
├── Features/
│   └── Orders/
│       ├── OrdersModule.csDI + registro de rotas
│       ├── OrderAliases.cs          ← global using aliases de retorno
│       ├── OrderContracts.cs        ← request e response records
│       ├── OrderContexts.cs         ← context records para [AsParameters]
│       ├── OrderCreateSanitizer.cs  ← normaliza entrada (puro, sem I/O)
│       ├── OrderCreateValidator.cs  ← valida input (puro, sem I/O)
│       ├── IOrderBusinessRules.cs   ← contrato de regras de negócio
│       ├── OrderBusinessRules.cs    ← regras de negócio (com I/O)
│       ├── IOrderRepository.cs      ← contrato de persistência
│       ├── IOrderReader.cs          ← contrato de leitura
│       ├── OrderResponseFilterOutput.cs   ← formata resposta (puro, sem I/O)
│       ├── Create.cs                ← handler
│       └── GetById.cs               ← handler
├── Infrastructure/
│   └── Extensions/
│       ├── ServiceExtensions.csAddDefaults
│       ├── AppExtensions.csUseDefaults
│       └── ModuleExtensions.csRegisterModules + MapModules
└── Shared/
    ├── IModule.cs
    ├── Result.cs
    ├── ResultExtensions.cs
    └── Filters/
        └── ValidationFilter.cs

O caminho da requisição

Toda requisição percorre a mesma sequência, e os nomes abaixo são as classes que você vai encontrar no código:

HTTP Request → ValidationFilter → [AsParameters] → Sanitize → Validate → Business Rules → Save → Read → Filter Output → TypedResults

EtapaPapelI/OFalha
ValidationFilterRejeita body nulo antes do handler-400
[AsParameters]DI resolve o context record--
1. SanitizeNormaliza o inputpuro-
2. ValidateRegras de input, retorna Result<T>puro400
3. Business RulesRegras de domínio, retorna Result<bool>com I/O404 / 409
4. SavePersiste, void (CQS)com I/O-
5. ReadBusca o que foi salvo, Result<Order>com I/O500
6. Filter OutputProjeta OrderResponsepuro-
TypedResultsResposta final-201 / 200 / 400 / 404 / 500

1. Ponto de entrada

O Program.cs cabe em quatro linhas porque não configura nada: ele chama quem configura.

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.AddDefaults();

var app = builder.Build();
app.UseDefaults();

app.Run();

AddDefaults registra a infraestrutura e chama RegisterModules, que varre o assembly em busca de classes que implementam IModule e registra os serviços de cada uma. Uma fatia nova é descoberta por existir.

// Infrastructure/Extensions/ServiceExtensions.cs
public static class ServiceExtensions
{
    public static WebApplicationBuilder AddDefaults(this WebApplicationBuilder builder)
    {
        builder.Services.AddProblemDetails(options =>
        {
            options.CustomizeProblemDetails = context =>
            {
                context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
                context.ProblemDetails.Extensions["timestamp"] = DateTimeOffset.UtcNow;
            };
        });

        builder.Services.AddAuthentication();
        builder.Services.AddAuthorization();

        builder.RegisterModules();

        return builder;
    }
}

UseDefaults configura o pipeline e chama MapModules, que mapeia as rotas de todos os módulos.

// Infrastructure/Extensions/AppExtensions.cs
public static class AppExtensions
{
    public static WebApplication UseDefaults(this WebApplication app)
    {
        app.UseExceptionHandler();
        app.UseHttpsRedirection();
        app.UseAuthentication();
        app.UseAuthorization();
        app.MapModules();

        return app;
    }
}

2. Cada fatia se registra sozinha

A fatia implementa IModule, que pede duas coisas: registrar os serviços dela e mapear as rotas dela. O ModuleExtensions encontra todas as implementações no assembly e chama os dois métodos de cada uma. Ninguém precisa lembrar de adicionar a feature nova a uma lista central, e por isso ninguém esquece.

// Shared/IModule.cs
public interface IModule
{
    IServiceCollection RegisterServices(IServiceCollection services);
    IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder app);
}
// Infrastructure/Extensions/ModuleExtensions.cs
public static class ModuleExtensions
{
    private static readonly IReadOnlyList<IModule> Modules = Assembly
        .GetExecutingAssembly()
        .GetTypes()
        .Where(type => typeof(IModule).IsAssignableFrom(type) && !type.IsInterface)
        .Select(Activator.CreateInstance)
        .Cast<IModule>()
        .ToList();

    public static WebApplicationBuilder RegisterModules(this WebApplicationBuilder builder)
    {
        foreach (var module in Modules)
            module.RegisterServices(builder.Services);

        return builder;
    }

    public static WebApplication MapModules(this WebApplication app)
    {
        foreach (var module in Modules)
            module.MapEndpoints(app);

        return app;
    }
}

OrdersModule registra os serviços da fatia e mapeia as rotas com seus filtros:

// Features/Orders/OrdersModule.cs
namespace Features.Orders;

public sealed class OrdersModule : IModule
{
    public IServiceCollection RegisterServices(IServiceCollection services)
    {
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IOrderBusinessRules, OrderBusinessRules>();
        services.AddScoped<IOrderReader, OrderReader>();

        return services;
    }

    public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", Create.Handle)
           .WithTags("Orders")
           .WithSummary("Places a new order")
           .AddEndpointFilter<ValidationFilter<OrderCreateRequest>>();

        app.MapGet("/orders/{id:guid}", GetById.Handle)
           .WithTags("Orders")
           .WithSummary("Returns an order by id");

        return app;
    }
}

3. Contratos: request, response e apelidos

OrderContracts.cs guarda os tipos que entram e saem da fatia. OrderAliases.cs batiza os tipos de retorno HTTP com um global using: uma linha por conjunto de status possíveis, válida no assembly inteiro, para que a assinatura do handler não carregue o namespace completo.

// Features/Orders/OrderContracts.cs
namespace Features.Orders;

public sealed record OrderCreateRequest(
    Guid CustomerId,
    IReadOnlyList<OrderItemRequest> Items);

public sealed record OrderItemRequest(Guid ProductId, int Quantity);

public sealed record OrderResponse(
    Guid Id,
    Guid CustomerId,
    IReadOnlyList<OrderItemRequest> Items,
    DateTimeOffset CreatedAt);
// Features/Orders/OrderAliases.cs
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>;

4. Entrada da requisição: filtro e contexto

Antes do handler, a requisição passa pelo ValidationFilter. Corpo vazio vira 400 ali mesmo, e o handler nem chega a rodar. Isso poupa o handler de começar todo método verificando se o que chegou existe.

// Shared/Filters/ValidationFilter.cs
public sealed class ValidationFilter<TRequest> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments.OfType<TRequest>().FirstOrDefault();

        if (request is null)
            return TypedResults.BadRequest("Request body is required.");

        return await next(context);
    }
}

O handler recebe um parâmetro só, marcado com [AsParameters]. O framework olha dentro do record e resolve cada propriedade pelo container, como se fossem parâmetros soltos. A assinatura fica curta e as dependências continuam explícitas.

// Features/Orders/OrderContexts.cs
namespace Features.Orders;

public sealed record OrderCreateContext(
    OrderCreateRequest Request,
    IOrderRepository Repository,
    IOrderBusinessRules BusinessRules,
    IOrderReader Reader,
    CancellationToken CancellationToken);

public sealed record OrderGetContext(
    Guid Id,
    IOrderReader Reader,
    CancellationToken CancellationToken);

5. Os seis passos do handler

Todo handler executa esta sequência, na íntegra e nesta ordem. A ordem existe por dependência: validar antes de limpar deixa passar um dado que a limpeza consertaria, e consultar o banco antes de validar gasta uma ida ao banco para descobrir que o campo estava vazio.

1. Sanitize       → normaliza entrada            (puro, sem I/O, classe estática)
2. Validate       → regras de input              (puro, sem I/O, classe estática)
3. BusinessRules  → regras de domínio            (com I/O, interface injetada)
4. Savepersiste (void, CQS)         (comando, sem retorno de dados)
5. Read           → busca o que foi salvo        (query, interface separada)
6. FilterOutput   → formata a resposta           (puro, sem I/O, classe estática)

5.1 Sanitize

Limpa a entrada antes de qualquer verificação: aqui, descarta os itens com quantidade zero. É uma função pura, sem acesso ao banco, então dá para testá-la passando um objeto e conferindo o resultado.

// Features/Orders/OrderCreateSanitizer.cs
namespace Features.Orders;

public static class OrderCreateSanitizer
{
    public static OrderCreateRequest Sanitize(OrderCreateRequest request) =>
        request with
        {
            Items = request.Items
                .Where(item => item.Quantity > 0)
                .ToList()
        };
}

5.2 Validate

Confere o formato do que chegou, sem consultar nada: cliente preenchido, pelo menos um item. Devolve Result<T>, com Fail e um código de erro em cada saída antecipada. O sucesso aproveita o implicit operator e devolve o próprio request.

// Features/Orders/OrderCreateValidator.cs
namespace Features.Orders;

public static class OrderCreateValidator
{
    public static Result<OrderCreateRequest> Validate(OrderCreateRequest request)
    {
        if (request.CustomerId == Guid.Empty)
            return Result<OrderCreateRequest>.Fail("Customer id is required.", "INVALID_CUSTOMER_ID");

        if (!request.Items.Any())
            return Result<OrderCreateRequest>.Fail("Order must have at least one item.", "INVALID_ITEMS");

        return request;
    }
}

5.3 Business Rules

Aqui ficam as perguntas que só o banco responde: o cliente existe? o pedido repete o mesmo produto? Esta etapa é separada do validator justamente porque acessa o repositório. Ela entra por interface, então o teste passa uma implementação em memória e continua rápido.

// Features/Orders/IOrderBusinessRules.cs
namespace Features.Orders;

public interface IOrderBusinessRules
{
    Task<Result<bool>> ValidateAsync(OrderCreateRequest request, CancellationToken cancellationToken);
}
// Features/Orders/OrderBusinessRules.cs
namespace Features.Orders;

public sealed class OrderBusinessRules(IOrderRepository repository) : IOrderBusinessRules
{
    public async Task<Result<bool>> ValidateAsync(
        OrderCreateRequest request,
        CancellationToken cancellationToken)
    {
        var customerExists = await repository.CustomerExistsAsync(request.CustomerId, cancellationToken);

        if (!customerExists)
            return Result<bool>.Fail("Customer not found.", "NOT_FOUND");

        var hasDuplicateItems = request.Items
            .GroupBy(item => item.ProductId)
            .Any(group => group.Count() > 1);

        if (hasDuplicateItems)
            return Result<bool>.Fail("Order has duplicate products.", "DUPLICATE_ITEMS");

        return true;
    }
}

5.4 Save

Grava o agregado e devolve void. O comando não entrega dado de volta: quem quiser ver o que ficou gravado usa o passo seguinte, e aí lê o estado que está no banco, com os campos que ele mesmo preencheu.

// Features/Orders/IOrderRepository.cs
namespace Features.Orders;

public interface IOrderRepository
{
    Task SaveAsync(Order order, CancellationToken cancellationToken);
    Task<bool> CustomerExistsAsync(Guid customerId, CancellationToken cancellationToken);
}

5.5 Read

Busca o registro gravado por um contrato próprio. IOrderReader existe separado de IOrderRepository para que a escrita e a leitura tenham interfaces diferentes: uma pode evoluir (ganhar um filtro, uma projeção) sem mexer na outra.

// Features/Orders/IOrderReader.cs
namespace Features.Orders;

public interface IOrderReader
{
    Task<Result<Order>> FindByIdAsync(Guid id, CancellationToken cancellationToken);
}

5.6 Filter Output

Copia da entidade para o DTO de resposta os campos que a API publica. A entidade de domínio fica de fora da resposta, e com ela ficam os campos internos que ninguém decidiu expor.

// Features/Orders/OrderResponseFilterOutput.cs
namespace Features.Orders;

public static class OrderResponseFilterOutput
{
    public static OrderResponse Apply(Order order) =>
        new(
            Id: order.Id,
            CustomerId: order.CustomerId,
            Items: order.Items.Select(MapItem).ToList(),
            CreatedAt: order.CreatedAt);

    private static OrderItemRequest MapItem(OrderItem item) =>
        new(item.ProductId, item.Quantity);
}

6. O handler orquestra

O handler chama os seis passos na ordem e sai na primeira falha. Ele não valida, não calcula e não monta texto: cada uma dessas coisas tem uma classe com nome. Lido de cima a baixo, ele conta a operação inteira.

❌ Ruim: lógica inline, SaveAsync retornando entidade, sem sanitize
// ❌ valida inline, CQS violado (SaveAsync retorna entidade), lógica no return
public static async Task<IResult> Handle(
    OrderCreateRequest request,
    IOrderRepository repository,
    CancellationToken cancellationToken)
{
    if (request.CustomerId == Guid.Empty)
        return TypedResults.BadRequest("Customer required.");

    var saved = await repository.SaveAsync(request, cancellationToken); // retorna entidade: CQS violado

    return TypedResults.Created($"/orders/{saved.Id}", saved); // lógica no return
}
✅ Bom: orquestrador slim, 6 steps explícitos
// Features/Orders/Create.cs
namespace Features.Orders;

public static class Create
{
    public static async Task<OrderCreateResult> Handle(
        [AsParameters] OrderCreateContext context)
    {
        var (request, repository, businessRules, reader, cancellationToken) = context;

        var sanitized = OrderCreateSanitizer.Sanitize(request);
        var validation = OrderCreateValidator.Validate(sanitized);

        if (!validation.IsSuccess)
            return TypedResults.BadRequest(validation.Error!.Message);

        var businessValidation = await businessRules.ValidateAsync(sanitized, cancellationToken);

        if (!businessValidation.IsSuccess)
            return TypedResults.Problem(businessValidation.Error!.Message);

        var order = Order.From(sanitized);

        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;
    }
}
// Features/Orders/GetById.cs
namespace Features.Orders;

public static class GetById
{
    public static async Task<OrderGetResult> Handle(
        [AsParameters] OrderGetContext context)
    {
        var (id, reader, cancellationToken) = context;

        var found = await reader.FindByIdAsync(id, cancellationToken);

        if (!found.IsSuccess)
            return TypedResults.NotFound();

        var orderResponse = OrderResponseFilterOutput.Apply(found.Value!);
        var response = TypedResults.Ok(orderResponse);
        return response;
    }
}

7. Compartilhado: Result e extensões

Result<T> é o que cada passo devolve: ou o valor, ou o erro com um código. O raciocínio que justifica esse tipo está em error-handling.md.

// Shared/Result.cs
public sealed record ApiError(string Message, string Code);

public record Result<T>(bool IsSuccess, bool IsFailure, T? Value, ApiError? Error)
{
    public static Result<T> Success(T value) => new(true, false, value, null);
    public static Result<T> Fail(string message, string code) => new(false, true, default, new ApiError(message, code));

    public static implicit operator Result<T>(T value) => Success(value);
}

ToHttpError traduz o código do erro em status HTTP, e serve aos handlers que devolvem IResult. Os handlers que enumeram os status na assinatura (como OrderCreateResult) escrevem cada TypedResults.* na mão, porque é assim que o tipo declarado no contrato se mantém.

// Shared/ResultExtensions.cs
public static class ResultExtensions
{
    public static IResult ToHttpError<T>(this Result<T> result) =>
        result.Error!.Code switch
        {
            "NOT_FOUND" => TypedResults.NotFound(),
            "UNAUTHORIZED" => TypedResults.Unauthorized(),
            "CONFLICT" => TypedResults.Conflict(),
            _ => TypedResults.Problem(result.Error.Message)
        };
}

Testes

O handler é um método estático que recebe um record. O teste monta esse record com implementações em memória e o chama direto, sem subir servidor e sem WebApplicationFactory. É por isso que a suíte roda em milissegundos.

[Fact]
public async Task ReturnsBusinessRuleFailureWhenCustomerDoesNotExist()
{
    var request = new OrderCreateRequest(CustomerId: Guid.NewGuid(), Items: [new(Guid.NewGuid(), 1)]); // arrange
    var repository = new FakeOrderRepository(customerExists: false);
    var businessRules = new OrderBusinessRules(repository);
    var reader = new FakeOrderReader();
    var context = new OrderCreateContext(request, repository, businessRules, reader, CancellationToken.None);

    var actualResult = await Create.Handle(context); // act

    var actualHttpResult = actualResult.Result; // assert
    var expectedType = typeof(ProblemHttpResult);
    Assert.IsType(expectedType, actualHttpResult);
}

Para convenções de teste completas, ver testing.md.


Antipadrões

Os quatro desvios abaixo aparecem juntos no mesmo handler com frequência, e cada um desfaz uma decisão do padrão.

❌ Ruim: violações frequentes no padrão vertical slice
// ❌ SaveAsync retornando entidade: CQS violado
var saved = await repository.SaveAsync(order, cancellationToken);

// ❌ leitura no mesmo contrato do comando: mistura command e query
public Task<Order> SaveAndReturnAsync(Order order, CancellationToken cancellationToken);

// ❌ regra de negócio inline no handler: step 3 fora do lugar
var customerExists = await repository.CustomerExistsAsync(request.CustomerId, cancellationToken);
if (!customerExists)
    return TypedResults.NotFound();

// ❌ lógica no return: URL e DTO construídos inline
return TypedResults.Created($"/orders/{order.Id}", OrderResponseFilterOutput.Apply(saved.Value!));
✅ Bom: cada step no seu lugar, comando e query separados
// ✅ SaveAsync sem retorno: comando persiste, não devolve estado (CQS)
await repository.SaveAsync(order, cancellationToken);

// ✅ leitura por contrato próprio: query dedicada após o comando
var saved = await reader.FindByIdAsync(order.Id, cancellationToken);

// ✅ regra de negócio no step dedicado: handler orquestra, não decide
var businessValidation = await businessRules.ValidateAsync(sanitized, cancellationToken);

if (!businessValidation.IsSuccess)
    return TypedResults.Problem(businessValidation.Error!.Message);

// ✅ sem lógica no return: URL e DTO nomeados antes da saída
var orderLocation = $"/orders/{order.Id}";
var orderResponse = OrderResponseFilterOutput.Apply(saved.Value!);

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

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