HenriqueMarino.github.io
ArquiteturaHexagonalDDDBackend

Arquitetura Hexagonal na prática: Portas e Adaptadores em um sistema de Matchmaking

Como manter regras de negócio longe de banco, framework e mensageria usando Java e Spring Boot.

05 de junho de 202617 min de leitura

Se você já criou um projeto Spring Boot, talvez conheça esta cena.

Você cria um modelo de domínio, adiciona @Entity em cima da classe, enche os campos com @Column, @Id e @GeneratedValue e, quando percebe, a regra mais importante da sua empresa está ditada pelas limitações do Hibernate. Para piorar, injeta o @Autowired direto no meio da lógica e mistura tratamento HTTP com lógica de negócios.

O resultado é aquele acoplamento que parece inofensivo no começo, mas cobra juros depois. Trocar de banco, atualizar uma biblioteca ou rodar testes unitários rápidos começa a doer mais do que deveria.

A Arquitetura Hexagonal (ou Portas e Adaptadores) ataca esse ponto. Ela não é sobre criar pastas bonitas; é sobre isolar o coração do sistema para que as regras continuem funcionando do mesmo jeito, independentemente de quem chamou a operação ou de onde os dados foram salvos.

Para ver como isso funciona na prática, vamos analisar o núcleo de um sistema de matchmaking em tempo real (uma plataforma para parear jogadores em partidas online).


A analogia: Restaurante e fornecedores

Imagine um restaurante.

  • A cozinha e as receitas são o Domínio.
  • A lista de ingredientes exigidos é a Porta.
  • Os funcionários que recebem e conferem os ingredientes são os Adaptadores.
  • Os fornecedores são o banco de dados, APIs, filas etc. (a infraestrutura).

A receita não muda se você trocar o fornecedor de tomates. Desde que os ingredientes cheguem conforme a especificação, a cozinha funciona da mesma forma.


O Mapa do Matchmaking

No nosso sistema de matchmaking, o objetivo é criar partidas, receber jogadores, balancear os times pelo nível de habilidade skillRating, começar o jogo e avisar o mundo sobre as mudanças.

A estrutura de pacotes do projeto segue essa divisão de forma rígida:

com.henriquemarino.matchmaking
├── domain          # O coração: regras puras (sem Spring, sem JPA)
│   ├── model       # Match (aggregate root), Player, GameMode...
│   └── events      # MatchCreatedEvent, PlayerJoinedMatchEvent...
├── application     # Orquestrador dos fluxos (Java puro, sem @Service)
│   └── usecases    # CreateMatchService, JoinMatchService...
├── ports           # Os contratos (interfaces)
│   ├── inbound     # O que o mundo de fora pode pedir (Use Cases)
│   └── outbound    # O que a aplicação precisa pedir ao mundo (Repository)
├── adapters        # Os funcionários que recebem os insumos (Adaptadores)
│   ├── inbound     # Controllers REST (HTTP -> API)
│   └── outbound    # JPA / PostgreSQL (Banco) e Spring Events (Mensageria)
└── config          # A central de injeção que une portas e adaptadores

A regra de ouro aqui é unidirecional: as dependências sempre apontam para dentro.

O domínio está no centro e não conhece ninguém. A aplicação conhece o domínio e as portas. Os adaptadores conhecem as portas e o domínio. O pacote config conhece todo mundo para conseguir costurar as dependências na inicialização.

Antes de entrar nos diagramas, guarde esta bússola:

HTTP -> Controller -> Porta de Entrada -> Caso de Uso -> Domínio
     -> Porta de Saída -> Adaptador JPA -> Banco
     -> Porta de Saída -> Adaptador de Eventos

E aqui está a diferença que mais confunde no começo:

TipoExemplo no projetoQuem chama?Quem implementa?
Porta de entradaJoinMatchUseCaseMatchControllerJoinMatchService
Porta de saídaMatchRepositoryJoinMatchServiceMatchPersistenceAdapter
Porta de saídaDomainEventPublisherCasos de usoSpringDomainEventPublisher

As portas de entrada dizem o que o sistema aceita receber. As portas de saída dizem do que o sistema precisa para terminar o trabalho.

Se bater dúvida sobre onde uma classe deveria morar, esta tabela resolve boa parte dos casos:

