Pular para o conteúdo

Exemplo Hexagonal com Spring

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.java
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));
}
}
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;
}
}
package com.exemplo.banco.domain;
public class SaldoInsuficienteException extends RuntimeException {
public SaldoInsuficienteException(String numeroConta) {
super("Saldo insuficiente na conta " + numeroConta);
}
}
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) {}
}
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);
}
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);
}
}
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();
}
}

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;
@RestControllerAdvice
public 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());
}
}
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));
}
}
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;
@Component
public 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();
}
}
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;
@Configuration
public 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 em BeansConfig, o que evita espalhar estereótipos do Spring pelo núcleo. Há, porém, uma exceção consciente: o @Transactional importado em TransferenciaService. 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 de application. Quem quiser pureza total pode movê-la para um decorator na infraestrutura que implementa TransferirUseCase, abre a transação e delega ao service — mais uma classe em troca de zero framework no núcleo.

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
  • @Service no 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 ContaJpaEntity no núcleo faz o domínio importar jakarta.persistence e amarra o modelo à tabela.
  • Regra de saldo no controller ou no adapter JPA: o debitar/creditar deve viver na entidade Conta, não na borda.
  • Transação no adapter em vez do caso de uso: o @Transactional pertence 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.

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 TransferirUseCaseTransferenciaService → entidade Conta para a regra e driven port ContaRepositoryContaJpaAdapter (adapter secundário) → banco. O controle flui para fora, mas todas as dependências de código apontam para o núcleo.