Design de Software

Arquitetura Hexagonal

Isola o domínio usando Ports (interfaces) e Adapters (implementações) — guia completo com diagrama, exemplos de adaptadores e comparação com Clean Architecture

Arquitetura Hexagonal, criada por Alistair Cockburn em 2005, também chamada de Ports & Adapters. A ideia central é simples: o domínio vive no centro de um hexágono. Cada lado do hexágono é um port — um ponto de entrada ou saída. Adapters ficam do lado de fora e se conectam ao domínio através dos ports.

O resultado: o domínio é completamente isolado de infraestrutura. Você pode trocar HTTP por CLI, JPA por MongoDB, ou SendGrid por SMS sem tocar em uma linha do domínio.


Diagrama — Ports e Adapters

                    ┌─────────────────────┐
                    │   Adapter Primário   │
                    │   (HTTP Controller)  │
                    └──────────┬──────────┘
                               │ chama
                    ┌──────────▼──────────┐
                    │   Port de Entrada    │
                    │ (PlaceOrderPort)     │
                    └──────────┬──────────┘
                               │ implementado por
┌──────────────────────────────▼──────────────────────────────┐
│                                                              │
│                    DOMÍNIO (Hexágono)                        │
│                                                              │
│  ┌───────────────────────────────────────────────────────┐  │
│  │              OrderService                             │  │
│  │  implements PlaceOrderPort                            │  │
│  │  uses OrderRepository, NotificationPort               │  │
│  └───────────────────────────────────────────────────────┘  │
│                                                              │
└────────────┬───────────────────────────┬─────────────────────┘
             │ define                    │ define
  ┌──────────▼──────────┐    ┌──────────▼──────────┐
  │   Port de Saída     │    │   Port de Saída     │
  │ (OrderRepository)   │    │ (NotificationPort)  │
  └──────────┬──────────┘    └──────────┬──────────┘
             │ implementado por          │ implementado por
  ┌──────────▼──────────┐    ┌──────────▼──────────┐
  │  Adapter Secundário │    │  Adapter Secundário │
  │  (JpaRepository)    │    │  (SendGridAdapter)  │
  └─────────────────────┘    └─────────────────────┘

Adapters Primários (driving) → acionam o domínio
Adapters Secundários (driven) ← são acionados pelo domínio

Portas de Entrada (Driving Ports)

Ports de entrada definem como o mundo externo pode acionar o domínio. São interfaces implementadas pelo domínio, chamadas pelos adapters primários.

// PORT DE ENTRADA — definido pelo domínio, implementado pelo serviço de domínio
public interface PlaceOrderPort {
    OrderId place(PlaceOrderCommand command);
}

public interface CancelOrderPort {
    void cancel(OrderId orderId, String reason);
}

public interface GetOrderStatusPort {
    OrderStatusResponse getStatus(OrderId orderId);
}

// Cada port representa uma "intenção de uso" — um caso de uso do sistema

Portas de Saída (Driven Ports)

Ports de saída definem o que o domínio precisa do mundo externo. São interfaces definidas pelo domínio, implementadas pela infraestrutura.

// PORTS DE SAÍDA — o domínio define o contrato que precisa
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
    List<Order> findByCustomer(CustomerId customerId);
}

public interface NotificationPort {
    void notifyOrderPlaced(Order order, String recipientEmail);
    void notifyOrderCancelled(Order order, String recipientEmail);
}

public interface PaymentPort {
    PaymentResult charge(Money amount, String customerId, String paymentMethodId);
    void refund(String paymentId, Money amount);
}

public interface StockPort {
    boolean isAvailable(ProductId productId, int quantity);
    void reserve(ProductId productId, int quantity);
    void release(ProductId productId, int quantity);
}

Adaptadores Primários — HTTP Controller, Consumer de Fila, CLI

Adapters primários acionam o domínio. Eles traduzem o protocolo externo (HTTP, Kafka, CLI) para os comandos que o domínio entende.

// ADAPTER HTTP — REST Controller
@RestController
@RequestMapping("/api/orders")
public class OrderHttpAdapter {

    private final PlaceOrderPort placeOrder;
    private final CancelOrderPort cancelOrder;
    private final GetOrderStatusPort getOrderStatus;

