.NET Arquitetura DDD Clean Architecture Testes Boas Práticas

Arquitetura Hexagonal no .NET: Ports & Adapters na prática com C#

Guia prático de Arquitetura Hexagonal (Ports & Adapters) no .NET: separação entre domínio e infraestrutura, ports de entrada e saída.

N
Neryx Digital Architects
5 de setembro de 2025
13 min de leitura
240 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Arquitetura Hexagonal — também chamada de Ports & Adapters — foi proposta por Alistair Cockburn em 2005 com um objetivo simples: permitir que uma aplicação seja igualmente controlada por usuários, programas, testes automatizados ou scripts em batch, e seja desenvolvida e testada isoladamente de dispositivos de runtime e bancos de dados. Em 2026, essa ideia é mais relevante do que nunca para APIs .NET.

O problema que a arquitetura hexagonal resolve

Em projetos tradicionais, a lógica de negócio fica entrelaçada com infraestrutura: o serviço de pedidos conhece o Entity Framework, o controller chama o banco diretamente, os testes precisam de banco real. Isso torna o código rígido, difícil de testar e impossível de trocar dependências sem refatoração massiva.

A arquitetura hexagonal inverte isso: o domínio não depende de nada externo. São as tecnologias externas que se adaptam ao domínio.

┌─────────────────────────────────────────────────────────────┐
│                    ADAPTERS DE ENTRADA                       │
│         (REST API, gRPC, Console, Worker, Testes)           │
│                          │                                   │
│              ┌───────────▼────────────┐                     │
│              │    PORTS DE ENTRADA    │                     │
│              │  (Interfaces de caso   │                     │
│              │   de uso / commands)   │                     │
│              └───────────┬────────────┘                     │
│                          │                                   │
│         ┌────────────────▼─────────────────┐               │
│         │                                   │               │
│         │        DOMÍNIO (Hexágono)         │               │
│         │   Entidades, Aggregates, VOs,     │               │
│         │   Serviços de Domínio, Regras     │               │
│         │                                   │               │
│         └────────────────┬─────────────────┘               │
│                          │                                   │
│              ┌───────────▼────────────┐                     │
│              │    PORTS DE SAÍDA      │                     │
│              │  (Interfaces de repo,  │                     │
│              │   email, pagamento...) │                     │
│              └───────────┬────────────┘                     │
│                          │                                   │
│                ADAPTERS DE SAÍDA                             │
│       (EF Core, Dapper, SMTP, Stripe, AWS S3...)            │
└─────────────────────────────────────────────────────────────┘

Estrutura de projeto

src/
├── Domain/                    # O hexágono — zero dependências externas
│   ├── Orders/
│   │   ├── Order.cs           # Aggregate root
│   │   ├── OrderItem.cs       # Entity
│   │   ├── OrderStatus.cs     # Value Object / Enum
│   │   └── OrderService.cs    # Domain Service
│   ├── Customers/
│   └── Shared/
│       ├── Entity.cs
│       ├── AggregateRoot.cs
│       └── ValueObject.cs
│
├── Application/               # Casos de uso + Ports de entrada e saída
│   ├── Ports/
│   │   ├── Input/             # Ports de entrada (o que a app faz)
│   │   │   ├── ICreateOrderUseCase.cs
│   │   │   ├── IGetOrderUseCase.cs
│   │   │   └── ICancelOrderUseCase.cs
│   │   └── Output/            # Ports de saída (o que a app precisa)
│   │       ├── IOrderRepository.cs
│   │       ├── ICustomerRepository.cs
│   │       ├── IPaymentGateway.cs
│   │       ├── IEmailNotifier.cs
│   │       └── IEventPublisher.cs
│   └── UseCases/              # Implementações dos casos de uso
│       ├── CreateOrderUseCase.cs
│       ├── GetOrderUseCase.cs
│       └── CancelOrderUseCase.cs
│
├── Infrastructure/            # Adapters de saída
│   ├── Persistence/
│   │   ├── EfCore/            # Adapter EF Core para IOrderRepository
│   │   └── Dapper/            # Adapter Dapper para queries de leitura
│   ├── Messaging/
│   │   └── RabbitMq/          # Adapter para IEventPublisher
│   ├── Email/
│   │   └── SendGrid/          # Adapter para IEmailNotifier
│   └── Payment/
│       └── Stripe/            # Adapter para IPaymentGateway
│
└── Api/                       # Adapters de entrada
    ├── Controllers/           # Adapter REST
    ├── Grpc/                  # Adapter gRPC (opcional)
    └── Workers/               # Adapter Worker (processar filas)

