Clean Architecture com .NET: Guia Prático

Domine a Clean Architecture em .NET com C#. Aprenda os princípios SOLID, a separação de camadas e os padrões de implementação para aplicações sustentáveis.

Guia Clean Architecture com .NET e C#

A Clean Architecture, popularizada por Robert C. Martin (Uncle Bob), organiza o código colocando a lógica de negócio no centro da aplicação, independente de frameworks e detalhes de implementação. Essa abordagem arquitetural garante testabilidade, manutenibilidade e escalabilidade para aplicações .NET. Este guia apresenta uma implementação prática com ASP.NET Core.

Por que Clean Architecture?

Aplicações que misturam lógica de negócio com código de infraestrutura tornam-se rapidamente difíceis de manter. A Clean Architecture impõe uma separação rigorosa que permite alterar detalhes técnicos sem impactar o núcleo do negócio.

Princípios fundamentais da Clean Architecture

A Clean Architecture baseia-se na inversão de dependências: as camadas internas não conhecem as camadas externas. O domínio de negócio permanece isolado e pode evoluir independentemente das escolhas técnicas, como o framework web ou o banco de dados.

text
┌─────────────────────────────────────────────────────────┐
│                     Presentation                        │
│  (Controllers, Razor Pages, Blazor, API Endpoints)     │
├─────────────────────────────────────────────────────────┤
│                    Infrastructure                       │
│    (EF Core, External APIs, File System, Email)        │
├─────────────────────────────────────────────────────────┤
│                      Application                        │
│       (Use Cases, Commands, Queries, DTOs)             │
├─────────────────────────────────────────────────────────┤
│                        Domain                           │
│     (Entities, Value Objects, Domain Services)          │
└─────────────────────────────────────────────────────────┘

As dependências sempre apontam para dentro: Presentation → Infrastructure → Application → Domain. O Domain não referencia nenhum outro projeto.

Estrutura do projeto .NET em Clean Architecture

A organização dos projetos reflete as diferentes camadas. Cada camada corresponde a um projeto distinto na solução do Visual Studio, garantindo uma separação física das responsabilidades.

bash
# terminal
# Criar a estrutura da solução
dotnet new sln -n CleanArchitecture

# Criar projetos por camada
dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain
dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application
dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure
dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api

# Adicionar projetos à solução
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api

# Configurar referências entre projetos
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain

cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application

cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.Infrastructure

Essa estrutura garante que o Domain permaneça independente e que as dependências sigam a Dependency Rule da Clean Architecture.

A camada Domain: o núcleo do negócio

A camada Domain contém as entidades de negócio, os Value Objects e as interfaces dos repositórios. Nenhuma dependência externa é permitida nessa camada.

Domain/Entities/Order.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Entidade com identidade e ciclo de vida de negócio
public class Order
{
    // Identificador único do pedido
    public Guid Id { get; private set; }

    // Referência ao cliente (Value Object para o e-mail)
    public string CustomerEmail { get; private set; }

    // Coleção de itens (relação um-para-muitos)
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // Status do pedido (enum de negócio)
    public OrderStatus Status { get; private set; }

    // Valor total calculado
    public decimal TotalAmount => _items.Sum(i => i.TotalPrice);

    // Datas de acompanhamento
    public DateTime CreatedAt { get; private set; }
    public DateTime? ShippedAt { get; private set; }

    // Construtor privado força o uso do factory method
    private Order() { }

    // Factory method para criar um pedido válido
    public static Order Create(string customerEmail)
    {
        // Validação das regras de negócio na criação
        if (string.IsNullOrWhiteSpace(customerEmail))
            throw new DomainException("O e-mail do cliente é obrigatório.");

        if (!IsValidEmail(customerEmail))
            throw new DomainException("Formato de e-mail inválido.");

        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerEmail = customerEmail,
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
    }

    // Método de negócio: adicionar um item
    public void AddItem(Product product, int quantity)
    {
        // Regra de negócio: não é possível modificar um pedido enviado
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Não é possível modificar um pedido já enviado.");

        if (quantity <= 0)
            throw new DomainException("A quantidade deve ser positiva.");

        // Verificar se o produto já existe
        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(this, product, quantity));
        }
    }

    // Método de negócio: confirmar o pedido
    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Apenas pedidos pendentes podem ser confirmados.");

        if (!_items.Any())
            throw new DomainException("Um pedido deve conter pelo menos um item.");

        Status = OrderStatus.Confirmed;
    }

    // Método de negócio: enviar o pedido
    public void Ship()
    {
        if (Status != OrderStatus.Confirmed)
            throw new DomainException("O pedido deve ser confirmado antes do envio.");

        Status = OrderStatus.Shipped;
        ShippedAt = DateTime.UtcNow;
    }

    private static bool IsValidEmail(string email) =>
        email.Contains('@') && email.Contains('.');
}