    public OrderHttpAdapter(
            PlaceOrderPort placeOrder,
            CancelOrderPort cancelOrder,
            GetOrderStatusPort getOrderStatus) {
        this.placeOrder = placeOrder;
        this.cancelOrder = cancelOrder;
        this.getOrderStatus = getOrderStatus;
    }

    @PostMapping
    public ResponseEntity<PlaceOrderResponse> create(
            @RequestBody @Valid PlaceOrderRequest request) {
        // Traduz HTTP request → comando de domínio
        PlaceOrderCommand command = new PlaceOrderCommand(
            request.customerId(),
            request.items().stream()
                .map(i -> new OrderItemCommand(i.productId(), i.quantity(), i.price()))
                .toList(),
            request.paymentMethodId()
        );

        OrderId orderId = placeOrder.place(command);

        // Traduz resposta de domínio → HTTP response
        return ResponseEntity
            .created(URI.create("/api/orders/" + orderId.value()))
            .body(new PlaceOrderResponse(orderId.value()));
    }

    @DeleteMapping("/{orderId}")
    public ResponseEntity<Void> cancel(
            @PathVariable String orderId,
            @RequestParam String reason) {
        cancelOrder.cancel(new OrderId(orderId), reason);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/{orderId}/status")
    public ResponseEntity<OrderStatusResponse> status(@PathVariable String orderId) {
        return ResponseEntity.ok(getOrderStatus.getStatus(new OrderId(orderId)));
    }
}

// ADAPTER KAFKA — Consumer de mensagens
@Component
public class OrderKafkaAdapter {

    private final PlaceOrderPort placeOrder;

    @KafkaListener(topics = "checkout.completed", groupId = "order-service")
    public void onCheckoutCompleted(
            @Payload String payload,
            @Header(KafkaHeaders.RECEIVED_KEY) String key) {

        CheckoutCompletedEvent event = deserialize(payload);

        // Traduz evento Kafka → comando de domínio
        PlaceOrderCommand command = new PlaceOrderCommand(
            event.customerId(),
            event.items(),
            event.paymentMethodId()
        );

        placeOrder.place(command);
    }
}

// ADAPTER CLI — linha de comando (útil para scripts e jobs)
@Component
public class OrderCliAdapter implements CommandLineRunner {

    private final PlaceOrderPort placeOrder;

    @Override
    public void run(String... args) {
        if (args.length > 0 && args[0].equals("--place-test-order")) {
            PlaceOrderCommand command = buildTestCommand();
            OrderId orderId = placeOrder.place(command);
            System.out.println("Pedido criado: " + orderId.value());
        }
    }
}

Adaptadores Secundários — JPA, HTTP Client, Email Service

Adapters secundários são acionados pelo domínio via ports de saída. Eles traduzem o contrato do domínio para o protocolo de infraestrutura.

// ADAPTER JPA — implementa OrderRepository
@Repository
public class JpaOrderAdapter implements OrderRepository {

    private final SpringDataOrderRepository springRepo;
    private final OrderEntityMapper mapper;

    @Override
    public void save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        springRepo.save(entity);
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return springRepo.findById(id.value()).map(mapper::toDomain);
    }

    @Override
    public List<Order> findByCustomer(CustomerId customerId) {
        return springRepo.findByCustomerId(customerId.value())
            .stream()
            .map(mapper::toDomain)
            .toList();
    }
}

// ADAPTER SENDGRID — implementa NotificationPort
@Component
public class SendGridNotificationAdapter implements NotificationPort {

    private final SendGrid sendGrid;
    private final String senderEmail;

    @Override
    public void notifyOrderPlaced(Order order, String recipientEmail) {
        Email from = new Email(senderEmail);
        Email to = new Email(recipientEmail);
        String subject = "Pedido #" + order.id().value() + " recebido!";
        Content content = new Content("text/html",
            "<p>Seu pedido foi recebido. Total: " + order.total().formatted() + "</p>"
        );

        Mail mail = new Mail(from, subject, to, content);

        try {
            Request request = new Request();
            request.setMethod(Method.POST);
            request.setEndpoint("mail/send");
            request.setBody(mail.build());
            sendGrid.api(request);
        } catch (IOException e) {
            throw new NotificationException("Falha ao enviar e-mail via SendGrid", e);
        }
    }

    @Override
    public void notifyOrderCancelled(Order order, String recipientEmail) {
        // similar ao acima
    }
}

