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ínioPortas 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 sistemaPortas 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 intercambiadasRegra 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.