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.