.NET Arquitetura Vertical Slice MediatR Clean Architecture Design Patterns

Vertical Slice Architecture no .NET: features auto-contidas do endpoint ao banco

Implemente Vertical Slice Architecture no .NET: organize o código por feature (não por camada), use MediatR por slice, teste cada feature isoladamente.

N
Neryx Digital Architects
5 de março de 2026
12 min de leitura
220 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

O Clean Architecture em camadas é o padrão mais ensinado no .NET: Controllers → Services → Repositories → Entities, cada camada com sua pasta, cada pasta com suas abstrações. É um padrão sólido. Mas tem um custo que vai aumentando conforme o projeto cresce: adicionar uma nova feature exige tocar em quatro ou cinco pastas diferentes, cada uma com suas próprias abstrações e interfaces.

A Vertical Slice Architecture inverte essa lógica: em vez de organizar o código pelo tipo técnico (controller, service, repository), organiza pelo caso de uso (criar pedido, cancelar pedido, listar produtos). Cada feature é uma fatia vertical que atravessa todas as camadas técnicas — e vive em um único lugar.

O problema com organização por camada

Em um projeto com organização por camada, a feature "criar pedido" está assim distribuída:

src/
├── Controllers/
│   └── OrdersController.cs           ← CreateOrder action aqui
├── Services/
│   ├── IOrderService.cs              ← interface aqui
│   └── OrderService.cs               ← implementação aqui
├── Repositories/
│   ├── IOrderRepository.cs           ← interface aqui
│   └── OrderRepository.cs            ← implementação aqui
├── Models/
│   └── CreateOrderRequest.cs         ← DTO aqui
├── DTOs/
│   └── OrderResponse.cs              ← response DTO aqui
└── Domain/
    └── Order.cs                      ← entidade aqui

Para entender ou modificar a feature "criar pedido", você navega por seis arquivos em seis pastas. Para adicionar uma nova feature, você repete o processo em todas as camadas. E quanto mais features o projeto tem, mais essa estrutura cresce horizontalmente — pastas com dezenas de arquivos onde o nome do arquivo é o único contexto.

Estrutura com Vertical Slice Architecture

src/
├── Features/
│   ├── Orders/
│   │   ├── CreateOrder/
│   │   │   ├── CreateOrderCommand.cs       ← command + handler + validator
│   │   │   ├── CreateOrderValidator.cs
│   │   │   └── CreateOrderEndpoint.cs     ← ou parte do controller
│   │   ├── CancelOrder/
│   │   │   ├── CancelOrderCommand.cs
│   │   │   └── CancelOrderEndpoint.cs
│   │   ├── GetOrder/
│   │   │   ├── GetOrderQuery.cs
│   │   │   └── GetOrderEndpoint.cs
│   │   └── ListOrders/
│   │       ├── ListOrdersQuery.cs
│   │       └── ListOrdersEndpoint.cs
│   └── Products/
│       ├── CreateProduct/
│       └── GetProduct/
├── Domain/
│   ├── Order.cs                           ← entidades de domínio compartilhadas
│   └── Product.cs
└── Infrastructure/
    ├── Persistence/
    │   └── AppDbContext.cs                ← infraestrutura compartilhada
    └── Messaging/

Agora toda a lógica de "criar pedido" está em um único lugar. Para entender a feature, você abre uma pasta. Para deletá-la, você deleta uma pasta. Para testar, você testa uma unidade coesa.

Implementação com MediatR: command e handler no mesmo arquivo

// Features/Orders/CreateOrder/CreateOrderCommand.cs
// Command, handler, validator e response — tudo junto por ser tudo da mesma feature

using MediatR;
using FluentValidation;

// ── Command ───────────────────────────────────────────────────────────────────
public record CreateOrderCommand(
    Guid CustomerId,
    List<CreateOrderItemDto> Items
) : IRequest<CreateOrderResult>;

public record CreateOrderItemDto(
    Guid ProductId,
    int Quantity,
    decimal UnitPrice
);

// ── Result ────────────────────────────────────────────────────────────────────
public record CreateOrderResult(
    Guid OrderId,
    string OrderNumber,
    decimal Total
);

// ── Validator ─────────────────────────────────────────────────────────────────
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty().WithMessage("O pedido deve ter ao menos um item");
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId).NotEmpty();
            item.RuleFor(i => i.Quantity).GreaterThan(0);
            item.RuleFor(i => i.UnitPrice).GreaterThan(0);
        });
    }
}

// ── Handler ───────────────────────────────────────────────────────────────────
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
    private readonly AppDbContext _context;
    private readonly IPublishEndpoint _bus;

    public CreateOrderHandler(AppDbContext context, IPublishEndpoint bus)
    {
        _context = context;
        _bus = bus;
    }

    public async Task<CreateOrderResult> Handle(
        CreateOrderCommand command,
        CancellationToken ct)
    {
        var order = Order.Create(command.CustomerId, command.Items.Select(i =>
            new OrderItem(i.ProductId, i.Quantity, i.UnitPrice)).ToList());

        _context.Orders.Add(order);

        await _bus.Publish(new OrderCreatedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            Total = order.Total
        }, ct);

        await _context.SaveChangesAsync(ct);

        return new CreateOrderResult(
            order.Id,
            order.OrderNumber,
            order.Total);
    }
}