CamadaPode depender deEvite trazer para cá
domainJava e tipos do próprio domínioSpring, JPA, HTTP, banco
applicationdomain e portsadapters, controllers, repositories JPA
portslinguagem do domínio e comandos/queriesdetalhes de infraestrutura
adaptersportas, domínio e frameworksregra de negócio nova
configtodas as peças que precisa conectarregra de negócio

O Fluxo de uma Ação

O que acontece no sistema quando um jogador pede para entrar em uma partida?

Vamos dividir esse caminho em três etapas. Primeiro o sistema recebe a intenção e busca o estado atual. Depois o domínio aplica as regras. Por fim, a aplicação salva o novo estado, publica os eventos e devolve a resposta.

Etapa 1Recepção e Busca do Estado (Leitura)

Chegada da requisição REST, tradução em Command e reidratação do agregado a partir do banco de dados.

O que está acontecendo no código: O ponto de partida é o MatchController (Adaptador de Entrada REST), que recebe os parâmetros da API e monta um JoinMatchCommand. Ele chama a porta de entrada JoinMatchUseCase. O JoinMatchService assume o fluxo e pede a partida para a porta de saída MatchRepository. Quem atende essa porta é o MatchPersistenceAdapter, que usa Spring Data JPA e o MatchPersistenceMapper para transformar a entidade do banco (MatchJpaEntity) em uma instância limpa de Match. Esse processo de reconstruir o agregado em memória é a reidratação.

Etapa 2Processamento de Regras e Persistência (Escrita)

Validação de invariantes de negócio na camada de domínio puro e persistência da partida modificada.

O que está acontecendo no código: O JoinMatchService não decide se o jogador pode entrar. Ele chama match.join(player) e deixa o domínio trabalhar. A classe Match verifica se o jogador já está na partida, se ainda há vaga e se o status permite entrada. Quando tudo passa, ela adiciona o jogador e registra um PlayerJoinedMatchEvent. Só depois disso a aplicação chama matchRepository.save(match), e o adaptador de persistência traduz o modelo de domínio atualizado para a entidade JPA.

Etapa 3Publicação de Eventos e Resposta (Efeitos Colaterais)

Coleta de eventos gerados, publicação por meio das portas outbound e tradução da resposta REST.

O que está acontecendo no código: Depois de salvar, o JoinMatchService chama match.pullDomainEvents(). Esse método copia os eventos acumulados e limpa a lista interna, evitando publicação duplicada. A lista vai para a porta de saída DomainEventPublisher. Hoje, quem implementa essa porta é o SpringDomainEventPublisher, que publica no ApplicationEventPublisher do Spring. A resposta volta ao MatchController, que usa o MatchRestMapper para transformar domínio em MatchResponse.


Olhando o Código de Perto

Vamos ver como esse isolamento se traduz em classes reais do projeto.

1. O coração limpo: Match.java (Domínio)

Aqui estão as regras do jogo. Repare nos imports: não há Spring, não há JPA. É Java puro.

O construtor é privado, então ninguém cria uma partida "pela metade". Existem dois caminhos explícitos: create, usado quando uma partida nasce agora, e rehydrate, usado quando o banco devolve os dados de uma partida que já existia.

A lista de eventos também fica dentro do agregado. O domínio registra que algo aconteceu, mas não publica nada sozinho. Publicar é trabalho da camada de aplicação por meio de uma porta.

O recorte abaixo omite alguns campos de tempo e resultado para manter o foco nas regras principais.

package com.henriquemarino.matchmaking.domain.model;
 
import com.henriquemarino.matchmaking.domain.events.DomainEvent;
import com.henriquemarino.matchmaking.domain.events.MatchCreatedEvent;
import com.henriquemarino.matchmaking.domain.events.PlayerJoinedMatchEvent;
import com.henriquemarino.matchmaking.domain.exceptions.InvalidMatchStateException;
import com.henriquemarino.matchmaking.domain.exceptions.MatchFullException;
import com.henriquemarino.matchmaking.domain.exceptions.PlayerAlreadyInMatchException;
 
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
 
public class Match {
    private final MatchId id;
    private final GameMode gameMode;
    private MatchStatus status;
    private final List<Player> players;
    private final List<DomainEvent> domainEvents = new ArrayList<>();
 
    private Match(MatchId id, GameMode gameMode, MatchStatus status, List<Player> players) {
        this.id = id;
        this.gameMode = gameMode;
        this.status = status;
        this.players = players;
    }
 
