Hexagonal Architecture — also known as Ports and Adapters — was introduced by Alistair Cockburn with a deceptively simple idea: the application core should have no knowledge of the outside world. Databases, HTTP, message brokers, UI frameworks — all of these are details that should be pluggable from the outside, not baked into your business logic.A Arquitetura Hexagonal — também conhecida como Ports and Adapters — foi introduzida por Alistair Cockburn com uma ideia deceptivamente simples: o núcleo da aplicação não deve ter conhecimento do mundo externo. Bancos de dados, HTTP, message brokers, frameworks de UI — todos são detalhes que devem ser plugáveis por fora, não embutidos na lógica de negócio.
After applying this pattern across several enterprise Java projects, I've found it to be one of the highest-leverage architectural decisions a team can make — not because it produces "clean" code in the aesthetic sense, but because it dramatically reduces the cost of change and makes the system genuinely testable without infrastructure.Após aplicar este padrão em vários projetos Java corporativos, descobri que é uma das decisões arquiteturais de maior alavancagem que uma equipe pode tomar — não porque produz código "limpo" no sentido estético, mas porque reduz dramaticamente o custo de mudança e torna o sistema genuinamente testável sem infraestrutura.
The Core IdeaA Ideia Central
The application is organized in three concentric layers:A aplicação é organizada em três camadas concêntricas:
HTTP request
event trigger
command
Port
CoreNúcleo
de Domínio
Domain ServicesEntidades · Casos de Uso
Serviços de Domínio
Port
database
messaging
external API
- Domain: Pure business logic. No framework annotations, no database imports. Entities, value objects, domain services. Can be tested with plain unit tests.Domínio: Lógica de negócio pura. Sem anotações de framework, sem imports de banco de dados. Entidades, value objects, serviços de domínio. Pode ser testado com testes unitários simples.
- Ports: Interfaces that define what the application needs (output ports) or what it offers (input ports). The core owns these interfaces.Portas: Interfaces que definem o que a aplicação precisa (portas de saída) ou o que ela oferece (portas de entrada). O núcleo é dono dessas interfaces.
- Adapters: Concrete implementations of ports. A JPA adapter implements the repository port. A REST controller calls the use case port. Adapters depend on the core — never the other way around.Adaptadores: Implementações concretas das portas. Um adaptador JPA implementa a porta de repositório. Um controller REST chama a porta de caso de uso. Adaptadores dependem do núcleo — nunca o contrário.
Project Structure in Spring BootEstrutura de Projeto no Spring Boot
src/main/java/com/example/order/
├── domain/
│ ├── model/
│ │ ├── Order.java # Aggregate root — pure Java, no JPA
│ │ ├── OrderItem.java
│ │ └── OrderStatus.java # Value object
│ └── service/
│ └── OrderDomainService.java
│
├── application/
│ ├── port/
│ │ ├── in/
│ │ │ ├── PlaceOrderUseCase.java # Input port (interface)
│ │ │ └── GetOrderUseCase.java
│ │ └── out/
│ │ ├── OrderRepository.java # Output port (interface)
│ │ └── PaymentGateway.java
│ └── service/
│ └── PlaceOrderService.java # Use case implementation
│
└── adapter/
├── in/
│ └── web/
│ └── OrderController.java # REST adapter (calls input port)
└── out/
├── persistence/
│ ├── OrderJpaRepository.java
│ └── OrderPersistenceAdapter.java # Implements OrderRepository port
└── messaging/
└── PaymentGatewayAdapter.java # Implements PaymentGateway portDefining PortsDefinindo Portas
Ports are plain Java interfaces owned by the application layer. They express intent, not implementation.Portas são interfaces Java simples pertencentes à camada de aplicação. Elas expressam intenção, não implementação.
// Input port — what the application offers to the outside world
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand command);
}
// Output port — what the application needs from the outside world
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
public interface PaymentGateway {
PaymentResult charge(CustomerId customerId, Money amount);
}The Domain Model: No Framework ContaminationO Modelo de Dominio: Sem Contaminacao de Framework
The most important discipline: domain entities must not import anything from Spring, JPA, or any other framework. They are plain Java objects that encode business rules.A disciplina mais importante: entidades de domínio não devem importar nada do Spring, JPA ou qualquer outro framework. São objetos Java simples que codificam regras de negócio.
// Pure domain entity — no @Entity, no @Column, no Spring annotations
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
private Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
this.id = id;
this.customerId = customerId;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
}
public static Order create(CustomerId customerId, List<OrderItem> items) {
if (items.isEmpty()) throw new DomainException("Order must have at least one item");
return new Order(OrderId.generate(), customerId, items);
}
public void confirm() {
if (this.status != OrderStatus.PENDING)
throw new DomainException("Only pending orders can be confirmed");
this.status = OrderStatus.CONFIRMED;
}
public Money totalAmount() {
return items.stream()
.map(OrderItem::subtotal)
.reduce(Money.ZERO, Money::add);
}
}Use Case ImplementationImplementacao de Caso de Uso
The use case orchestrates the domain and calls output ports. It has no knowledge of HTTP, JPA, or Kafka — only the interfaces.O caso de uso orquestra o domínio e chama as portas de saída. Não tem conhecimento de HTTP, JPA ou Kafka — apenas das interfaces.
@Service
@Transactional
@RequiredArgsConstructor
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orderRepository; // output port — injected
private final PaymentGateway paymentGateway; // output port — injected
@Override
public OrderId placeOrder(PlaceOrderCommand command) {
Order order = Order.create(command.customerId(), command.items());
PaymentResult payment = paymentGateway.charge(
command.customerId(), order.totalAmount()
);
if (!payment.isSuccessful()) {
throw new PaymentFailedException(payment.reason());
}
order.confirm();
orderRepository.save(order);
return order.getId();
}
}Adapters: Connecting to the InfrastructureAdaptadores: Conectando a Infraestrutura
Persistence Adapter (JPA)Adaptador de Persistencia (JPA)
// JPA entity — separate from the domain entity
@Entity
@Table(name = "orders")
class OrderJpaEntity {
@Id String id;
String customerId;
String status;
// ... JPA-specific fields
}
// The adapter translates between domain model and JPA entity
@Component
@RequiredArgsConstructor
class OrderPersistenceAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
@Override
public void save(Order order) {
jpaRepository.save(mapper.toJpaEntity(order));
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomainEntity);
}
}REST Adapter (Spring MVC)Adaptador REST (Spring MVC)
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final PlaceOrderUseCase placeOrderUseCase; // input port
@PostMapping
public ResponseEntity<PlaceOrderResponse> placeOrder(
@RequestBody @Valid PlaceOrderRequest request) {
PlaceOrderCommand command = PlaceOrderCommand.from(request);
OrderId orderId = placeOrderUseCase.placeOrder(command);
return ResponseEntity
.created(URI.create("/api/orders/" + orderId.value()))
.body(new PlaceOrderResponse(orderId.value()));
}
}The Testability PayoffO Retorno da Testabilidade
The real return on this investment shows up in the test suite. Because the domain has no infrastructure dependencies, unit tests are fast, focused, and free of Spring context startup.O verdadeiro retorno desse investimento aparece na suite de testes. Como o domínio não tem dependências de infraestrutura, os testes unitários são rápidos, focados e livres da inicialização do contexto Spring.
class PlaceOrderServiceTest {
// No Spring context, no database, no Kafka
private final OrderRepository orderRepository = mock(OrderRepository.class);
private final PaymentGateway paymentGateway = mock(PaymentGateway.class);
private final PlaceOrderService service =
new PlaceOrderService(orderRepository, paymentGateway);
@Test
void shouldConfirmOrderWhenPaymentSucceeds() {
given(paymentGateway.charge(any(), any()))
.willReturn(PaymentResult.success());
OrderId orderId = service.placeOrder(validCommand());
assertThat(orderId).isNotNull();
verify(orderRepository).save(argThat(o ->
o.getStatus() == OrderStatus.CONFIRMED
));
}
@Test
void shouldThrowWhenPaymentFails() {
given(paymentGateway.charge(any(), any()))
.willReturn(PaymentResult.failure("Insufficient funds"));
assertThatThrownBy(() -> service.placeOrder(validCommand()))
.isInstanceOf(PaymentFailedException.class);
verify(orderRepository, never()).save(any());
}
}Result: The entire use case logic runs in milliseconds with no infrastructure. Integration tests (using Testcontainers) are reserved for verifying the adapters — not the business rules. This separation typically reduces test suite runtime by 60–80% compared to testing everything through the Spring context.Resultado: Toda a lógica do caso de uso roda em milissegundos sem infraestrutura. Testes de integração (usando Testcontainers) são reservados para verificar os adaptadores — não as regras de negócio. Essa separação tipicamente reduz o tempo de execução da suite de testes em 60–80% comparado a testar tudo pelo contexto Spring.
When Not to Use ItQuando Nao Usar
Hexagonal Architecture has a real cost: more files, more interfaces, more mapping code. It is the right trade-off when:A Arquitetura Hexagonal tem um custo real: mais arquivos, mais interfaces, mais código de mapeamento. É o trade-off correto quando:
- The domain logic is complex enough to warrant isolation.A lógica de domínio é complexa o suficiente para justificar o isolamento.
- The team needs to test business rules independently of infrastructure.A equipe precisa testar regras de negócio independentemente da infraestrutura.
- You expect to swap or add adapters (e.g., adding a Kafka consumer alongside the REST API).Você espera trocar ou adicionar adaptadores (ex: adicionar um consumer Kafka ao lado da API REST).
For CRUD-heavy microservices with minimal business logic, a simpler layered architecture (controller → service → repository) will often serve better. Hexagonal architecture is a scalpel, not a hammer.Para microsserviços centrados em CRUD com lógica de negócio mínima, uma arquitetura em camadas mais simples (controller → service → repository) frequentemente serve melhor. Arquitetura hexagonal é um bisturi, não um martelo.
Key TakeawaysPontos Principais
- The domain model must be free of framework annotations — test it with plain unit tests.O modelo de domínio deve ser livre de anotações de framework — teste-o com testes unitários simples.
- Ports are interfaces owned by the application core; adapters are implementations owned by the infrastructure layer.Portas são interfaces pertencentes ao núcleo da aplicação; adaptadores são implementações pertencentes à camada de infraestrutura.
- Dependency always flows inward: adapters depend on the core, never the reverse.A dependência sempre flui para dentro: adaptadores dependem do núcleo, nunca o contrário.
- Keep domain entities and JPA entities separate — map between them in the persistence adapter.Mantenha entidades de domínio e entidades JPA separadas — mapeie entre elas no adaptador de persistência.
- The payoff is testability: full use case coverage with no Spring context, no database, no message broker.O retorno é a testabilidade: cobertura completa do caso de uso sem contexto Spring, sem banco de dados, sem message broker.