A entidade Order encapsula as regras de negócio e protege seu estado interno. As modificações precisam passar por métodos de negócio que validam as invariantes.

Domain/Entities/OrderItem.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Entidade filha com identidade própria
public class OrderItem
{
    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; }
    public decimal UnitPrice { get; private set; }
    public int Quantity { get; private set; }

    // Cálculo do preço total da linha
    public decimal TotalPrice => UnitPrice * Quantity;

    private OrderItem() { }

    // Factory method com validação
    public static OrderItem Create(Order order, Product product, int quantity)
    {
        return new OrderItem
        {
            Id = Guid.NewGuid(),
            OrderId = order.Id,
            ProductId = product.Id,
            ProductName = product.Name,
            UnitPrice = product.Price,
            Quantity = quantity
        };
    }

    // Método para aumentar a quantidade
    public void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
            throw new DomainException("A quantidade adicional deve ser positiva.");

        Quantity += additionalQuantity;
    }
}
Domain/Enums/OrderStatus.cscsharp
namespace CleanArchitecture.Domain.Enums;

// Enumeração dos status do pedido
public enum OrderStatus
{
    Pending = 0,      // Aguardando confirmação
    Confirmed = 1,    // Confirmado, pronto para envio
    Shipped = 2,      // Enviado
    Delivered = 3,    // Entregue
    Cancelled = 4     // Cancelado
}
Value Objects

Value Objects como Money, Address ou Email encapsulam conceitos de negócio sem identidade própria. Sua igualdade é baseada nos valores, não em referências. Essa abordagem reforça a expressividade do domínio.

As interfaces Repository no Domain

As interfaces dos repositórios são definidas no Domain, mas suas implementações ficam no Infrastructure. Esse padrão respeita o princípio da inversão de dependências.

Domain/Interfaces/IOrderRepository.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Interface do repositório Order
public interface IOrderRepository
{
    // Recuperação por identificador
    Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    // Recuperação com itens incluídos
    Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);

    // Recuperação pelo e-mail do cliente
    Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default);

    // Adicionar um novo pedido
    Task AddAsync(Order order, CancellationToken cancellationToken = default);

    // Atualizar um pedido existente
    Task UpdateAsync(Order order, CancellationToken cancellationToken = default);

    // Excluir um pedido
    Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
Domain/Interfaces/IUnitOfWork.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Padrão Unit of Work para gerenciamento transacional
public interface IUnitOfWork : IDisposable
{
    // Repositórios acessíveis via UoW
    IOrderRepository Orders { get; }
    IProductRepository Products { get; }

    // Salvamento atômico de todas as alterações
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);

    // Gerenciamento explícito de transações
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

O padrão Unit of Work coordena as operações em múltiplos repositórios dentro de uma única transação.

Pronto para mandar bem nas entrevistas de .NET?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

A camada Application: orquestração dos Use Cases

A camada Application contém a lógica aplicativa (use cases), os DTOs e as interfaces dos serviços externos. Ela orquestra as interações entre o domínio e o mundo externo.

Application/Orders/Commands/CreateOrderCommand.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Commands;

// Command que representa a intenção de criar um pedido
public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderItemDto> Items
) : IRequest<Guid>;

// DTO para os itens do pedido
public record OrderItemDto(
    Guid ProductId,
    int Quantity
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Commands;

// Handler que implementa o caso de uso
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Criar a entidade Order via factory method
        var order = Order.Create(request.CustomerEmail);

        // Adicionar os itens ao pedido
        foreach (var item in request.Items)
        {
            // Recuperar o produto do repositório
            var product = await _unitOfWork.Products
                .GetByIdAsync(item.ProductId, cancellationToken);

            if (product == null)
                throw new NotFoundException($"Produto {item.ProductId} não encontrado.");

            // Usar o método de negócio da entidade
            order.AddItem(product, item.Quantity);
        }

        // Persistir via repositório
        await _unitOfWork.Orders.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

O MediatR implementa o padrão Mediator para desacoplar handlers dos controllers. Cada command tem um único handler responsável por seu processamento.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Queries;

// Query para recuperar um pedido pelo ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// DTO de resposta para um pedido
public record OrderDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    DateTime CreatedAt,
    DateTime? ShippedAt,
    List<OrderItemResponseDto> Items
);

