MÓDULO 15 · CONCEITO 03 DE 12

Hexagonal Architecture — Ports & Adapters

O domínio no centro, o mundo externo como adaptadores intercambiáveis — e por que essa metáfora resolve o problema de testabilidade que Layered não consegue

Tempo de leitura ~21 min Pré-requisito 02 · Layered Architecture Próximo 04 · Clean Architecture

Em 2005, Alistair Cockburn publicou um artigo que mudou silenciosamente a forma como a indústria pensa sobre arquitetura de software. O título era direto: "Hexagonal Architecture". A metáfora do hexágono era deliberadamente arbitrária — ele escolheu seis lados para poder desenhar ports ao redor sem criar hierarquia visual entre eles. O que não era arbitrário era a ideia central: o domínio da aplicação deveria ser completamente isolado de qualquer tecnologia, e tudo que precisa se comunicar com o domínio deveria fazer isso através de uma interface bem definida.

O estilo ficou conhecido também como Ports & Adapters, que descreve a mecânica mais diretamente. Ports são as interfaces — os contratos que o domínio define para receber ou enviar dados. Adapters são as implementações concretas que conversam com o mundo real: um controller HTTP que recebe requisições (adapter de entrada), um repositório que grava no banco (adapter de saída), um publicador que envia mensagens para uma fila (adapter de saída).

A anatomia: dentro e fora

Cockburn divide o mundo em dois lados. O lado de dentro é o hexágono: a aplicação com seu domínio, suas regras de negócio, seus use cases. Esse lado não sabe que o mundo externo existe. Ele define o que precisa e aguarda que alguém forneça.

O lado de fora é tudo que é tecnologia: HTTP, gRPC, Kafka, PostgreSQL, Redis, S3, SMTP, APIs externas. Esse lado se conecta ao hexágono através de ports — mas o hexágono nunca se conecta ao lado de fora diretamente.

Os ports são de dois tipos. Ports de entrada (driving ports, primary ports) são as interfaces através das quais o mundo externo aciona o domínio: uma interface de Application Service que o controller HTTP chama, um Command Handler que um consumer de fila chama. Ports de saída (driven ports, secondary ports) são as interfaces que o domínio usa para pedir algo ao mundo: um IOrderRepository que o domínio chama para persistir, um IEmailSender que o domínio chama para notificar.

a distinção fundamental

Driving adapters chamam o domínio (inbound: HTTP controller → Application Service). Driven adapters são chamados pelo domínio (outbound: Domain → IRepository → PostgresRepo). O domínio nunca sabe qual adapter está do outro lado.

Por que a testabilidade explode

Em arquitetura Layered onde Domain chama Infrastructure diretamente, testar uma regra de negócio requer banco de dados, porque a chamada ao repositório está hardcoded no domínio. Você pode mockar a chamada, mas é trabalhoso e frágil.

Em Hexagonal, o domínio chama um port (uma interface). Em produção, o adapter real (PostgresOrderRepository) é injetado. Em teste, um fake adapter (InMemoryOrderRepository) é injetado. O domínio não sabe a diferença. O teste corre em microssegundos, sem banco, sem rede, sem estado externo. E ele testa exatamente o que precisa ser testado: as regras de negócio.

Isso não é conveniência — é design. Quando o domínio é testável sem infraestrutura, você descobre bugs de regra de negócio sem dependência de ambiente. Quando é difícil de testar sem infraestrutura, você tende a testar menos — e bugs de domínio só aparecem em produção.

Implementação em três linguagens

C# — Ports como interfaces, Adapters como implementações
// === DOMAIN (hexágono) ===
// Port de saída — o domínio define o contrato
public interface IOrderRepository  // driven port
{
    Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct = default);
    Task SaveAsync(Order order, CancellationToken ct = default);
}

public interface IPaymentGateway   // driven port
{
    Task<PaymentResult> ChargeAsync(PaymentRequest req, CancellationToken ct = default);
}

// Port de entrada — o que o mundo externo pode pedir ao domínio
public interface IPlaceOrderUseCase  // driving port
{
    Task<OrderId> ExecuteAsync(PlaceOrderCommand cmd, CancellationToken ct = default);
}

// Use Case — domínio puro, sem import de infra
public class PlaceOrderUseCase : IPlaceOrderUseCase
{
    private readonly IOrderRepository _orders;
    private readonly IPaymentGateway _payments;