O domínio: sem dependências externas

// src/Domain/Orders/Order.cs
// O aggregate root não conhece EF Core, repositórios, nem HTTP
public class Order : AggregateRoot
{
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money Total { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? ConfirmedAt { get; private set; }

    private readonly List<OrderItem> _items = [];
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

    // Factory method — encapsula a criação com invariantes
    public static Order Create(Guid customerId, IEnumerable<(Guid ProductId, int Quantity, Money Price)> items)
    {
        if (customerId == Guid.Empty)
            throw new DomainException("Cliente inválido");

        var itemList = items.ToList();
        if (!itemList.Any())
            throw new DomainException("Pedido deve ter pelo menos um item");

        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            Status = OrderStatus.Draft,
            CreatedAt = DateTime.UtcNow
        };

        foreach (var (productId, quantity, price) in itemList)
            order.AddItem(productId, quantity, price);

        order.RecalculateTotal();

        // Evento de domínio — desacoplado de infraestrutura
        order.RaiseDomainEvent(new OrderCreatedEvent(order.Id, order.CustomerId, order.Total));

        return order;
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException($"Não é possível confirmar um pedido com status {Status}");

        if (!_items.Any())
            throw new DomainException("Não é possível confirmar um pedido sem itens");

        Status = OrderStatus.Confirmed;
        ConfirmedAt = DateTime.UtcNow;

        RaiseDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
    }

    public void Cancel(string reason)
    {
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException("Não é possível cancelar um pedido já enviado ou entregue");

        Status = OrderStatus.Cancelled;
        RaiseDomainEvent(new OrderCancelledEvent(Id, CustomerId, reason));
    }

    private void AddItem(Guid productId, int quantity, Money price)
    {
        if (quantity <= 0)
            throw new DomainException("Quantidade deve ser maior que zero");

        var existing = _items.FirstOrDefault(i => i.ProductId == productId);
        if (existing is not null)
        {
            existing.IncreaseQuantity(quantity);
            return;
        }

        _items.Add(OrderItem.Create(Id, productId, quantity, price));
    }

    private void RecalculateTotal()
        => Total = _items.Aggregate(Money.Zero, (sum, item) => sum + item.Subtotal);
}

Ports de saída (interfaces que o domínio precisa)

// src/Application/Ports/Output/IOrderRepository.cs
// Port de saída — o que a aplicação precisa de persistência
// NÃO menciona EF Core, SQL, ou qualquer tecnologia específica
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task UpdateAsync(Order order, CancellationToken ct = default);
}

// src/Application/Ports/Output/IPaymentGateway.cs
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct = default);
    Task<RefundResult> RefundAsync(string transactionId, Money amount, CancellationToken ct = default);
}

// src/Application/Ports/Output/IEmailNotifier.cs
public interface IEmailNotifier
{
    Task SendOrderConfirmationAsync(Order order, string customerEmail, CancellationToken ct = default);
    Task SendOrderCancelledAsync(Order order, string customerEmail, string reason, CancellationToken ct = default);
}

// src/Application/Ports/Output/IEventPublisher.cs
public interface IEventPublisher
{
    Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
        where TEvent : IDomainEvent;
}

Caso de uso: orquestra domínio + ports de saída

// src/Application/UseCases/CreateOrderUseCase.cs
public class CreateOrderUseCase : ICreateOrderUseCase
{
    private readonly IOrderRepository _orders;
    private readonly ICustomerRepository _customers;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IEmailNotifier _emailNotifier;
    private readonly IEventPublisher _eventPublisher;

    public CreateOrderUseCase(
        IOrderRepository orders,
        ICustomerRepository customers,
        IPaymentGateway paymentGateway,
        IEmailNotifier emailNotifier,
        IEventPublisher eventPublisher)
    {
        _orders = orders;
        _customers = customers;
        _paymentGateway = paymentGateway;
        _emailNotifier = emailNotifier;
        _eventPublisher = eventPublisher;
    }

