Blog Post

Desenvolvedores BR
10 MIN READ

De Layered para Hexagonal

wdossantos's avatar
wdossantos
Icon for Microsoft rankMicrosoft
Nov 14, 2026

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

 

ConceitoO que éExemplo
Port (Porta)Uma interface Java que define um contratoConsultarEstoqueUseCase, ErpPort
Adapter (Adaptador)Uma classe que implementa um portStockController, ErpAdapter
Domain (Domínio)A lógica de negócio pura, sem dependências de frameworkConsultarEstoqueService

Driving vs Driven (Entrada vs Saída)

TipoDireçãoQuem inicia?Exemplos
Driving (IN)De fora → para dentroO mundo externo chama a aplicaçãoController REST, Consumer Kafka, CLI
Driven (OUT)De dentro → para foraA aplicação chama o mundo externoRepository, 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

ProblemaSintoma
Acoplamento verticalController importa classes do Repository
Domínio anêmicoService vira "pass-through" sem regras claras
DTOs espalhadosO mesmo DTO serve para HTTP, banco e integração
Difícil de testarPara testar o Service, precisa mockar toda a infra
Difícil de trocar tecnologiaMudar 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

ComponenteTecnologia
LinguagemJava 21
FrameworkSpring Boot 3.x
BuildMaven
SDK ERPSDK proprietário (ErpConnector)
ContainerDocker (UBI8 + OpenJDK 21)
OrquestraçãoKubernetes (AKS)
DeployArgoCD + Helm
TestesJUnit 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

#ProblemaOnde
1DTOs de integração (protocolo ERP) moram em domain/dtodomain/ não deveria ter detalhes de infra
2adapter/ contém apenas a saída (ERP), mas é confuso pois controller também é um adapteradapter/
3mappers/ são de integração mas ficam no nível raizmappers/
4StockResponseDTO (HTTP) mora em domain/domain/
5Não existem ports — tudo depende de implementações concretasTodo o projeto
6Service depende diretamente de ErpConnectorImplAcoplamento

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 originalDestinoMotivo
controller/v1/StockController.javaadapter/in/api/rest/controller/v1/Controller é um adapter de entrada
controller/v1/StockSwagger.javaadapter/in/api/rest/controller/v1/Acompanha o controller
domain/dto/.../StockResponseDTO.javaadapter/in/api/rest/dto/DTO HTTP é detalhe do adapter
adapter/ErpConnectorImpl.javaadapter/out/erp/ErpAdapter.javaAdapter de saída
adapter/ConnectorStatusHandler.javaadapter/out/erp/Detalhe do SDK
domain/dto/.../connector/request/*.javaadapter/out/erp/dto/DTOs de integração são do adapter
domain/dto/.../connector/response/*.javaadapter/out/erp/dto/DTOs de integração são do adapter
mappers/StockRequestMapper.javaadapter/out/erp/mapper/Converte domínio → infra
mappers/StockResponseMapper.javaadapter/out/erp/mapper/Converte infra → domínio
service/v1/StockServiceImpl.javaapplication/service/ConsultarEstoqueService.javaLógica central
exception/StockBusinessException.javadomain/exception/Exceção é regra de domínio
exception/handler/RestExceptionHandler.javadomain/exception/handler/Handler global

Arquivos NOVOS

ArquivoTipoPropósito
ConsultarEstoqueUseCase.javaPort INInterface que define o que a aplicação faz
ErpPort.javaPort OUTInterface 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?

PerguntaAdapter INAdapter OUT
Quem inicia a ação?O mundo externo chama a aplicaçãoA aplicação chama o mundo externo
O port é implementado por quem?Pelo Service (domínio)Pelo Adapter (infra)
Direção da dependênciaController → Port ← ServiceService → Port ← Adapter
Se eu trocar...Posso trocar REST por gRPC sem mudar o domínioPosso 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 de application/ importa classes de adapter/.
Nada dentro de application/ 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/.

ClasseMotivo de existirOnde mora
STKREQRequest.javaFormato do protocolo do ERPadapter/out/erp/dto/
StockResponseDTO.javaFormato JSON da API RESTadapter/in/api/rest/dto/
StockBusinessException.javaRegra: produto inexistente é errodomain/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ícioAntes (Layered)Depois (Hexagonal)
Para trocar o ERP por APIMexe em Service + Mappers + DTOsCria novo Adapter OUT, o resto não muda
Para adicionar gRPCCria controller, repete lógicaCria novo Adapter IN, usa mesmo UseCase
Para testar o domínioPrecisa mockar SDK do ERPMocka 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

Updated May 14, 2026
Version 1.0