    public PlaceOrderUseCase(IOrderRepository orders, IPaymentGateway payments)
    {
        _orders = orders;
        _payments = payments;
    }

    public async Task<OrderId> ExecuteAsync(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(cmd.CustomerId, cmd.Lines);
        var result = await _payments.ChargeAsync(order.ToPaymentRequest(), ct);
        if (!result.IsSuccess)
            throw new PaymentFailedException(result.ErrorCode);
        order.MarkPaid(result.TransactionId);
        await _orders.SaveAsync(order, ct);
        return order.Id;
    }
}

// === DRIVING ADAPTER — HTTP Controller ===
// Chama o port de entrada (IPlaceOrderUseCase)
[ApiController]
public class OrdersController : ControllerBase
{
    private readonly IPlaceOrderUseCase _placeOrder;

    [HttpPost]
    public async Task<IActionResult> Place(PlaceOrderRequest request, CancellationToken ct)
    {
        var cmd = new PlaceOrderCommand(request.CustomerId, request.Lines.ToValueObjects());
        var orderId = await _placeOrder.ExecuteAsync(cmd, ct);
        return CreatedAtAction(nameof(Get), new { id = orderId }, null);
    }
}

// === DRIVEN ADAPTER — PostgreSQL ===
// Implementa o port de saída (IOrderRepository)
public class PostgresOrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;
    public async Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct)
        => await _db.Orders.FindAsync(new object[] { id.Value }, ct);
    public async Task SaveAsync(Order order, CancellationToken ct)
    {
        _db.Orders.Update(order);
        await _db.SaveChangesAsync(ct);
    }
}

// === DRIVEN ADAPTER — fake para testes ===
public class InMemoryOrderRepository : IOrderRepository
{
    private readonly Dictionary<string, Order> _store = new();
    public Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct)
        => Task.FromResult(_store.GetValueOrDefault(id.Value));
    public Task SaveAsync(Order order, CancellationToken ct)
    {
        _store[order.Id.Value] = order;
        return Task.CompletedTask;
    }
}

// Teste de domínio — sem banco, sem HTTP, em microssegundos
public class PlaceOrderUseCaseTests
{
    [Fact]
    public async Task Confirm_WithValidLines_PersistsOrder()
    {
        var orders = new InMemoryOrderRepository();
        var gateway = new AlwaysSucceedPaymentGateway();
        var useCase = new PlaceOrderUseCase(orders, gateway);

        var id = await useCase.ExecuteAsync(ValidCommand(), default);

        var saved = await orders.FindByIdAsync(id, default);
        Assert.Equal(OrderStatus.Paid, saved!.Status);
    }
}

InMemoryOrderRepository substitui o Postgres em testes. O domínio não sabe a diferença — só conhece a interface IOrderRepository.

Python — FastAPI como driving adapter, PostgreSQL como driven adapter
from abc import ABC, abstractmethod
from dataclasses import dataclass

# === DOMAIN ===
# Ports de saída (driven)
class OrderRepository(ABC):
    @abstractmethod
    async def find_by_id(self, order_id: str) -> "Order | None": ...
    @abstractmethod
    async def save(self, order: "Order") -> None: ...

class PaymentGateway(ABC):
    @abstractmethod
    async def charge(self, amount: float, token: str) -> "PaymentResult": ...

# Port de entrada (driving)
class PlaceOrderUseCase(ABC):
    @abstractmethod
    async def execute(self, cmd: "PlaceOrderCommand") -> str: ...

# Use Case — domínio puro
class PlaceOrderService(PlaceOrderUseCase):
    def __init__(self, orders: OrderRepository, gateway: PaymentGateway):
        self._orders = orders
        self._gateway = gateway

    async def execute(self, cmd: PlaceOrderCommand) -> str:
        order = Order.create(cmd.customer_id, cmd.lines)
        result = await self._gateway.charge(order.total, cmd.payment_token)
        if not result.success:
            raise PaymentError(result.error_code)
        order.mark_paid(result.transaction_id)
        await self._orders.save(order)
        return order.id

# Driving Adapter — FastAPI
from fastapi import APIRouter
router = APIRouter()

@router.post("/orders")
async def place_order(request: PlaceOrderRequest, use_case: PlaceOrderUseCase):
    order_id = await use_case.execute(request.to_command())
    return {"id": order_id}