// DTO para os itens na resposta
public record OrderItemResponseDto(
    Guid Id,
    string ProductName,
    decimal UnitPrice,
    int Quantity,
    decimal TotalPrice
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Queries;

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<OrderDto?> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        // Recuperar com os itens incluídos
        var order = await _orderRepository
            .GetByIdWithItemsAsync(request.OrderId, cancellationToken);

        if (order == null)
            return null;

        // Mapeamento para DTO (projeção manual)
        return new OrderDto(
            Id: order.Id,
            CustomerEmail: order.CustomerEmail,
            Status: order.Status.ToString(),
            TotalAmount: order.TotalAmount,
            CreatedAt: order.CreatedAt,
            ShippedAt: order.ShippedAt,
            Items: order.Items.Select(i => new OrderItemResponseDto(
                Id: i.Id,
                ProductName: i.ProductName,
                UnitPrice: i.UnitPrice,
                Quantity: i.Quantity,
                TotalPrice: i.TotalPrice
            )).ToList()
        );
    }
}

A separação Commands/Queries segue o padrão CQRS (Command Query Responsibility Segregation), otimizando leituras e escritas de forma independente.

Validação com FluentValidation

A validação dos commands ocorre na camada Application, antes da execução do handler.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;

namespace CleanArchitecture.Application.Orders.Validators;

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        // E-mail obrigatório e formato válido
        RuleFor(x => x.CustomerEmail)
            .NotEmpty().WithMessage("O e-mail do cliente é obrigatório.")
            .EmailAddress().WithMessage("Formato de e-mail inválido.");

        // Pelo menos um item obrigatório
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("O pedido deve conter pelo menos um item.");

        // Validação de cada item
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId)
                .NotEmpty().WithMessage("O identificador do produto é obrigatório.");

            item.RuleFor(i => i.Quantity)
                .GreaterThan(0).WithMessage("A quantidade deve ser positiva.")
                .LessThanOrEqualTo(100).WithMessage("Quantidade máxima: 100.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
using FluentValidation;
using MediatR;

namespace CleanArchitecture.Application.Common.Behaviors;

// Pipeline behavior para validação automática
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    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 cancellationToken)
    {
        // Se não houver validadores, continuar
        if (!_validators.Any())
            return await next();

        // Executar todos os validadores
        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        // Agregar erros
        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        // Lançar exceção se houver erros
        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

O ValidationBehavior é executado automaticamente antes de cada handler, garantindo que apenas commands válidos cheguem à lógica de negócio.

A camada Infrastructure: implementação técnica

A camada Infrastructure fornece as implementações concretas das interfaces definidas em Domain e Application.

Infrastructure/Persistence/AppDbContext.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Aplicar configurações do assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence.Configurations;

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        // Tabela e chave primária
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        // Propriedades
        builder.Property(o => o.CustomerEmail)
            .IsRequired()
            .HasMaxLength(256);

        builder.Property(o => o.Status)
            .IsRequired()
            .HasConversion<string>();  // Armazenar como string legível

        // Relação com OrderItems
        builder.HasMany(o => o.Items)
            .WithOne()
            .HasForeignKey(i => i.OrderId)
            .OnDelete(DeleteBehavior.Cascade);

        // Índice para busca por e-mail
        builder.HasIndex(o => o.CustomerEmail);

        // Acessar o campo privado _items
        builder.Navigation(o => o.Items)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;

namespace CleanArchitecture.Infrastructure.Repositories;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<Order?> GetByIdWithItemsAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        // Eager loading dos itens
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
    }

    public Task UpdateAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        // O EF Core rastreia automaticamente as mudanças
        _context.Orders.Update(order);
        return Task.CompletedTask;
    }

    public async Task DeleteAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders
            .Where(o => o.Id == id)
            .ExecuteDeleteAsync(cancellationToken);
    }
}
Cuidado com Leaky Abstractions

Os repositórios não devem expor IQueryable diretamente, pois isso criaria uma dependência ao EF Core nas camadas superiores. Prefira métodos específicos com parâmetros claros.

Implementação do Unit of Work

