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.
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
// === 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.
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.
// 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
- artigo Hexagonal Architecture — Alistair Cockburn. alistair.cockburn.us, 2005.
- vídeo Hexagonal Architecture: the pattern in practice — Alistair Cockburn. YouTube, 2017.
- livro Implementing Domain-Driven Design — Vaughn Vernon. Addison-Wesley, 2013.
- artigo The Onion Architecture — Jeffrey Palermo. jeffreypalermo.com, 2008.
- livro Clean Architecture — Robert C. Martin. Prentice Hall, 2017.
- vídeo Functional architecture: the pits of success — Scott Wlaschin. NDC, 2016.
- livro Introducing Event Storming — Alberto Brandolini. Leanpub, 2021.
- livro Growing Object-Oriented Software, Guided by Tests — Freeman & Pryce. Addison-Wesley, 2009.
- livro Code That Fits in Your Head — Mark Seemann. Addison-Wesley, 2021.
- artigo Ports and Adapters Pattern — Herberto Graça. herbertograca.com, 2017.
- livro Hands-On Domain-Driven Design with .NET — Alexey Zimarev. Packt, 2019.
- livro Patterns, Principles, and Practices of Domain-Driven Design — Millett & Tune. Wrox, 2015.