# Driven Adapter — PostgreSQL (implementa port)
import asyncpg

class PostgresOrderRepository(OrderRepository):
    def __init__(self, pool: asyncpg.Pool):
        self._pool = pool

    async def find_by_id(self, order_id: str) -> Order | None:
        async with self._pool.acquire() as conn:
            row = await conn.fetchrow("SELECT * FROM orders WHERE id=$1", order_id)
            return Order.from_row(row) if row else None

    async def save(self, order: Order) -> None:
        async with self._pool.acquire() as conn:
            await conn.execute(
                "INSERT INTO orders VALUES ($1,$2,$3) ON CONFLICT (id) DO UPDATE SET status=$3",
                order.id, order.customer_id, order.status.value
            )

# Driven Adapter — fake para testes
class InMemoryOrderRepository(OrderRepository):
    def __init__(self): self._store: dict[str, Order] = {}
    async def find_by_id(self, order_id: str) -> Order | None:
        return self._store.get(order_id)
    async def save(self, order: Order) -> None:
        self._store[order.id] = order

# Teste de domínio — sem banco
import pytest

@pytest.mark.asyncio
async def test_place_order_marks_as_paid():
    repo = InMemoryOrderRepository()
    gw = AlwaysSucceedGateway()
    svc = PlaceOrderService(repo, gw)
    order_id = await svc.execute(valid_command())
    saved = await repo.find_by_id(order_id)
    assert saved.status == OrderStatus.PAID

AlwaysSucceedGateway e InMemoryOrderRepository são driven adapters de teste. O use case é testável sem FastAPI, banco ou rede.

Go — domain/ports.go, adapters/http, adapters/postgres, adapters/fake
// domain/ports.go — interfaces definidas no domínio
package domain

import "context"

// Driven port — o domínio chama, infra implementa
type OrderRepository interface {
    FindByID(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, order *Order) error
}

type PaymentGateway interface {
    Charge(ctx context.Context, amount float64, token string) (PaymentResult, error)
}

// Driving port — o mundo chama, domínio implementa
type PlaceOrderUseCase interface {
    Execute(ctx context.Context, cmd PlaceOrderCommand) (string, error)
}

// app/place_order.go — domínio puro
package app

type placeOrderService struct {
    orders  domain.OrderRepository
    gateway domain.PaymentGateway
}

func NewPlaceOrderService(o domain.OrderRepository, g domain.PaymentGateway) domain.PlaceOrderUseCase {
    return &placeOrderService{orders: o, gateway: g}
}

func (s *placeOrderService) Execute(ctx context.Context, cmd domain.PlaceOrderCommand) (string, error) {
    order := domain.NewOrder(cmd.CustomerID, cmd.Lines)
    result, err := s.gateway.Charge(ctx, order.Total(), cmd.PaymentToken)
    if err != nil || !result.Success {
        return "", domain.ErrPaymentFailed
    }
    order.MarkPaid(result.TransactionID)
    if err := s.orders.Save(ctx, order); err != nil {
        return "", err
    }
    return order.ID, nil
}

// adapters/http/orders_handler.go — driving adapter
package http

type OrdersHandler struct{ uc domain.PlaceOrderUseCase }

func (h *OrdersHandler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    var req PlaceOrderRequest
    json.NewDecoder(r.Body).Decode(&req)
    id, err := h.uc.Execute(r.Context(), req.ToCommand())
    if err != nil { http.Error(w, err.Error(), 400); return }
    w.Header().Set("Location", "/orders/"+id)
    w.WriteHeader(201)
}

// adapters/postgres/order_repo.go — driven adapter
package postgres

type OrderRepository struct{ db *pgxpool.Pool }

func (r *OrderRepository) Save(ctx context.Context, order *domain.Order) error {
    _, err := r.db.Exec(ctx,
        "INSERT INTO orders(id,customer_id,status) VALUES($1,$2,$3) ON CONFLICT(id) DO UPDATE SET status=$3",
        order.ID, order.CustomerID, order.Status,
    )
    return err
}

// adapters/fake/order_repo.go — driven adapter para teste
package fake

type OrderRepository struct{ store map[string]*domain.Order }

func NewOrderRepository() *OrderRepository {
    return &OrderRepository{store: make(map[string]*domain.Order)}
}
func (r *OrderRepository) FindByID(_ context.Context, id string) (*domain.Order, error) {
    return r.store[id], nil
}
func (r *OrderRepository) Save(_ context.Context, o *domain.Order) error {
    r.store[o.ID] = o; return nil
}