    public static Match create(MatchId id, GameMode gameMode) {
        Objects.requireNonNull(id, "MatchId não pode ser nulo");
        Objects.requireNonNull(gameMode, "GameMode não pode ser nulo");
 
        Match match = new Match(id, gameMode, MatchStatus.WAITING_FOR_PLAYERS, new ArrayList<>());
        match.recordEvent(MatchCreatedEvent.of(id, gameMode));
        return match;
    }
 
    public static Match rehydrate(
            MatchId id,
            GameMode gameMode,
            MatchStatus status,
            List<Player> players
    ) {
        return new Match(id, gameMode, status, new ArrayList<>(players));
    }
 
    public void join(Player player) {
        Objects.requireNonNull(player, "Player não pode ser nulo");
 
        if (players.contains(player)) {
            throw new PlayerAlreadyInMatchException(id, player.id());
        }
        if (status == MatchStatus.READY) {
            throw new MatchFullException(id, gameMode.capacity());
        }
        if (status != MatchStatus.WAITING_FOR_PLAYERS) {
            throw new InvalidMatchStateException(id, status, "entrar em");
        }
 
        players.add(player);
        recordEvent(PlayerJoinedMatchEvent.of(id, player.id(), players.size(), gameMode.capacity()));
 
        if (players.size() == gameMode.capacity()) {
            status = MatchStatus.READY;
        }
    }
 
    public List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> pulled = List.copyOf(domainEvents);
        domainEvents.clear();
        return pulled;
    }
 
    private void recordEvent(DomainEvent event) {
        domainEvents.add(event);
    }
}

2. A Porta (Lista de Ingredientes): MatchRepository.java

Nossa aplicação precisa salvar partidas, mas ela não pode saber como. Criamos uma interface na camada de ports/outbound que fala a linguagem do domínio:

package com.henriquemarino.matchmaking.ports.outbound;
 
import com.henriquemarino.matchmaking.domain.model.Match;
import com.henriquemarino.matchmaking.domain.model.MatchId;
import java.util.Optional;
 
public interface MatchRepository {
    Match save(Match match);
    Optional<Match> findById(MatchId matchId);
 
    // ... além das consultas de histórico (findFinishedMatches, findFinishedMatchesByPlayer)
}

3. O Organizador (Caso de Uso): JoinMatchService.java

A classe que orquestra a entrada do jogador não tem @Service do Spring. Ela recebe suas dependências pelo construtor, sempre pelas portas. Isso facilita muito o teste: no lugar de um banco real, podemos passar um repositório em memória.

package com.henriquemarino.matchmaking.application.usecases;
 
import com.henriquemarino.matchmaking.application.commands.JoinMatchCommand;
import com.henriquemarino.matchmaking.domain.exceptions.MatchNotFoundException;
import com.henriquemarino.matchmaking.domain.model.Match;
import com.henriquemarino.matchmaking.domain.model.Player;
import com.henriquemarino.matchmaking.ports.inbound.JoinMatchUseCase;
import com.henriquemarino.matchmaking.ports.outbound.DomainEventPublisher;
import com.henriquemarino.matchmaking.ports.outbound.MatchRepository;
 
public class JoinMatchService implements JoinMatchUseCase {
 
    private final MatchRepository matchRepository;
    private final DomainEventPublisher eventPublisher;
 
    public JoinMatchService(MatchRepository matchRepository, DomainEventPublisher eventPublisher) {
        this.matchRepository = matchRepository;
        this.eventPublisher = eventPublisher;
    }
 
    @Override
    public Match join(JoinMatchCommand command) {
        Match match = matchRepository.findById(command.matchId())
                .orElseThrow(() -> new MatchNotFoundException(command.matchId()));
 
        match.join(Player.join(command.playerId(), command.skillRating()));
 
        Match saved = matchRepository.save(match);
        eventPublisher.publishAll(match.pullDomainEvents());
 
        return saved;
    }
}

Perceba a divisão: o caso de uso define a ordem dos passos, mas não decide se o jogador pode entrar. Essa decisão fica no Match.

4. Onde o skillRating entra: SkillBasedTeamBalancingStrategy.java

Até aqui, vimos o jogador entrando na partida. Mas matchmaking não é só preencher uma sala; também é montar times minimamente justos.