    public async Task<CreateOrderResult> ExecuteAsync(
        CreateOrderCommand command,
        CancellationToken ct = default)
    {
        // 1. Verificar pré-condições (via ports de saída)
        var customer = await _customers.GetByIdAsync(command.CustomerId, ct)
            ?? throw new NotFoundException($"Cliente {command.CustomerId} não encontrado");

        if (!customer.IsActive)
            throw new DomainException("Cliente inativo não pode realizar pedidos");

        // 2. Criar o aggregate via factory do domínio
        var items = command.Items.Select(i =>
            (i.ProductId, i.Quantity, new Money(i.UnitPrice, "BRL")));

        var order = Order.Create(command.CustomerId, items);

        // 3. Processar pagamento (port de saída)
        var paymentResult = await _paymentGateway.ChargeAsync(
            new PaymentRequest(order.Total, command.PaymentToken), ct);

        if (!paymentResult.IsSuccessful)
            throw new PaymentException(paymentResult.ErrorMessage);

        // 4. Confirmar pedido (lógica de domínio)
        order.Confirm();

        // 5. Persistir (port de saída)
        await _orders.AddAsync(order, ct);

        // 6. Notificações (ports de saída — fire and forget errors)
        await Task.WhenAll(
            _emailNotifier.SendOrderConfirmationAsync(order, customer.Email, ct),
            PublishDomainEventsAsync(order, ct));

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

    private async Task PublishDomainEventsAsync(Order order, CancellationToken ct)
    {
        foreach (var domainEvent in order.DomainEvents)
            await _eventPublisher.PublishAsync(domainEvent, ct);

        order.ClearDomainEvents();
    }
}

Adapter de saída: EF Core implementando IOrderRepository

// src/Infrastructure/Persistence/EfCore/EfOrderRepository.cs
// Adapter de saída — conhece EF Core, mas o domínio não sabe disso
public class EfOrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public EfOrderRepository(AppDbContext context)
        => _context = context;

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, ct);

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

    public async Task UpdateAsync(Order order, CancellationToken ct = default)
    {
        _context.Orders.Update(order);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<IEnumerable<Order>> GetByCustomerIdAsync(
        Guid customerId, CancellationToken ct = default)
        => await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .AsNoTracking()
            .ToListAsync(ct);
}

// Adapter de saída: Stripe implementando IPaymentGateway
public class StripePaymentGateway : IPaymentGateway
{
    private readonly StripeClient _stripeClient;

    public StripePaymentGateway(StripeClient stripeClient)
        => _stripeClient = stripeClient;

    public async Task<PaymentResult> ChargeAsync(
        PaymentRequest request, CancellationToken ct = default)
    {
        try
        {
            var options = new PaymentIntentCreateOptions
            {
                Amount = (long)(request.Amount.Value * 100),
                Currency = request.Amount.Currency.ToLower(),
                PaymentMethod = request.Token,
                Confirm = true
            };

            var service = new PaymentIntentService(_stripeClient);
            var intent = await service.CreateAsync(options, cancellationToken: ct);

            return new PaymentResult(
                IsSuccessful: intent.Status == "succeeded",
                TransactionId: intent.Id,
                ErrorMessage: null);
        }
        catch (StripeException ex)
        {
            return new PaymentResult(
                IsSuccessful: false,
                TransactionId: null,
                ErrorMessage: ex.StripeError?.Message ?? ex.Message);
        }
    }
}

Adapter de entrada: REST API Controller

