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.
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 EventosE aqui está a diferença que mais confunde no começo:
| Tipo | Exemplo no projeto | Quem chama? | Quem implementa? |
|---|---|---|---|
| Porta de entrada | JoinMatchUseCase | MatchController | JoinMatchService |
| Porta de saída | MatchRepository | JoinMatchService | MatchPersistenceAdapter |
| Porta de saída | DomainEventPublisher | Casos de uso | SpringDomainEventPublisher |
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:
| Camada | Pode depender de | Evite trazer para cá |
|---|---|---|
domain | Java e tipos do próprio domínio | Spring, JPA, HTTP, banco |
application | domain e ports | adapters, controllers, repositories JPA |
ports | linguagem do domínio e comandos/queries | detalhes de infraestrutura |
adapters | portas, domínio e frameworks | regra de negócio nova |
config | todas as peças que precisa conectar | regra 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.
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.
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.
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.