// ADAPTER HTTP CLIENT — implementa StockPort chamando microserviço externo
@Component
public class StockServiceHttpAdapter implements StockPort {

    private final RestClient restClient;

    public StockServiceHttpAdapter(RestClient.Builder builder,
                                    @Value("${services.stock.url}") String stockServiceUrl) {
        this.restClient = builder.baseUrl(stockServiceUrl).build();
    }

    @Override
    public boolean isAvailable(ProductId productId, int quantity) {
        StockCheckResponse response = restClient.get()
            .uri("/api/stock/{productId}?quantity={qty}", productId.value(), quantity)
            .retrieve()
            .body(StockCheckResponse.class);

        return response != null && response.available();
    }

    @Override
    public void reserve(ProductId productId, int quantity) {
        restClient.post()
            .uri("/api/stock/reserve")
            .body(new StockReserveRequest(productId.value(), quantity))
            .retrieve()
            .toBodilessEntity();
    }

    @Override
    public void release(ProductId productId, int quantity) {
        restClient.post()
            .uri("/api/stock/release")
            .body(new StockReleaseRequest(productId.value(), quantity))
            .retrieve()
            .toBodilessEntity();
    }
}

Exemplo Completo — Serviço de Notificação com 2 Ports e 3 Adapters

// === DOMÍNIO ===

// PORT DE ENTRADA
public interface SendNotificationPort {
    void send(NotificationCommand command);
}

// PORT DE SAÍDA 1 — canal de entrega
public interface NotificationChannelPort {
    void deliver(String recipient, String subject, String body);
}

// PORT DE SAÍDA 2 — registro de notificações enviadas
public interface NotificationLogPort {
    void log(NotificationRecord record);
    List<NotificationRecord> findByRecipient(String recipient);
}

// ENTIDADE DE DOMÍNIO
public class Notification {
    private final NotificationId id;
    private final String recipient;
    private final String subject;
    private final String body;
    private final NotificationType type;
    private final Instant sentAt;

    public static Notification create(NotificationCommand cmd) {
        if (cmd.recipient() == null || cmd.recipient().isBlank()) {
            throw new DomainException("Destinatário é obrigatório");
        }
        return new Notification(
            NotificationId.generate(),
            cmd.recipient(),
            cmd.subject(),
            cmd.body(),
            cmd.type(),
            Instant.now()
        );
    }
}

// SERVIÇO DE DOMÍNIO — implementa port de entrada, usa ports de saída
public class NotificationService implements SendNotificationPort {

    private final NotificationChannelPort channel;
    private final NotificationLogPort log;

    public NotificationService(NotificationChannelPort channel, NotificationLogPort log) {
        this.channel = channel;
        this.log = log;
    }

    @Override
    public void send(NotificationCommand command) {
        Notification notification = Notification.create(command);

        channel.deliver(notification.recipient(), notification.subject(), notification.body());

        log.log(new NotificationRecord(
            notification.id().value(),
            notification.recipient(),
            notification.sentAt(),
            NotificationStatus.SENT
        ));
    }
}

// === ADAPTERS ===

// ADAPTER PRIMÁRIO 1 — REST
@RestController
@RequestMapping("/api/notifications")
public class NotificationHttpAdapter {
    private final SendNotificationPort sendNotification;

    @PostMapping
    public ResponseEntity<Void> send(@RequestBody NotificationRequest request) {
        sendNotification.send(new NotificationCommand(
            request.recipient(),
            request.subject(),
            request.body(),
            NotificationType.valueOf(request.type())
        ));
        return ResponseEntity.accepted().build();
    }
}

// ADAPTER PRIMÁRIO 2 — Kafka consumer
@Component
public class NotificationKafkaAdapter {
    private final SendNotificationPort sendNotification;

    @KafkaListener(topics = "notifications.requested")
    public void onNotificationRequested(@Payload String payload) {
        NotificationRequestedEvent event = deserialize(payload);
        sendNotification.send(new NotificationCommand(
            event.recipient(), event.subject(), event.body(), event.type()
        ));
    }
}

// ADAPTER SECUNDÁRIO 1 — Email via SendGrid
@Component
public class SendGridChannelAdapter implements NotificationChannelPort {
    private final SendGrid sendGrid;

    @Override
    public void deliver(String recipient, String subject, String body) {
        // ... SendGrid API call
    }
}