No projeto, essa regra fica em um serviço de domínio: SkillBasedTeamBalancingStrategy. O Match sabe quando pode começar. A estratégia sabe como distribuir jogadores entre os times.

public void start(TeamBalancingStrategy balancingStrategy) {
    if (status != MatchStatus.READY) {
        throw new InvalidMatchStateException(id, status, "iniciar");
    }
 
    balancingStrategy.balance(players);
    status = MatchStatus.IN_PROGRESS;
    startedAt = Instant.now();
    recordEvent(MatchStartedEvent.of(id, startedAt));
}

E a estratégia usa o skillRating para tentar equilibrar os lados:

public class SkillBasedTeamBalancingStrategy implements TeamBalancingStrategy {
 
    @Override
    public void balance(List<Player> players) {
        List<Player> ordered = new ArrayList<>(players);
        ordered.sort(Comparator.comparingInt(Player::skillRating).reversed());
 
        int skillOfTeamA = 0;
        int skillOfTeamB = 0;
        int sizeOfTeamA = 0;
        int sizeOfTeamB = 0;
        int halfSize = ordered.size() / 2;
 
        for (Player player : ordered) {
            boolean teamAisFull = sizeOfTeamA == halfSize;
            boolean teamBisFull = sizeOfTeamB == halfSize;
            boolean goToTeamA = !teamAisFull && (teamBisFull || skillOfTeamA <= skillOfTeamB);
 
            if (goToTeamA) {
                player.assignTeam(Team.TEAM_A);
                skillOfTeamA += player.skillRating();
                sizeOfTeamA++;
            } else {
                player.assignTeam(Team.TEAM_B);
                skillOfTeamB += player.skillRating();
                sizeOfTeamB++;
            }
        }
    }
}

Esse é um bom exemplo de domínio rico sem virar bagunça. A regra de estado continua dentro de Match. A regra de balanceamento, que é uma política própria e pode mudar com o tempo, fica em uma estratégia separada.

5. O Adaptador (Funcionário/Conferente): MatchPersistenceAdapter.java

Aqui a infraestrutura entra. Este adaptador fica em adapters/outbound/persistence. Ele implementa a interface MatchRepository (porta) usando a infraestrutura do Spring Data JPA.

Para evitar que anotações do banco poluam o domínio, temos classes separadas: Match (do domínio) e MatchJpaEntity (do banco). O mapper faz a conversão entre as duas.

package com.henriquemarino.matchmaking.adapters.outbound.persistence;
 
import com.henriquemarino.matchmaking.domain.model.Match;
import com.henriquemarino.matchmaking.ports.outbound.MatchRepository;
import org.springframework.stereotype.Repository;
 
@Repository
public class MatchPersistenceAdapter implements MatchRepository {
 
    private final SpringDataMatchRepository jpaRepository;
    private final MatchPersistenceMapper mapper;
 
    public MatchPersistenceAdapter(SpringDataMatchRepository jpaRepository, MatchPersistenceMapper mapper) {
        this.jpaRepository = jpaRepository;
        this.mapper = mapper;
    }
 
    @Override
    public Match save(Match match) {
        // Converte o modelo puro de domínio para a entidade anotada com JPA
        MatchJpaEntity entity = jpaRepository.findById(match.id().value())
                .map(existing -> {
                    mapper.updateEntity(existing, match);
                    return existing;
                })
                .orElseGet(() -> mapper.toNewEntity(match));
 
        // Salva de fato no banco e converte de volta para o domínio
        return mapper.toDomain(jpaRepository.save(entity));
    }
 
    // ... findById
}

6. O ganho aparece nos testes

Até aqui, a separação pode parecer um custo: interfaces, mappers, comandos, adaptadores. O ponto em que ela começa a pagar é no teste.

No projeto, os testes dos casos de uso não precisam subir PostgreSQL, container, contexto Spring nem servidor HTTP. Eles montam o caso de uso com implementações simples das portas:

@BeforeEach
void setUp() {
    repository = new InMemoryMatchRepository();
    publisher = new RecordingEventPublisher();
 
    createMatch = new CreateMatchService(new MatchFactory(), repository, publisher);
    joinMatch = new JoinMatchService(repository, publisher);
    startMatch = new StartMatchService(repository, new SkillBasedTeamBalancingStrategy(), publisher);
    finishMatch = new FinishMatchService(repository, publisher);
    getHistory = new GetMatchHistoryService(repository);
}

