Exemplo Hexagonal com Spring
Estrutura de pacotes
Seção intitulada “Estrutura de pacotes”com.exemplo.banco├── domain│ ├── Conta.java│ ├── Dinheiro.java│ └── SaldoInsuficienteException.java├── application│ ├── port│ │ ├── in│ │ │ └── TransferirUseCase.java│ │ └── out│ │ └── ContaRepository.java│ └── service│ └── TransferenciaService.java└── infrastructure ├── rest │ ├── TransferenciaRestController.java │ └── RestExceptionHandler.java ├── persistence │ ├── ContaJpaAdapter.java │ ├── ContaJpaEntity.java │ └── ContaJpaRepository.java └── config └── BeansConfig.javaDomínio
Seção intitulada “Domínio”Value Object Dinheiro
Seção intitulada “Value Object Dinheiro”package com.exemplo.banco.domain;
import java.math.BigDecimal;import java.util.Objects;
public record Dinheiro(BigDecimal valor) {
public Dinheiro { Objects.requireNonNull(valor, "valor nao pode ser nulo"); if (valor.signum() < 0) { throw new IllegalArgumentException("valor nao pode ser negativo"); } }
public static Dinheiro de(BigDecimal valor) { return new Dinheiro(valor); }
public boolean menorQue(Dinheiro outro) { return this.valor.compareTo(outro.valor) < 0; }
public Dinheiro somar(Dinheiro outro) { return new Dinheiro(this.valor.add(outro.valor)); }
public Dinheiro subtrair(Dinheiro outro) { return new Dinheiro(this.valor.subtract(outro.valor)); }}Entidade Conta
Seção intitulada “Entidade Conta”package com.exemplo.banco.domain;
import java.util.Objects;
public class Conta {
private final String numero; private final String titular; private Dinheiro saldo;
public Conta(String numero, String titular, Dinheiro saldo) { this.numero = Objects.requireNonNull(numero); this.titular = Objects.requireNonNull(titular); this.saldo = Objects.requireNonNull(saldo); }
public void debitar(Dinheiro valor) { if (saldo.menorQue(valor)) { throw new SaldoInsuficienteException(numero); } this.saldo = saldo.subtrair(valor); }
public void creditar(Dinheiro valor) { this.saldo = saldo.somar(valor); }
public String numero() { return numero; }
public String titular() { return titular; }
public Dinheiro saldo() { return saldo; }}Exceção de domínio
Seção intitulada “Exceção de domínio”package com.exemplo.banco.domain;
public class SaldoInsuficienteException extends RuntimeException {
public SaldoInsuficienteException(String numeroConta) { super("Saldo insuficiente na conta " + numeroConta); }}Application
Seção intitulada “Application”Driving port TransferirUseCase
Seção intitulada “Driving port TransferirUseCase”package com.exemplo.banco.application.port.in;
import java.math.BigDecimal;
public interface TransferirUseCase {
void transferir(ComandoTransferencia comando);
record ComandoTransferencia(String origem, String destino, BigDecimal valor) {}}Driven port ContaRepository
Seção intitulada “Driven port ContaRepository”package com.exemplo.banco.application.port.out;
import com.exemplo.banco.domain.Conta;import java.util.Optional;
public interface ContaRepository {
Optional<Conta> porNumero(String numero);
Conta salvar(Conta conta);}Application service TransferenciaService
Seção intitulada “Application service TransferenciaService”package com.exemplo.banco.application.service;
import com.exemplo.banco.application.port.in.TransferirUseCase;import com.exemplo.banco.application.port.out.ContaRepository;import com.exemplo.banco.domain.Conta;import com.exemplo.banco.domain.Dinheiro;import java.util.NoSuchElementException;import org.springframework.transaction.annotation.Transactional;
public class TransferenciaService implements TransferirUseCase {
private final ContaRepository contaRepository;
public TransferenciaService(ContaRepository contaRepository) { this.contaRepository = contaRepository; }
@Override @Transactional public void transferir(ComandoTransferencia comando) { Conta origem = contaRepository.porNumero(comando.origem()) .orElseThrow(() -> new NoSuchElementException("Conta origem inexistente")); Conta destino = contaRepository.porNumero(comando.destino()) .orElseThrow(() -> new NoSuchElementException("Conta destino inexistente"));
Dinheiro valor = Dinheiro.de(comando.valor());
origem.debitar(valor); destino.creditar(valor);
contaRepository.salvar(origem); contaRepository.salvar(destino); }}Infrastructure
Seção intitulada “Infrastructure”Adapter primário: controller REST
Seção intitulada “Adapter primário: controller REST”package com.exemplo.banco.infrastructure.rest;
import com.exemplo.banco.application.port.in.TransferirUseCase;import com.exemplo.banco.application.port.in.TransferirUseCase.ComandoTransferencia;import java.math.BigDecimal;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;
@RestController@RequestMapping("/transferencias")public class TransferenciaRestController {
private final TransferirUseCase transferirUseCase;
public TransferenciaRestController(TransferirUseCase transferirUseCase) { this.transferirUseCase = transferirUseCase; }
public record TransferenciaRequest(String origem, String destino, BigDecimal valor) {}
@PostMapping public ResponseEntity<Void> transferir(@RequestBody TransferenciaRequest req) { transferirUseCase.transferir( new ComandoTransferencia(req.origem(), req.destino(), req.valor())); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); }}Adapter primário: tradução de erros
Seção intitulada “Adapter primário: tradução de erros”O núcleo lança SaldoInsuficienteException e NoSuchElementException sem saber que HTTP existe. Converter exceção de domínio em status code é tradução de protocolo — trabalho do adapter primário:
package com.exemplo.banco.infrastructure.rest;
import com.exemplo.banco.domain.SaldoInsuficienteException;import java.util.NoSuchElementException;import org.springframework.http.HttpStatus;import org.springframework.http.ProblemDetail;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvicepublic class RestExceptionHandler {
@ExceptionHandler(SaldoInsuficienteException.class) public ProblemDetail saldoInsuficiente(SaldoInsuficienteException excecao) { return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, excecao.getMessage()); }
@ExceptionHandler(NoSuchElementException.class) public ProblemDetail contaInexistente(NoSuchElementException excecao) { return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, excecao.getMessage()); }}Adapter secundário: entidade JPA
Seção intitulada “Adapter secundário: entidade JPA”package com.exemplo.banco.infrastructure.persistence;
import com.exemplo.banco.domain.Conta;import com.exemplo.banco.domain.Dinheiro;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.math.BigDecimal;
@Entity@Table(name = "conta")public class ContaJpaEntity {
@Id @Column(name = "numero") private String numero;
@Column(name = "titular") private String titular;
@Column(name = "saldo") private BigDecimal saldo;
protected ContaJpaEntity() { }
public ContaJpaEntity(String numero, String titular, BigDecimal saldo) { this.numero = numero; this.titular = titular; this.saldo = saldo; }
public static ContaJpaEntity deDominio(Conta conta) { return new ContaJpaEntity(conta.numero(), conta.titular(), conta.saldo().valor()); }
public Conta paraDominio() { return new Conta(numero, titular, Dinheiro.de(saldo)); }}Adapter secundário: repositório Spring Data
Seção intitulada “Adapter secundário: repositório Spring Data”package com.exemplo.banco.infrastructure.persistence;
import java.util.Optional;import org.springframework.data.jpa.repository.JpaRepository;
public interface ContaJpaRepository extends JpaRepository<ContaJpaEntity, String> {
Optional<ContaJpaEntity> findByNumero(String numero);}Adapter secundário: implementação da driven port
Seção intitulada “Adapter secundário: implementação da driven port”package com.exemplo.banco.infrastructure.persistence;
import com.exemplo.banco.application.port.out.ContaRepository;import com.exemplo.banco.domain.Conta;import java.util.Optional;import org.springframework.stereotype.Component;
@Componentpublic class ContaJpaAdapter implements ContaRepository {
private final ContaJpaRepository jpaRepository;
public ContaJpaAdapter(ContaJpaRepository jpaRepository) { this.jpaRepository = jpaRepository; }
@Override public Optional<Conta> porNumero(String numero) { return jpaRepository.findByNumero(numero).map(ContaJpaEntity::paraDominio); }
@Override public Conta salvar(Conta conta) { return jpaRepository.save(ContaJpaEntity.deDominio(conta)).paraDominio(); }}Configuração: montando o núcleo com Spring
Seção intitulada “Configuração: montando o núcleo com Spring”package com.exemplo.banco.infrastructure.config;
import com.exemplo.banco.application.port.in.TransferirUseCase;import com.exemplo.banco.application.port.out.ContaRepository;import com.exemplo.banco.application.service.TransferenciaService;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configurationpublic class BeansConfig {
@Bean public TransferirUseCase transferirUseCase(ContaRepository contaRepository) { return new TransferenciaService(contaRepository); }}O application service não é anotado com
@Service: ele é um bean puro registrado emBeansConfig, o que evita espalhar estereótipos do Spring pelo núcleo. Há, porém, uma exceção consciente: o@Transactionalimportado emTransferenciaService. A atomicidade é regra do caso de uso, e a anotação é a forma pragmática de obtê-la — ao preço de um import do Spring dentro deapplication. Quem quiser pureza total pode movê-la para um decorator na infraestrutura que implementaTransferirUseCase, abre a transação e delega ao service — mais uma classe em troca de zero framework no núcleo.
Fluxo de uma requisição
Seção intitulada “Fluxo de uma requisição”sequenceDiagram
actor Cliente
participant Ctrl as TransferenciaRestController<br/>(adapter primario)
participant UC as TransferirUseCase<br/>(driving port)
participant Svc as TransferenciaService<br/>(application service)
participant Dom as Conta<br/>(dominio)
participant Repo as ContaRepository<br/>(driven port)
participant Jpa as ContaJpaAdapter<br/>(adapter secundario)
participant DB as Banco de Dados
Cliente->>Ctrl: POST /transferencias
Ctrl->>UC: transferir(ComandoTransferencia)
UC-->>Svc: (implementacao)
Svc->>Repo: porNumero(origem)
Repo-->>Jpa: (implementacao)
Jpa->>DB: SELECT
DB-->>Jpa: linha
Jpa-->>Svc: Optional<Conta>
Svc->>Repo: porNumero(destino)
Repo-->>Jpa: (implementacao)
Jpa->>DB: SELECT
DB-->>Jpa: linha
Jpa-->>Svc: Optional<Conta>
Svc->>Dom: origem.debitar(valor)
Svc->>Dom: destino.creditar(valor)
Svc->>Repo: salvar(origem) e salvar(destino)
Repo-->>Jpa: (implementacao)
Jpa->>DB: UPDATE
Svc-->>Ctrl: retorno
Ctrl-->>Cliente: 204 No Content
Anti-padrões e erros comuns
Seção intitulada “Anti-padrões e erros comuns”@Serviceno application service: anotar o núcleo com Spring reacopla domínio ao framework; prefira registrar o bean na configuração da infraestrutura.- Entidade JPA como objeto de domínio: reusar
ContaJpaEntityno núcleo faz o domínio importarjakarta.persistencee amarra o modelo à tabela. - Regra de saldo no controller ou no adapter JPA: o
debitar/creditardeve viver na entidadeConta, não na borda. - Transação no adapter em vez do caso de uso: o
@Transactionalpertence ao service que orquestra a operação de negócio, garantindo atomicidade da transferência. - Controller conhecendo
ContaRepository: pular a driving port e chamar a persistência direto elimina o isolamento do núcleo.
Relações
Seção intitulada “Relações”- Concretiza os conceitos de Ports e Adapters.
- Ilustra os papéis de Driving e Driven.
Dinheiroé um Value Object;ContaRepositoryrealiza um Repositório do Domain-Driven Design.- Inversão sustentada por DIP.
- Este núcleo testado sem Spring: Testando o Núcleo.
- Volte ao índice Arquitetura Hexagonal.
Perguntas de revisão
Seção intitulada “Perguntas de revisão”1. Por que o TransferenciaService não recebe @Service?
Resposta
Para evitar estereótipos do Spring no núcleo: ele é registrado como bean puro em BeansConfig, na infraestrutura, e permanece um POJO testável sem contexto Spring. A exceção consciente é o @Transactional — a única marca de framework tolerada em application neste exemplo, discutida no callout de BeansConfig.
2. Qual o papel do ContaJpaEntity e por que ele não é a Conta de domínio?
Resposta
É a entidade de persistência, com anotações jakarta.persistence, mapeando a tabela. Separá-la da Conta evita que o domínio importe JPA e desacopla o modelo de negócio do esquema do banco. A conversão acontece no adapter (deDominio/paraDominio).
3. Onde vive a regra de “não permitir saldo negativo” e por quê?
Resposta
No método debitar da entidade Conta, que lança SaldoInsuficienteException. A regra é invariante do agregado e deve residir no núcleo, não no controller nem no adapter JPA, garantindo que qualquer caminho de entrada a respeite.
4. Seguindo o sequenceDiagram, em que ordem uma requisição atravessa as camadas?
Resposta
Cliente → controller REST (adapter primário) → driving port TransferirUseCase → TransferenciaService → entidade Conta para a regra e driven port ContaRepository → ContaJpaAdapter (adapter secundário) → banco. O controle flui para fora, mas todas as dependências de código apontam para o núcleo.