Se você já manteve por tempo suficiente uma aplicação Spring Boot, provavelmente já viu regra de negócio, controller e entidade JPA misturados no mesmo lugar. Funciona, até o dia em que você precisa trocar o banco de dados, escrever um teste unitário decente ou simplesmente entender onde termina a lógica do negócio e começa o framework. Foi justamente esse tipo de dor que me fez estudar a fundo a Hexagonal Architecture, e neste artigo eu quero mostrar como apliquei os conceitos numa aplicação Spring Boot real.
O que é Hexagonal Architecture
Hexagonal Architecture (também chamada de Ports and Adapters) foi proposta por Alistair Cockburn no início dos anos 2000. A ideia central é simples: o núcleo da aplicação (as regras de negócio) não deve depender de nada externo. Nem do banco de dados, nem do framework web, nem de nenhuma biblioteca de terceiros.
Para isso, o núcleo define portas (interfaces) que descrevem o que ele precisa ou o que ele oferece. O mundo externo se conecta a essas portas através de adaptadores, que implementam a integração concreta com uma tecnologia específica.
Existem dois tipos de porta:
- Portas de entrada (input ports): definem os casos de uso que a aplicação oferece. Um controller REST, por exemplo, é um adaptador que aciona uma porta de entrada.
- Portas de saída (output ports): definem o que o núcleo precisa do mundo externo, como persistir dados ou chamar um serviço de terceiros. Um repositório JPA é um adaptador que implementa uma porta de saída.
O resultado prático é que o domínio nunca importa nada do Spring, do Hibernate ou de qualquer outra infraestrutura. Ele só conhece interfaces.
Por que aplicar isso num projeto Spring Boot
Spring Boot facilita demais criar tudo em cima do framework: @Entity na mesma classe que representa a regra de negócio, @Service chamando @Repository diretamente, tudo acoplado. Funciona bem no começo, mas cobra caro conforme o sistema cresce:
- Testar regra de negócio exige subir contexto Spring ou banco de dados
- Trocar de fornecedor (banco, fila, API externa) vira uma reforma, não uma troca de peça
- A lógica de negócio fica espalhada entre controllers, services e entidades
Hexagonal Architecture resolve isso isolando o domínio de qualquer decisão de infraestrutura. O trade-off é mais código de “encanamento” (interfaces, mapeamentos), vale a pena principalmente em domínios complexos e sistemas de vida longa. Para um CRUD simples que não deve crescer, pode ser complexidade desnecessária. Isso eu abordo melhor no fechamento.
Estrutura de pastas
Essa é a estrutura que uso como ponto de partida:
src/main/java/com/exemplo/pedidos/
├── domain/
│ └── Pedido.java # entidade de domínio, sem anotações de framework
├── application/
│ ├── port/
│ │ ├── in/
│ │ │ └── CriarPedidoUseCase.java # porta de entrada
│ │ └── out/
│ │ └── PedidoRepositoryPort.java # porta de saída
│ └── service/
│ └── CriarPedidoService.java # implementa o caso de uso
└── adapter/
├── in/
│ └── web/
│ └── PedidoController.java # adaptador de entrada (REST)
└── out/
└── persistence/
├── PedidoJpaEntity.java
├── PedidoJpaRepository.java
└── PedidoRepositoryAdapter.java # adaptador de saída (JPA)
Repare que domain não depende de nada. application depende só de domain. Só os adapter conhecem Spring, JPA e HTTP.
Implementando na prática
Vamos usar um exemplo simples: criar um pedido.
1. O domínio, sem nenhuma anotação de framework:
package com.exemplo.pedidos.domain;
import java.math.BigDecimal;
public class Pedido {
private final String id;
private final String clienteId;
private final BigDecimal valorTotal;
public Pedido(String id, String clienteId, BigDecimal valorTotal) {
if (valorTotal.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Valor do pedido deve ser positivo");
}
this.id = id;
this.clienteId = clienteId;
this.valorTotal = valorTotal;
}
public String getId() { return id; }
public String getClienteId() { return clienteId; }
public BigDecimal getValorTotal() { return valorTotal; }
}
2. A porta de entrada — o contrato do caso de uso:
package com.exemplo.pedidos.application.port.in;
import com.exemplo.pedidos.domain.Pedido;
import java.math.BigDecimal;
public interface CriarPedidoUseCase {
Pedido criar(String clienteId, BigDecimal valorTotal);
}
3. A porta de saída — o que o domínio precisa da infraestrutura:
package com.exemplo.pedidos.application.port.out;
import com.exemplo.pedidos.domain.Pedido;
public interface PedidoRepositoryPort {
Pedido salvar(Pedido pedido);
}
4. O serviço de aplicação, que implementa o caso de uso:
package com.exemplo.pedidos.application.service;
import com.exemplo.pedidos.application.port.in.CriarPedidoUseCase;
import com.exemplo.pedidos.application.port.out.PedidoRepositoryPort;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.UUID;
@Service
public class CriarPedidoService implements CriarPedidoUseCase {
private final PedidoRepositoryPort repository;
public CriarPedidoService(PedidoRepositoryPort repository) {
this.repository = repository;
}
@Override
public Pedido criar(String clienteId, BigDecimal valorTotal) {
Pedido pedido = new Pedido(UUID.randomUUID().toString(), clienteId, valorTotal);
return repository.salvar(pedido);
}
}
Repare que o @Service está aqui, não no domínio. Essa é a única classe da camada de aplicação que conhece Spring — e mesmo assim, só pra ser gerenciada como bean.
5. O adaptador de entrada — o controller REST:
package com.exemplo.pedidos.adapter.in.web;
import com.exemplo.pedidos.application.port.in.CriarPedidoUseCase;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/pedidos")
public class PedidoController {
private final CriarPedidoUseCase criarPedidoUseCase;
public PedidoController(CriarPedidoUseCase criarPedidoUseCase) {
this.criarPedidoUseCase = criarPedidoUseCase;
}
@PostMapping
public PedidoResponse criar(@RequestBody CriarPedidoRequest request) {
Pedido pedido = criarPedidoUseCase.criar(request.clienteId(), request.valorTotal());
return new PedidoResponse(pedido.getId(), pedido.getClienteId(), pedido.getValorTotal());
}
record CriarPedidoRequest(String clienteId, BigDecimal valorTotal) {}
record PedidoResponse(String id, String clienteId, BigDecimal valorTotal) {}
}
O controller depende da porta (CriarPedidoUseCase), nunca da implementação concreta. Isso permite, por exemplo, trocar CriarPedidoService por qualquer outra implementação sem tocar no controller.
6. O adaptador de saída — persistência com JPA:
package com.exemplo.pedidos.adapter.out.persistence;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "pedidos")
public class PedidoJpaEntity {
@Id
private String id;
private String clienteId;
private BigDecimal valorTotal;
protected PedidoJpaEntity() {}
public PedidoJpaEntity(String id, String clienteId, BigDecimal valorTotal) {
this.id = id;
this.clienteId = clienteId;
this.valorTotal = valorTotal;
}
// getters omitidos por brevidade
}
package com.exemplo.pedidos.adapter.out.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PedidoJpaRepository extends JpaRepository<PedidoJpaEntity, String> {}
package com.exemplo.pedidos.adapter.out.persistence;
import com.exemplo.pedidos.application.port.out.PedidoRepositoryPort;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.stereotype.Component;
@Component
public class PedidoRepositoryAdapter implements PedidoRepositoryPort {
private final PedidoJpaRepository jpaRepository;
public PedidoRepositoryAdapter(PedidoJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Pedido salvar(Pedido pedido) {
PedidoJpaEntity entity = new PedidoJpaEntity(
pedido.getId(), pedido.getClienteId(), pedido.getValorTotal());
jpaRepository.save(entity);
return pedido;
}
}
Note que existem duas representações do pedido: Pedido (domínio) e PedidoJpaEntity (persistência). Parece redundante, mas é exatamente isso que impede o Hibernate de vazar pra dentro da sua regra de negócio. Se amanhã o @Column precisar mudar por causa de uma particularidade do banco, o domínio nem fica sabendo.
Testando a arquitetura
O ganho mais concreto aparece na hora do teste. CriarPedidoService pode ser testado sem subir contexto Spring nenhum:
@Test
void deveCriarPedidoComValorValido() {
PedidoRepositoryPort repositoryMock = mock(PedidoRepositoryPort.class);
when(repositoryMock.salvar(any())).thenAnswer(inv -> inv.getArgument(0));
CriarPedidoService service = new CriarPedidoService(repositoryMock);
Pedido pedido = service.criar("cliente-1", new BigDecimal("150.00"));
assertEquals("cliente-1", pedido.getClienteId());
}
Esse teste roda em milissegundos, sem banco, sem Spring Context, sem mocks pesados. O adaptador de persistência, por sua vez, ganha um teste de integração à parte, usando @DataJpaTest, e o controller um @WebMvcTest, cada camada testada no nível certo.
Vantagens e quando vale a pena
Vantagens:
- Domínio isolado, testável sem infraestrutura
- Trocar tecnologia (banco, mensageria, API externa) sem tocar na regra de negócio
- Fronteiras claras: cada camada sabe exatamente o que pode e o que não pode depender
Quando pensar duas vezes:
- Projetos pequenos, com uma única integração e vida útil curta, podem sofrer mais com o excesso de interfaces e classes do que ganhar com o isolamento
- Times sem familiaridade com o padrão tendem a aplicar as camadas de forma mecânica, sem entender o “porquê”, e isso gera mais boilerplate do que benefício real
No fim, Hexagonal Architecture não é bala de prata, é uma ferramenta pra domínios que vão crescer e viver por anos. Comecei aplicando isso pensando em testabilidade, e o que eu não esperava era o quanto isso deixaria claro, pra mim e pro time, onde cada regra de negócio realmente mora. Se você está decidindo se vale a pena adotar no seu projeto, essa é a pergunta que eu faria primeiro: esse domínio vai crescer e mudar de infraestrutura ao longo do tempo, ou é algo pontual? A resposta guia a decisão melhor do que qualquer dogma de arquitetura.