Esse teste conta a história inteira da partida:

@Test
void fullLifecycleShouldEndInHistory() {
    Match created = createMatch.create(new CreateMatchCommand(GameMode.SOLO_DUEL));
    MatchId matchId = created.id();
    PlayerId playerOne = PlayerId.newId();
 
    joinMatch.join(new JoinMatchCommand(matchId, playerOne, 100));
    Match ready = joinMatch.join(new JoinMatchCommand(matchId, PlayerId.newId(), 80));
    assertThat(ready.status()).isEqualTo(MatchStatus.READY);
 
    Match started = startMatch.start(new StartMatchCommand(matchId));
    assertThat(started.status()).isEqualTo(MatchStatus.IN_PROGRESS);
    assertThat(started.players()).allMatch(p -> p.team().isPlayable());
 
    Match finished = finishMatch.finish(new FinishMatchCommand(matchId, Team.TEAM_A, "GG"));
    assertThat(finished.status()).isEqualTo(MatchStatus.FINISHED);
 
    assertThat(getHistory.getHistory(GetMatchHistoryQuery.forPlayer(playerOne))).hasSize(1);
}

Esse é o tipo de teste que eu gosto de ver em domínio com regra. Ele lê quase como uma especificação: cria, recebe jogadores, fica pronto, começa, termina e aparece no histórico.


E se precisarmos do Kafka?

Hoje, o sistema publica eventos localmente (usando o ApplicationEventPublisher do Spring) para que um listener simples grave logs.

Mas e se amanhã a plataforma crescer e precisarmos mandar esses eventos para o Apache Kafka para avisar outros microsserviços (como o de ranking e de conquistas)?

Em uma arquitetura acoplada, esse tipo de mudança costuma espalhar Kafka por lugares onde Kafka não deveria aparecer: services de negócio, entidades, métodos que antes só cuidavam da partida.

Aqui, o caminho é mais estreito. O domínio continua registrando eventos. A aplicação continua chamando DomainEventPublisher. O que muda é o adaptador por trás dessa porta:

@Component
public class KafkaDomainEventPublisher implements DomainEventPublisher {
    private final KafkaTemplate<String, Object> kafkaTemplate;
 
    public KafkaDomainEventPublisher(KafkaTemplate<String, Object> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }
 
    @Override
    public void publishAll(List<DomainEvent> events) {
        events.forEach(event -> kafkaTemplate.send("matchmaking-events", event));
    }
}

Depois disso, a configuração escolhe qual implementação será injetada: a atual, baseada em Spring Events, ou a nova, baseada em Kafka. O ponto importante é o que não muda: Match.java, JoinMatchService.java e a porta DomainEventPublisher continuam falando a mesma língua.

A receita e a cozinha continuaram as mesmas. Mudamos o fornecedor.


Quando NÃO usar?

Arquitetura Hexagonal tem preço: mais arquivos, mais interfaces e mais lugares para navegar. Em troca, você ganha isolamento, testes mais leves e liberdade para trocar infraestrutura com menos trauma.

Se o sistema é um CRUD simples, onde a regra é basicamente validar campos e salvar no banco, talvez isso seja arquitetura demais para problema de menos. Você vai gastar energia criando DTOs, commands, entities, mappers e interfaces sem receber tanto de volta.

Use a Arquitetura Hexagonal quando:

  • O domínio tem regras complexas, mutáveis e que precisam de testes unitários rápidos.
  • O ecossistema de infraestrutura ao redor é incerto (o banco pode mudar, a forma de mensageria pode evoluir).
  • Você precisa proteger o ativo mais valioso da empresa: as regras de negócio.

Resumo de bolso

Se eu tivesse que reduzir tudo a poucas frases, ficaria assim:

  • O domínio guarda as regras e não importa framework.
  • A aplicação coordena os passos, mas não inventa regra de negócio.
  • As portas são contratos: o que entra e o que precisa sair.
  • Os adaptadores traduzem HTTP, banco, eventos e qualquer outra infraestrutura.
  • A camada de config junta as peças sem contaminar o miolo.

No matchmaking, essa separação fica bem concreta: o Match protege o estado da partida, os casos de uso conduzem o fluxo, o JPA fica do lado de fora e os eventos podem sair por Spring Events hoje ou por Kafka amanhã. O desenho só vale a pena porque as regras da partida merecem essa proteção.