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.