// Teste de domínio
func TestPlaceOrder_MarksPaid(t *testing.T) {
    repo := fake.NewOrderRepository()
    gw := fake.AlwaysSucceedGateway{}
    svc := app.NewPlaceOrderService(repo, gw)

    id, err := svc.Execute(context.Background(), validCommand())
    require.NoError(t, err)
    saved, _ := repo.FindByID(context.Background(), id)
    assert.Equal(t, domain.Paid, saved.Status)
}

fake.OrderRepository é o driven adapter de teste — injeta em NewPlaceOrderService. O handler HTTP nunca aparece no teste de domínio.

O custo de Hexagonal

Hexagonal não é gratuito. O custo mais visível é a cerimônia: para cada ponto de contato com o mundo externo, você precisa de uma interface (port) e pelo menos dois adapters (real e fake). Em sistemas com muitas integrações, isso pode ser significativo.

O segundo custo é conceitual: equipes que não estão familiarizadas com a metáfora levam tempo para entender onde cada peça vai. "Isso é um port ou um adapter?" é uma pergunta comum em onboarding.

O terceiro custo é a tentação de criar ports para tudo — incluindo coisas que nunca vão mudar. Se você só tem uma implementação de banco de dados e não pretende substituí-la, criar um IOrderRepository completo com todos os métodos possíveis pode ser over-engineering. O princípio de Interface Segregation ajuda: defina ports com o mínimo necessário para o use case atual.

Hexagonal não é sobre injeção de dependência

Um equívoco comum é confundir Hexagonal Architecture com "usar um container de DI". Injeção de dependência é o mecanismo que permite que adapters sejam plugados nos ports — mas não é o que define o estilo. O que define Hexagonal é a topologia de dependências: domínio define contratos, mundo externo os implementa. Você pode fazer Hexagonal sem container de DI (com composição manual) e pode usar container de DI sem fazer Hexagonal (se o domínio ainda chama infraestrutura diretamente).

Referências para aprofundar

  1. artigo Hexagonal Architecture — Alistair Cockburn. alistair.cockburn.us, 2005. O artigo original — por que o hexágono, o que são ports e adapters, a motivação de testabilidade.
  2. vídeo Hexagonal Architecture: the pattern in practice — Alistair Cockburn. YouTube, 2017. Palestra do autor revisitando o padrão e esclarecendo mal-entendidos comuns.
  3. livro Implementing Domain-Driven Design — Vaughn Vernon. Addison-Wesley, 2013. Cap 4: Ports & Adapters integrado com DDD tático — a combinação mais usada em produção.
  4. artigo The Onion Architecture — Jeffrey Palermo. jeffreypalermo.com, 2008. Variante de Hexagonal que nomeia as camadas internas explicitamente.
  5. livro Clean Architecture — Robert C. Martin. Prentice Hall, 2017. Caps 17-22: a relação entre Clean Architecture e Hexagonal — mesma ideia, vocabulário diferente.
  6. vídeo Functional architecture: the pits of success — Scott Wlaschin. NDC, 2016. Hexagonal Architecture em linguagem funcional — ports como tipos, adapters como funções.
  7. livro Introducing Event Storming — Alberto Brandolini. Leanpub, 2021. Como descobrir ports e adapters através do processo de modelagem colaborativa.
  8. livro Growing Object-Oriented Software, Guided by Tests — Freeman & Pryce. Addison-Wesley, 2009. A gênese de muitas ideias de Hexagonal via TDD outside-in — testes guiam as interfaces.
  9. livro Code That Fits in Your Head — Mark Seemann. Addison-Wesley, 2021. Cap 12: a relação entre Hexagonal Architecture e composição de objetos na prática.
  10. artigo Ports and Adapters Pattern — Herberto Graça. herbertograca.com, 2017. Diagrama detalhado e nomenclatura — a referência visual mais clara do padrão.
  11. livro Hands-On Domain-Driven Design with .NET — Alexey Zimarev. Packt, 2019. Implementação prática de Hexagonal Architecture em C# moderno com exemplos completos.
  12. livro Patterns, Principles, and Practices of Domain-Driven Design — Millett & Tune. Wrox, 2015. Cap 12: Hexagonal como arquitetura base para sistemas DDD.