Infrastructure/Persistence/UnitOfWork.cscsharp
using Microsoft.EntityFrameworkCore.Storage;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure.Persistence;

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    private IDbContextTransaction? _transaction;

    // Lazy loading dos repositórios
    private IOrderRepository? _orderRepository;
    private IProductRepository? _productRepository;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
    }

    // Criação sob demanda (lazy initialization)
    public IOrderRepository Orders =>
        _orderRepository ??= new OrderRepository(_context);

    public IProductRepository Products =>
        _productRepository ??= new ProductRepository(_context);

    public async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }

    public async Task BeginTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        _transaction = await _context.Database
            .BeginTransactionAsync(cancellationToken);
    }

    public async Task CommitTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Nenhuma transação ativa.");

        await _transaction.CommitAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public async Task RollbackTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Nenhuma transação ativa.");

        await _transaction.RollbackAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

Configuração da injeção de dependências

O registro dos serviços ocorre em cada camada por meio de extension methods, depois orquestrados em Program.cs.

Infrastructure/DependencyInjection.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Configuração do Entity Framework
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection"),
                b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));

        // Registrar Unit of Work (Scoped)
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        // Repositórios individuais se necessário
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
Application/DependencyInjection.cscsharp
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Application.Common.Behaviors;

namespace CleanArchitecture.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        var assembly = typeof(DependencyInjection).Assembly;

        // Registrar MediatR com os handlers
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));

        // Registrar os validadores do FluentValidation
        services.AddValidatorsFromAssembly(assembly);

        // Pipeline behaviors (a ordem de execução importa)
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

        return services;
    }
}
Api/Program.cscsharp
using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Registrar as camadas
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

// Configuração da API
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

A camada Presentation: controllers da API

Os controllers são adaptadores simples que delegam o trabalho ao MediatR.

Api/Controllers/OrdersController.cscsharp
using MediatR;
using Microsoft.AspNetCore.Mvc;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Application.Orders.Queries;

namespace CleanArchitecture.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        // Delegação completa ao MediatR
        var orderId = await _mediator.Send(command, cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = orderId },
            orderId);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var order = await _mediator.Send(
            new GetOrderByIdQuery(id),
            cancellationToken);

        if (order == null)
            return NotFound();

        return Ok(order);
    }

    [HttpPost("{id:guid}/confirm")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Confirm(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);
        return NoContent();
    }

    [HttpPost("{id:guid}/ship")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Ship(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ShipOrderCommand(id), cancellationToken);
        return NoContent();
    }
}

Os controllers permanecem leves e não contêm lógica de negócio, apenas o mapeamento HTTP.

Testes unitários da camada Application

A Clean Architecture facilita os testes unitários por meio do isolamento de dependências.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
using Moq;
using Xunit;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Tests.Application;

public class CreateOrderCommandHandlerTests
{
    private readonly Mock<IUnitOfWork> _unitOfWorkMock;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _unitOfWorkMock = new Mock<IUnitOfWork>();
        _handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
    {
        // Arrange
        var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 };

        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        _unitOfWorkMock.Setup(x => x.Orders.AddAsync(
            It.IsAny<Order>(),
            It.IsAny<CancellationToken>()))
            .Returns(Task.CompletedTask);

        _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: product.Id, Quantity: 2)
            });

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

        // Assert
        Assert.NotEqual(Guid.Empty, result);
        _unitOfWorkMock.Verify(x => x.Orders.AddAsync(
            It.Is<Order>(o => o.CustomerEmail == "test@example.com"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(x => x.SaveChangesAsync(
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_ProductNotFound_ThrowsNotFoundException()
    {
        // Arrange
        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: Guid.NewGuid(), Quantity: 1)
            });

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => _handler.Handle(command, CancellationToken.None));
    }
}

Os mocks do Moq permitem isolar o handler de suas dependências, testando apenas a lógica de orquestração.

Conclusão

A Clean Architecture com .NET fornece uma estrutura robusta para aplicações empresariais. A separação rigorosa de responsabilidades entre as camadas Domain, Application, Infrastructure e Presentation garante código sustentável e testável a longo prazo.

Checklist Clean Architecture .NET

  • ✅ Domain isolado, sem dependências externas
  • ✅ Entidades com lógica de negócio encapsulada
  • ✅ Interfaces de repositórios no Domain
  • ✅ Use Cases via Commands/Queries (CQRS)
  • ✅ Validação com FluentValidation e Pipeline Behaviors
  • ✅ Infrastructure implementa as interfaces do Domain
  • ✅ Controllers leves delegando ao MediatR
  • ✅ Testes unitários isolados com mocks

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

O investimento inicial nessa arquitetura compensa rapidamente em projetos médios e grandes, em que mudanças de requisitos técnicos (migração de banco de dados, troca de framework) impactam apenas as camadas externas, preservando o núcleo do negócio intacto.

Tags

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Compartilhar

Artigos relacionados