Migração de arquitetura em um microserviço Spring Boot que integra com sistema legado.
De Layered para Hexagonal
Artigo técnico — Caso de uso real (nomes anonimizados) de migração de arquitetura em um microserviço Spring Boot que integra com sistema legado.
1. Introdução
Imagine que você herdou um microserviço em produção. Ele funciona bem — consulta estoque de produtos num ERP legado, devolve JSON para o e-commerce. Mas o código é organizado em camadas tradicionais (Controller → Service → Adapter), com tudo misturado: DTOs de integração moram ao lado de regras de negócio, mappers ficam soltos, e se amanhã você precisar trocar o ERP por uma API REST moderna… bem, boa sorte.
Este artigo documenta a refatoração real de um microserviço Java/Spring Boot de arquitetura Layered para Arquitetura Hexagonal (Ports & Adapters).
O que vamos demonstrar
- Como identificar os "cheiros" de uma arquitetura acoplada
- O que muda (e o que não muda) ao migrar para Hexagonal
- Cada conceito explicado como se você nunca ouviu falar de "port", "adapter", "driving" ou "driven"
- Código real: antes e depois, lado a lado
O que NÃO muda nesta refatoração
- Zero lógica de negócio nova — é reorganização de código
- Contrato HTTP — request/response JSON permanecem idênticos
- Testes — continuam passando (ajustam-se apenas os imports)
- Infraestrutura — Docker, Kubernetes, CI/CD intocados
2. O que é Arquitetura Hexagonal?
Origem
Criada por Alistair Cockburn em 2005, também chamada de Ports & Adapters. O nome "hexagonal" vem do diagrama — um hexágono no centro representando o domínio da aplicação, com portas (ports) em cada face conectando o mundo externo.
A ideia central (em uma frase)
O domínio da aplicação não deve saber COMO os dados chegam nem PARA ONDE vão — apenas O QUE fazer com eles.
Analogia do dia a dia
Pense num carregador de celular:
- O celular (domínio) precisa de energia
- A tomada da parede é um adaptador de entrada (traz energia de fora para dentro)
- O cabo USB é outro adaptador de saída (leva energia para o celular)
- A porta do celular define o formato aceito (USB-C, Lightning)
Se amanhã a tomada mudar de padrão brasileiro para americano, você troca o adaptador — o celular não precisa mudar.
Os três conceitos fundamentais
| Conceito | O que é | Exemplo |
|---|---|---|
| Port (Porta) | Uma interface Java que define um contrato | ConsultarEstoqueUseCase, ErpPort |
| Adapter (Adaptador) | Uma classe que implementa um port | StockController, ErpAdapter |
| Domain (Domínio) | A lógica de negócio pura, sem dependências de framework | ConsultarEstoqueService |
Driving vs Driven (Entrada vs Saída)
| Tipo | Direção | Quem inicia? | Exemplos |
|---|---|---|---|
| Driving (IN) | De fora → para dentro | O mundo externo chama a aplicação | Controller REST, Consumer Kafka, CLI |
| Driven (OUT) | De dentro → para fora | A aplicação chama o mundo externo | Repository, Client HTTP, SDK de ERP |
Por que separar IN e OUT?
Porque quem dirige a aplicação (entrada) e quem a aplicação dirige (saída) são responsabilidades fundamentalmente diferentes:
- IN: define como o mundo "fala" com o domínio
- OUT: define como o domínio "fala" com o mundo
Se você misturá-los, acaba com controllers importando classes do banco de dados, ou repositórios conhecendo detalhes de serialização HTTP.
3. Arquitetura Layered (Camadas) — O ponto de partida
A arquitetura em camadas é a mais comum em projetos Spring Boot. É o que a maioria dos tutoriais ensina:
Vantagens da Layered
- Simples de entender
- Rápida para prototipar
- Funciona bem para CRUDs pequenos
Problemas que surgem com o tempo
| Problema | Sintoma |
|---|---|
| Acoplamento vertical | Controller importa classes do Repository |
| Domínio anêmico | Service vira "pass-through" sem regras claras |
| DTOs espalhados | O mesmo DTO serve para HTTP, banco e integração |
| Difícil de testar | Para testar o Service, precisa mockar toda a infra |
| Difícil de trocar tecnologia | Mudar o banco afeta todas as camadas |
4. O projeto: contexto e stack
O que o serviço faz
Um ACL (Anti-Corruption Layer) que consulta estoque de produtos em um ERP legado (protocolo proprietário flat-message) e expõe como API REST (JSON). É um microserviço de "tradução" — recebe HTTP, monta uma mensagem no formato proprietário do ERP, chama via SDK, parseia a resposta e devolve JSON.
Stack tecnológica
| Componente | Tecnologia |
|---|---|
| Linguagem | Java 21 |
| Framework | Spring Boot 3.x |
| Build | Maven |
| SDK ERP | SDK proprietário (ErpConnector) |
| Container | Docker (UBI8 + OpenJDK 21) |
| Orquestração | Kubernetes (AKS) |
| Deploy | ArgoCD + Helm |
| Testes | JUnit 5 + Mockito + AssertJ |
Fluxo do serviço
5. Anatomia do código atual (Layered)
Estrutura de pacotes
com.retailcorp.stock.acl/
├── Application.java
├── adapter/
│ ├── ErpConnector.java (interface)
│ ├── ErpConnectorImpl.java (implementação)
│ └── ConnectorStatusHandler.java (handler de erros do SDK)
├── config/
│ └── SwaggerConfig.java
├── controller/
│ └── v1/
│ ├── StockController.java
│ └── StockSwagger.java
├── domain/
│ ├── dto/
│ │ └── stock/
│ │ ├── connector/
│ │ │ ├── request/
│ │ │ │ ├── StockRequest.java (message wrapper)
│ │ │ │ └── STKREQRequest.java (fields do protocolo)
│ │ │ └── response/
│ │ │ ├── StockResponse.java (message wrapper)
│ │ │ └── STKRESResponse.java (fields do protocolo)
│ │ └── http/
│ │ └── response/
│ │ └── StockResponseDTO.java (JSON de saída)
│ └── enums/
│ └── ErpTransactionsEnum.java
├── exception/
│ ├── StockBusinessException.java
│ └── handler/
│ └── RestExceptionHandler.java
├── infrastructure/
│ └── log/
│ ├── LogComponent.java
│ └── LogComponentImpl.java
├── mappers/
│ ├── StockRequestMapper.java
│ └── StockResponseMapper.java
└── service/
└── v1/
├── StockService.java (interface)
└── StockServiceImpl.java (implementação)
Total: 21 arquivos de código + 7 testes
Problemas identificados nesta estrutura
| # | Problema | Onde |
|---|---|---|
| 1 | DTOs de integração (protocolo ERP) moram em domain/dto | domain/ não deveria ter detalhes de infra |
| 2 | adapter/ contém apenas a saída (ERP), mas é confuso pois controller também é um adapter | adapter/ |
| 3 | mappers/ são de integração mas ficam no nível raiz | mappers/ |
| 4 | StockResponseDTO (HTTP) mora em domain/ | domain/ |
| 5 | Não existem ports — tudo depende de implementações concretas | Todo o projeto |
| 6 | Service depende diretamente de ErpConnectorImpl | Acoplamento |
6. O plano de refatoração
Nova estrutura (Hexagonal)
com.retailcorp.stock.acl/
├── Application.java
│
├── adapter/
│ ├── in/ ← DRIVING (quem dirige)
│ │ └── api/rest/
│ │ ├── controller/v1/
│ │ │ ├── StockController.java
│ │ │ └── StockSwagger.java
│ │ └── dto/
│ │ └── StockResponseDTO.java
│ │
│ └── out/ ← DRIVEN (quem é dirigido)
│ └── erp/
│ ├── ErpAdapter.java (implementa ErpPort)
│ ├── ConnectorStatusHandler.java
│ ├── dto/
│ │ ├── STKREQRequest.java
│ │ ├── StockRequest.java
│ │ ├── STKRESResponse.java
│ │ └── StockResponse.java
│ └── mapper/
│ ├── StockRequestMapper.java
│ └── StockResponseMapper.java
│
├── application/ ← O CORAÇÃO (domínio)
│ ├── port/
│ │ ├── in/
│ │ │ └── ConsultarEstoqueUseCase.java (port de entrada)
│ │ └── out/
│ │ └── ErpPort.java (port de saída)
│ └── service/
│ └── ConsultarEstoqueService.java (implementa o use case)
│
├── config/
│ └── SwaggerConfig.java
│
├── domain/
│ ├── enums/
│ │ └── ErpTransactionsEnum.java
│ └── exception/
│ ├── StockBusinessException.java
│ └── handler/
│ └── RestExceptionHandler.java
│
└── infrastructure/
└── log/
├── LogComponent.java
└── LogComponentImpl.java
Mapa de movimentação
| Arquivo original | Destino | Motivo |
|---|---|---|
controller/v1/StockController.java | adapter/in/api/rest/controller/v1/ | Controller é um adapter de entrada |
controller/v1/StockSwagger.java | adapter/in/api/rest/controller/v1/ | Acompanha o controller |
domain/dto/.../StockResponseDTO.java | adapter/in/api/rest/dto/ | DTO HTTP é detalhe do adapter |
adapter/ErpConnectorImpl.java | adapter/out/erp/ErpAdapter.java | Adapter de saída |
adapter/ConnectorStatusHandler.java | adapter/out/erp/ | Detalhe do SDK |
domain/dto/.../connector/request/*.java | adapter/out/erp/dto/ | DTOs de integração são do adapter |
domain/dto/.../connector/response/*.java | adapter/out/erp/dto/ | DTOs de integração são do adapter |
mappers/StockRequestMapper.java | adapter/out/erp/mapper/ | Converte domínio → infra |
mappers/StockResponseMapper.java | adapter/out/erp/mapper/ | Converte infra → domínio |
service/v1/StockServiceImpl.java | application/service/ConsultarEstoqueService.java | Lógica central |
exception/StockBusinessException.java | domain/exception/ | Exceção é regra de domínio |
exception/handler/RestExceptionHandler.java | domain/exception/handler/ | Handler global |
Arquivos NOVOS
| Arquivo | Tipo | Propósito |
|---|---|---|
ConsultarEstoqueUseCase.java | Port IN | Interface que define o que a aplicação faz |
ErpPort.java | Port OUT | Interface que abstrai a dependência do ERP |
7. Explicando cada conceito
7.1 O que é um Adapter?
Um adapter é uma classe que traduz entre dois mundos:
Mundo Externo ←→ [ADAPTER] ←→ Domínio da Aplicação
Analogia: Um tradutor simultâneo numa conferência. O palestrante fala em japonês (mundo externo), o tradutor converte para português (domínio), e a plateia entende.
No código:
// ADAPTER DE ENTRADA (in) — traduz HTTP → Domínio
@RestController
public class StockController {
private final ConsultarEstoqueUseCase useCase; // fala a "língua" do domínio
@GetMapping("/v1/stock")
public ResponseEntity<StockResponseDTO> consultar(
@RequestParam String warehouseId,
@RequestParam String sku) {
// Traduz: parâmetros HTTP → chamada de domínio
var result = useCase.consultarEstoque(warehouseId, sku);
// Traduz: domínio → DTO HTTP
return ResponseEntity.ok(result);
}
}
// ADAPTER DE SAÍDA (out) — traduz Domínio → ERP
@Component
public class ErpAdapter implements ErpPort {
private final ErpClient client; // SDK do ERP
@Override
public StockResponseDTO consultarEstoque(String warehouseId, String sku) {
// Traduz: parâmetros do domínio → mensagem do ERP
var request = StockRequestMapper.toErpRequest(warehouseId, sku);
var response = client.execute(request, new StockResponse());
// Traduz: mensagem ERP → DTO do domínio
return StockResponseMapper.toDTO(response);
}
}
7.2 O que é um Port?
Um port é uma interface Java — um contrato que diz "eu preciso que alguém faça isso, mas não me importa como".
// PORT DE ENTRADA — define o que a aplicação OFERECE ao mundo
public interface ConsultarEstoqueUseCase {
StockResponseDTO consultarEstoque(String warehouseId, String sku, String channel, String trans);
}
// PORT DE SAÍDA — define o que a aplicação PRECISA do mundo
public interface ErpPort {
StockResponseDTO consultarEstoque(String warehouseId, String sku, String channel, String trans);
}
Analogia: Um port é como uma tomada de 3 pinos. Ela define o formato, mas não se importa se a energia vem de hidroelétrica, solar ou eólica.
7.3 Por que separar IN e OUT?
| Pergunta | Adapter IN | Adapter OUT |
|---|---|---|
| Quem inicia a ação? | O mundo externo chama a aplicação | A aplicação chama o mundo externo |
| O port é implementado por quem? | Pelo Service (domínio) | Pelo Adapter (infra) |
| Direção da dependência | Controller → Port ← Service | Service → Port ← Adapter |
| Se eu trocar... | Posso trocar REST por gRPC sem mudar o domínio | Posso trocar ERP por API sem mudar o domínio |
7.4 O que é o pacote application/?
É o coração da aplicação. Contém:
- Ports — contratos que definem as fronteiras
- Services — orquestração de regras de negócio
Nada dentro deapplication/importa classes deadapter/.
Nada dentro deapplication/importa Spring MVC, Feign, SDK, JPA, etc.
7.5 O que é o pacote domain/?
Contém conceitos puros do negócio que existiriam mesmo sem computador:
- Exceções de negócio (
StockBusinessException) - Enums de domínio (
ErpTransactionsEnum) - Entidades e Value Objects (se existirem)
7.6 Por que os DTOs de integração saem do domain/?
O formato proprietário do ERP é um detalhe de infraestrutura. Se amanhã a empresa migrar o ERP legado para uma API REST moderna, esses DTOs morrem — mas o domínio continua existindo.
Regra: Se a classe existe por causa de uma tecnologia específica, ela pertence ao adapter/, não ao domain/.
| Classe | Motivo de existir | Onde mora |
|---|---|---|
STKREQRequest.java | Formato do protocolo do ERP | adapter/out/erp/dto/ |
StockResponseDTO.java | Formato JSON da API REST | adapter/in/api/rest/dto/ |
StockBusinessException.java | Regra: produto inexistente é erro | domain/exception/ |
7.7 O que muda na testabilidade?
ANTES (Layered)
@Test
void deveConsultarEstoque() {
// Preciso mockar a implementação CONCRETA do adapter
when(erpConnectorImpl.execute(any(), any())).thenReturn(...);
}
DEPOIS (Hexagonal)
@Test
void deveConsultarEstoque() {
// Mocko apenas a INTERFACE (port) — não conheço a implementação
when(erpPort.consultarEstoque("WH-01", "PRD-9921", "WEB", "")).thenReturn(...);
}
A diferença parece sutil, mas é poderosa: no segundo caso, o teste não sabe se a implementação usa ERP legado, banco de dados, ou um mock em memória. Isso permite:
- Testes unitários ultra-rápidos
- Testes de integração com implementações fake
- Substituição de tecnologia sem reescrever testes
8. Código: Antes vs Depois
8.1 Controller (Adapter IN)
ANTES
package com.retailcorp.stock.acl.controller.v1;
@RequiredArgsConstructor
@RestController
@RequestMapping("/v1/stock")
public class StockController implements StockSwagger {
// ❌ Depende diretamente do Service (implementação)
private final StockService stockService;
@Override
@GetMapping
public ResponseEntity<StockResponseDTO> consultarEstoque(
@RequestParam String warehouseId,
@RequestParam String sku,
@RequestParam String channel,
@RequestParam(required = false, defaultValue = "") String trans) {
return ResponseEntity.ok(stockService.consultarEstoque(warehouseId, sku, channel, trans));
}
}
DEPOIS
package com.retailcorp.stock.acl.adapter.in.api.rest.controller.v1;
@RequiredArgsConstructor
@RestController
@RequestMapping("/v1/stock")
public class StockController implements StockSwagger {
// ✅ Depende apenas do PORT (interface)
private final ConsultarEstoqueUseCase consultarEstoqueUseCase;
@Override
@GetMapping
public ResponseEntity<StockResponseDTO> consultarEstoque(
@RequestParam String warehouseId,
@RequestParam String sku,
@RequestParam String channel,
@RequestParam(required = false, defaultValue = "") String trans) {
var response = consultarEstoqueUseCase.consultarEstoque(warehouseId, sku, channel, trans);
return ResponseEntity.ok(response);
}
}
8.2 Port de Entrada (NOVO)
package com.retailcorp.stock.acl.application.port.in;
/**
* Port de entrada — define o contrato do caso de uso "Consultar Estoque".
* Qualquer adapter de entrada (REST, gRPC, CLI) pode usar este port.
*/
public interface ConsultarEstoqueUseCase {
StockResponseDTO consultarEstoque(String warehouseId, String sku, String channel, String trans);
}
8.3 Port de Saída (NOVO)
package com.retailcorp.stock.acl.application.port.out;
/**
* Port de saída — define o contrato para buscar estoque em um sistema externo.
* A aplicação não sabe se o estoque vem do ERP, banco ou API.
*/
public interface ErpPort {
StockResponseDTO consultarEstoque(String warehouseId, String sku, String channel, String trans);
}
8.4 Service (Application)
ANTES
package com.retailcorp.stock.acl.service.v1;
@Service
@RequiredArgsConstructor
public class StockServiceImpl implements StockService {
// ❌ Conhece TUDO: adapter, DTOs do ERP, mappers, logs...
private final ErpConnector erpConnector;
private final LogComponent logger;
@Override
public StockResponseDTO consultarEstoque(String warehouseId, String sku, String channel, String trans) {
// ❌ Monta mensagem aqui (detalhe de infra no service)
StockRequest request = StockRequestMapper.toErpRequest(warehouseId, sku, channel, trans);
StockResponse response = new StockResponse();
erpConnector.execute(request, response);
STKRESResponse resposta = response.getStkresResponse();
validarRetorno(resposta);
logger.insertSuccessLog("Consulta realizada", ErpTransactionsEnum.STOCK);
return StockResponseMapper.toDTO(response);
}
}
DEPOIS
package com.retailcorp.stock.acl.application.service;
@Service
@RequiredArgsConstructor
public class ConsultarEstoqueService implements ConsultarEstoqueUseCase {
private static final Logger LOG = LoggerFactory.getLogger(ConsultarEstoqueService.class);
// ✅ Depende apenas do PORT (interface) — não sabe que é ERP
private final ErpPort erpPort;
@Override
public StockResponseDTO consultarEstoque(String warehouseId, String sku, String channel, String trans) {
LOG.info("Consultando estoque: warehouse={}, sku={}, channel={}", warehouseId, sku, channel);
// ✅ Delega para o port — toda a lógica de protocolo fica no adapter
return erpPort.consultarEstoque(warehouseId, sku, channel, trans);
}
}
8.5 Adapter de Saída (ERP)
ANTES
package com.retailcorp.stock.acl.adapter;
// ❌ Nome genérico, sem clareza se é IN ou OUT
@Component
@RequiredArgsConstructor
public class ErpConnectorImpl implements ErpConnector {
private final ErpClient client;
private final ConnectorStatusHandler statusHandler;
@Override
public FrameworkHeaderResponse execute(FrameworkHeaderRequest request, FrameworkHeaderResponse response) {
client.flow().execute(request, response, statusHandler);
return response;
}
}
DEPOIS
package com.retailcorp.stock.acl.adapter.out.erp;
// ✅ Nome claro, implementa PORT, toda lógica de integração encapsulada aqui
@Component
@RequiredArgsConstructor
public class ErpAdapter implements ErpPort {
private final ErpClient client;
private final ConnectorStatusHandler statusHandler;
private final LogComponent logger;
private static final String RC_OK = "O";
private static final String RC_NOT_FOUND = "N";
@Override
public StockResponseDTO consultarEstoque(String warehouseId, String sku, String channel, String trans) {
// ✅ Toda a lógica de protocolo está AQUI (onde pertence)
StockRequest request = StockRequestMapper.toErpRequest(warehouseId, sku, channel, trans);
StockResponse response = new StockResponse();
client.flow().execute(request, response, statusHandler);
STKRESResponse resposta = response.getStkresResponse();
validarRetorno(resposta);
logger.insertSuccessLog("Consulta realizada", ErpTransactionsEnum.STOCK);
return StockResponseMapper.toDTO(response);
}
private void validarRetorno(STKRESResponse resposta) {
String rc = resposta.getRc();
if (RC_OK.equals(rc)) return;
if (RC_NOT_FOUND.equals(rc)) {
throw new StockBusinessException("Produto não encontrado. RC2: " + resposta.getRc2());
}
throw new StockBusinessException("Erro ERP. RC: " + rc + ", RC2: " + resposta.getRc2());
}
}
9. Conclusão
O que ganhamos
| Benefício | Antes (Layered) | Depois (Hexagonal) |
|---|---|---|
| Para trocar o ERP por API | Mexe em Service + Mappers + DTOs | Cria novo Adapter OUT, o resto não muda |
| Para adicionar gRPC | Cria controller, repete lógica | Cria novo Adapter IN, usa mesmo UseCase |
| Para testar o domínio | Precisa mockar SDK do ERP | Mocka o Port (interface simples) |
| Para novo dev entender | "Onde fica o quê?" | Estrutura autodocumentada |
Quando NÃO usar Hexagonal
- Microserviços de passo único (um CRUD simples)
- Protótipos descartáveis
- Projetos com prazo de dias e sem evolução prevista
Quando usar Hexagonal
- Serviços que integram com sistemas externos (ERPs, APIs, filas)
- Projetos que precisam ser testáveis de forma isolada
- Domínios que podem mudar de tecnologia no futuro
- Equipes grandes que precisam de fronteiras claras entre responsabilidades
Resumo visual