Endpoint: conecta o HTTP ao MediatR

// Features/Orders/CreateOrder/CreateOrderEndpoint.cs
// Com Minimal APIs — o endpoint é parte da feature

public static class CreateOrderEndpoint
{
    public static IEndpointRouteBuilder MapCreateOrder(
        this IEndpointRouteBuilder app)
    {
        app.MapPost("/api/orders", async (
            CreateOrderCommand command,
            ISender sender,
            CancellationToken ct) =>
        {
            var result = await sender.Send(command, ct);
            return Results.Created($"/api/orders/{result.OrderId}", result);
        })
        .WithName("CreateOrder")
        .WithTags("Orders")
        .WithSummary("Cria um novo pedido")
        .ProducesValidationProblem()
        .Produces<CreateOrderResult>(StatusCodes.Status201Created);

        return app;
    }
}

// Program.cs — cada feature registra seus endpoints
app.MapCreateOrder();
app.MapCancelOrder();
app.MapGetOrder();
app.MapListOrders();

// Alternativa: auto-discover por convenção
app.MapFeatureEndpoints(typeof(Program).Assembly);

Validação automática com Pipeline Behavior

// Infrastructure/Behaviors/ValidationBehavior.cs
// Comportamento compartilhado que valida todos os commands automaticamente
// — a única peça de infraestrutura transversal que existe fora dos slices

public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(e => e != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}

// Registro no Program.cs
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
});

builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

Testes por feature: isolamento natural

// Tests/Features/Orders/CreateOrderHandlerTests.cs
// Cada feature tem seus próprios testes — nenhum mock compartilhado entre features

public class CreateOrderHandlerTests
{
    private readonly AppDbContext _context;
    private readonly Mock<IPublishEndpoint> _busMock;
    private readonly CreateOrderHandler _handler;

    public CreateOrderHandlerTests()
    {
        // Banco em memória por feature — sem interferência entre testes
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        _context = new AppDbContext(options);
        _busMock = new Mock<IPublishEndpoint>();
        _handler = new CreateOrderHandler(_context, _busMock.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndPublishesEvent()
    {
        // Arrange
        var command = new CreateOrderCommand(
            CustomerId: Guid.NewGuid(),
            Items: [new(Guid.NewGuid(), 2, 50.0m)]
        );

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        result.Total.Should().Be(100.0m);
        result.OrderId.Should().NotBeEmpty();

        var savedOrder = await _context.Orders.FindAsync(result.OrderId);
        savedOrder.Should().NotBeNull();
        savedOrder!.CustomerId.Should().Be(command.CustomerId);

        _busMock.Verify(
            b => b.Publish(
                It.Is<OrderCreatedEvent>(e => e.OrderId == result.OrderId),
                It.IsAny<CancellationToken>()),
            Times.Once);
    }

    [Fact]
    public async Task Handle_EmptyItems_ThrowsValidationException()
    {
        var command = new CreateOrderCommand(Guid.NewGuid(), []);
        var validator = new CreateOrderCommandValidator();

        var result = await validator.ValidateAsync(command);

        result.IsValid.Should().BeFalse();
        result.Errors.Should().Contain(e =>
            e.PropertyName == "Items");
    }
}

Quando usar Vertical Slice vs Clean Architecture em camadas

Critério Vertical Slice Clean Architecture (Camadas)
Complexidade de domínio Baixa a média Alta (muitas regras compartilhadas)
Número de features Muitas features independentes Poucas features com muita lógica compartilhada
Time de desenvolvimento Múltiplos devs em features paralelas Times especializados por camada
Curva de aprendizado Mais intuitivo para novos membros Requer entendimento de toda a arquitetura
Reutilização de lógica Duplicação intencional entre slices Abstração compartilhada em services
Testabilidade Fácil — cada feature é testável isoladamente Médio — depende de mocks de serviços

Os dois padrões não são mutuamente exclusivos. Muitos projetos maduros adotam uma abordagem híbrida: Vertical Slice para organizar as features de produto, com uma camada de domínio compartilhada para as entidades e regras de negócio que realmente pertencem ao domínio e são reutilizadas em múltiplos slices.

A decisão mais importante não é qual padrão usar — é ser consistente dentro do projeto. Misturar os dois sem critério claro resulta no pior dos dois mundos.


Escolher a arquitetura certa depende do contexto do seu time e produto. Se você quer avaliar qual abordagem faz mais sentido para o seu projeto .NET, a Neryx pode ajudar com um diagnóstico técnico.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.