// src/Api/Controllers/OrdersController.cs
// Adapter de entrada — converte HTTP em comandos do domínio
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class OrdersController : ControllerBase
{
    private readonly ICreateOrderUseCase _createOrder;
    private readonly IGetOrderUseCase _getOrder;
    private readonly ICancelOrderUseCase _cancelOrder;

    public OrdersController(
        ICreateOrderUseCase createOrder,
        IGetOrderUseCase getOrder,
        ICancelOrderUseCase cancelOrder)
    {
        _createOrder = createOrder;
        _getOrder = getOrder;
        _cancelOrder = cancelOrder;
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreateOrderRequest request)
    {
        var command = new CreateOrderCommand(
            CustomerId: User.GetUserId(),
            Items: request.Items.Select(i =>
                new OrderItemCommand(i.ProductId, i.Quantity, i.UnitPrice)).ToList(),
            PaymentToken: request.PaymentToken);

        var result = await _createOrder.ExecuteAsync(command);

        return CreatedAtAction(nameof(GetById), new { id = result.OrderId }, result);
    }

    [HttpGet("{id:guid}", Name = "GetOrder")]
    public async Task<IActionResult> GetById(Guid id)
    {
        var query = new GetOrderQuery(id, UserId: User.GetUserId());
        var result = await _getOrder.ExecuteAsync(query);
        return result is null ? NotFound() : Ok(result);
    }

    [HttpDelete("{id:guid}")]
    public async Task<IActionResult> Cancel(Guid id, [FromBody] CancelOrderRequest request)
    {
        var command = new CancelOrderCommand(id, UserId: User.GetUserId(), request.Reason);
        await _cancelOrder.ExecuteAsync(command);
        return NoContent();
    }
}

Testabilidade: o maior benefício

// Testar o caso de uso sem banco, sem HTTP, sem Stripe
public class CreateOrderUseCaseTests
{
    private readonly Mock<IOrderRepository> _orders = new();
    private readonly Mock<ICustomerRepository> _customers = new();
    private readonly Mock<IPaymentGateway> _payment = new();
    private readonly Mock<IEmailNotifier> _email = new();
    private readonly Mock<IEventPublisher> _events = new();

    private CreateOrderUseCase CreateUseCase() =>
        new(_orders.Object, _customers.Object, _payment.Object,
            _email.Object, _events.Object);

    [Fact]
    public async Task Execute_ValidCommand_CreatesAndConfirmsOrder()
    {
        // Arrange
        var customerId = Guid.NewGuid();

        _customers.Setup(r => r.GetByIdAsync(customerId, default))
            .ReturnsAsync(new Customer { Id = customerId, IsActive = true, Email = "test@test.com" });

        _payment.Setup(g => g.ChargeAsync(It.IsAny<PaymentRequest>(), default))
            .ReturnsAsync(new PaymentResult(true, "pi_123", null));

        var command = new CreateOrderCommand(
            CustomerId: customerId,
            Items: [new OrderItemCommand(Guid.NewGuid(), 2, 49.90m)],
            PaymentToken: "tok_visa");

        // Act
        var result = await CreateUseCase().ExecuteAsync(command);

        // Assert
        result.Status.Should().Be(OrderStatus.Confirmed);
        _orders.Verify(r => r.AddAsync(It.IsAny<Order>(), default), Times.Once);
        _email.Verify(n => n.SendOrderConfirmationAsync(
            It.IsAny<Order>(), "test@test.com", default), Times.Once);
    }

    [Fact]
    public async Task Execute_PaymentFails_ThrowsPaymentException()
    {
        _customers.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), default))
            .ReturnsAsync(new Customer { IsActive = true });

        _payment.Setup(g => g.ChargeAsync(It.IsAny<PaymentRequest>(), default))
            .ReturnsAsync(new PaymentResult(false, null, "Cartão recusado"));

        var act = () => CreateUseCase().ExecuteAsync(
            new CreateOrderCommand(Guid.NewGuid(), [new(Guid.NewGuid(), 1, 10m)], "tok_fail"));

        await act.Should().ThrowAsync<PaymentException>()
            .WithMessage("*Cartão recusado*");

        // Pedido NÃO deve ter sido salvo
        _orders.Verify(r => r.AddAsync(It.IsAny<Order>(), default), Times.Never);
    }
}

Arquitetura Hexagonal vs Clean Architecture

A diferença é menos do que parece: Clean Architecture de Uncle Bob é uma instância mais específica dos mesmos princípios com nomes diferentes (Use Cases = Application, Gateways = Ports). Na prática, ao implementar Clean Architecture com interfaces bem definidas no .NET, você chega ao mesmo lugar. O mais importante é o princípio: o domínio não depende de nada externo.


Arquitetura Hexagonal não é complexidade por complexidade — é a diferença entre um sistema que você consegue manter e evoluir por anos e um que se torna um legado em meses. Se você quer revisar a arquitetura do seu projeto .NET, fale com a Neryx.

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.