// ADAPTER SECUNDÁRIO 2 — SMS via Twilio
@Component
@Profile("sms")
public class TwilioChannelAdapter implements NotificationChannelPort {
    private final Twilio twilio;

    @Override
    public void deliver(String recipient, String subject, String body) {
        // recipient como número de telefone
        Message.creator(new PhoneNumber(recipient), new PhoneNumber("+5511..."), body).create();
    }
}

// ADAPTER SECUNDÁRIO 3 — Log em banco de dados
@Repository
public class JpaNotificationLogAdapter implements NotificationLogPort {
    private final SpringDataNotificationRepo jpaRepo;

    @Override
    public void log(NotificationRecord record) {
        jpaRepo.save(NotificationEntity.from(record));
    }

    @Override
    public List<NotificationRecord> findByRecipient(String recipient) {
        return jpaRepo.findByRecipient(recipient).stream()
            .map(NotificationEntity::toRecord)
            .toList();
    }
}

Testando o Domínio sem Infraestrutura

A principal vantagem da Arquitetura Hexagonal é que o domínio é testável sem banco, sem HTTP, sem Kafka — apenas lógica pura.

class NotificationServiceTest {

    // FAKES — implementações em memória para testes
    static class InMemoryNotificationChannel implements NotificationChannelPort {
        private final List<String> deliveredTo = new ArrayList<>();

        @Override
        public void deliver(String recipient, String subject, String body) {
            deliveredTo.add(recipient);
        }

        public List<String> deliveredTo() { return deliveredTo; }
    }

    static class InMemoryNotificationLog implements NotificationLogPort {
        private final List<NotificationRecord> records = new ArrayList<>();

        @Override
        public void log(NotificationRecord record) { records.add(record); }

        @Override
        public List<NotificationRecord> findByRecipient(String recipient) {
            return records.stream()
                .filter(r -> r.recipient().equals(recipient))
                .toList();
        }

        public int count() { return records.size(); }
    }

    private final InMemoryNotificationChannel channel = new InMemoryNotificationChannel();
    private final InMemoryNotificationLog log = new InMemoryNotificationLog();
    private final NotificationService service = new NotificationService(channel, log);

    @Test
    void shouldDeliverAndLogNotification() {
        NotificationCommand cmd = new NotificationCommand(
            "cliente@email.com", "Pedido confirmado", "Seu pedido foi processado.",
            NotificationType.EMAIL
        );

        service.send(cmd);

        assertThat(channel.deliveredTo()).containsExactly("cliente@email.com");
        assertThat(log.count()).isEqualTo(1);
    }

    @Test
    void shouldRejectNotificationWithoutRecipient() {
        NotificationCommand cmd = new NotificationCommand(
            "", "Assunto", "Corpo", NotificationType.EMAIL
        );

        assertThatThrownBy(() -> service.send(cmd))
            .isInstanceOf(DomainException.class)
            .hasMessage("Destinatário é obrigatório");

        assertThat(channel.deliveredTo()).isEmpty();
        assertThat(log.count()).isZero();
    }
}

Comparação com Clean Architecture

Hexagonal e Clean Architecture compartilham o mesmo princípio fundamental (domínio isolado de infraestrutura) mas têm terminologias e ênfases diferentes.

Hexagonal Architecture          Clean Architecture
─────────────────────          ───────────────────
Port de entrada          ≈     Use Case (interface)
Port de saída            ≈     Gateway / Repository (interface)
Adapter primário         ≈     Interface Adapter (controller)
Adapter secundário       ≈     Interface Adapter (gateway/repo)
Domínio (hexágono)       ≈     Entities + Use Cases
Sem camadas explícitas         4 camadas concêntricas formais

Diferenças práticas:
- Hexagonal é mais simples de explicar: "tudo é port ou adapter"
- Clean Architecture é mais prescritiva sobre o que vai em cada camada
- Clean Architecture distingue explicitamente Use Cases de Entities
- Hexagonal agrupa tudo no "domínio" sem essa distinção formal
- Na prática, as duas se complementam — muitos projetos usam as nomenclaturas intercambiadas

Regra de ouro: independente do nome, o que importa é que o domínio não importe nada de infraestrutura. Se o pacote domain não tiver imports de Spring, JPA, Kafka ou qualquer lib externa, você está no caminho certo.