Pular para o conteúdo

Exemplo Clean com Spring

com.exemplo.pedidos
├── domain (1. Entities)
│ ├── Pedido.java
│ ├── ItemPedido.java
│ └── Dinheiro.java
├── application (2. Use Cases)
│ ├── port
│ │ ├── in
│ │ │ ├── CriarPedidoInputPort.java
│ │ │ ├── CriarPedidoRequest.java
│ │ │ └── ItemRequest.java
│ │ └── out
│ │ ├── CriarPedidoOutputPort.java
│ │ └── PedidoGateway.java
│ ├── model
│ │ └── PedidoResponse.java
│ └── usecase
│ └── CriarPedidoInteractor.java
└── infrastructure (3 e 4. Adapters + Frameworks/Drivers)
├── web
│ ├── PedidoController.java
│ └── PedidoPresenter.java
├── persistence
│ ├── PedidoJpaEntity.java
│ ├── ItemPedidoJpaEntity.java
│ ├── PedidoJpaRepository.java
│ └── PedidoGatewayJpaAdapter.java
└── config
└── BeanConfig.java

CriarPedidoOutputPort mora em port/out ao lado do gateway de propósito: ambos são contratos que o interactor chama — driven ports, na terminologia da Arquitetura Hexagonal —, enquanto port/in reúne o que o mundo externo chama. A diferença entre os dois é de papel: o gateway pede um serviço (persistir), o output port entrega o resultado a quem apresenta.

package com.exemplo.pedidos.domain;
import java.math.BigDecimal;
public record Dinheiro(BigDecimal valor) {
public Dinheiro {
if (valor == null || valor.signum() < 0) {
throw new IllegalArgumentException("Valor monetario invalido");
}
}
public Dinheiro somar(Dinheiro outro) {
return new Dinheiro(this.valor.add(outro.valor));
}
public Dinheiro multiplicar(int fator) {
return new Dinheiro(this.valor.multiply(BigDecimal.valueOf(fator)));
}
public static Dinheiro zero() {
return new Dinheiro(BigDecimal.ZERO);
}
}
package com.exemplo.pedidos.domain;
public class ItemPedido {
private final String produtoId;
private final int quantidade;
private final Dinheiro precoUnitario;
public ItemPedido(String produtoId, int quantidade, Dinheiro precoUnitario) {
if (produtoId == null || produtoId.isBlank()) {
throw new IllegalArgumentException("Produto obrigatorio");
}
if (quantidade <= 0) {
throw new IllegalArgumentException("Quantidade deve ser positiva");
}
this.produtoId = produtoId;
this.quantidade = quantidade;
this.precoUnitario = precoUnitario;
}
public Dinheiro subtotal() {
return precoUnitario.multiplicar(quantidade);
}
public String getProdutoId() {
return produtoId;
}
public int getQuantidade() {
return quantidade;
}
public Dinheiro getPrecoUnitario() {
return precoUnitario;
}
}
package com.exemplo.pedidos.domain;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
public class Pedido {
private final String id;
private final String clienteId;
private final List<ItemPedido> itens;
private StatusPedido status;
public Pedido(String clienteId, List<ItemPedido> itens) {
if (clienteId == null || clienteId.isBlank()) {
throw new IllegalArgumentException("Cliente obrigatorio");
}
if (itens == null || itens.isEmpty()) {
throw new IllegalArgumentException("Pedido deve ter ao menos um item");
}
this.id = UUID.randomUUID().toString();
this.clienteId = clienteId;
this.itens = new ArrayList<>(itens);
this.status = StatusPedido.CRIADO;
}
public Pedido(String id, String clienteId, List<ItemPedido> itens, StatusPedido status) {
this.id = id;
this.clienteId = clienteId;
this.itens = new ArrayList<>(itens);
this.status = status;
}
public Dinheiro total() {
return itens.stream()
.map(ItemPedido::subtotal)
.reduce(Dinheiro.zero(), Dinheiro::somar);
}
public void confirmar() {
if (status != StatusPedido.CRIADO) {
throw new IllegalStateException("Somente pedidos criados podem ser confirmados");
}
this.status = StatusPedido.CONFIRMADO;
}
public String getId() {
return id;
}
public String getClienteId() {
return clienteId;
}
public List<ItemPedido> getItens() {
return Collections.unmodifiableList(itens);
}
public StatusPedido getStatus() {
return status;
}
}
package com.exemplo.pedidos.domain;
public enum StatusPedido {
CRIADO,
CONFIRMADO,
CANCELADO
}
package com.exemplo.pedidos.application.port.in;
public interface CriarPedidoInputPort {
void executar(CriarPedidoRequest request);
}
package com.exemplo.pedidos.application.port.in;
import java.util.List;
public record CriarPedidoRequest(String clienteId, List<ItemRequest> itens) {}
package com.exemplo.pedidos.application.port.in;
import java.math.BigDecimal;
public record ItemRequest(String produtoId, int quantidade, BigDecimal precoUnitario) {}
package com.exemplo.pedidos.application.port.out;
import com.exemplo.pedidos.application.model.PedidoResponse;
public interface CriarPedidoOutputPort {
void apresentar(PedidoResponse response);
}
package com.exemplo.pedidos.application.model;
import java.math.BigDecimal;
public record PedidoResponse(String pedidoId, String status, BigDecimal total) {}
package com.exemplo.pedidos.application.port.out;
import com.exemplo.pedidos.domain.Pedido;
public interface PedidoGateway {
void salvar(Pedido pedido);
}
package com.exemplo.pedidos.application.usecase;
import com.exemplo.pedidos.application.model.PedidoResponse;
import com.exemplo.pedidos.application.port.in.CriarPedidoInputPort;
import com.exemplo.pedidos.application.port.in.CriarPedidoRequest;
import com.exemplo.pedidos.application.port.out.CriarPedidoOutputPort;
import com.exemplo.pedidos.application.port.out.PedidoGateway;
import com.exemplo.pedidos.domain.Dinheiro;
import com.exemplo.pedidos.domain.ItemPedido;
import com.exemplo.pedidos.domain.Pedido;
import java.util.List;
public class CriarPedidoInteractor implements CriarPedidoInputPort {
private final PedidoGateway gateway;
private final CriarPedidoOutputPort outputPort;
public CriarPedidoInteractor(PedidoGateway gateway, CriarPedidoOutputPort outputPort) {
this.gateway = gateway;
this.outputPort = outputPort;
}
@Override
public void executar(CriarPedidoRequest request) {
List<ItemPedido> itens = request.itens().stream()
.map(i -> new ItemPedido(i.produtoId(), i.quantidade(), new Dinheiro(i.precoUnitario())))
.toList();
Pedido pedido = new Pedido(request.clienteId(), itens);
pedido.confirmar();
gateway.salvar(pedido);
PedidoResponse response = new PedidoResponse(
pedido.getId(),
pedido.getStatus().name(),
pedido.total().valor());
outputPort.apresentar(response);
}
}

3 e 4. Infraestrutura (Interface Adapters + Frameworks & Drivers)

Seção intitulada “3 e 4. Infraestrutura (Interface Adapters + Frameworks & Drivers)”
package com.exemplo.pedidos.infrastructure.web;
import com.exemplo.pedidos.application.model.PedidoResponse;
import com.exemplo.pedidos.application.port.out.CriarPedidoOutputPort;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;
import org.springframework.web.context.WebApplicationContext;
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class PedidoPresenter implements CriarPedidoOutputPort {
private PedidoResponse response;
@Override
public void apresentar(PedidoResponse response) {
this.response = response;
}
public PedidoResponse getResponse() {
return response;
}
}
package com.exemplo.pedidos.infrastructure.web;
import com.exemplo.pedidos.application.model.PedidoResponse;
import com.exemplo.pedidos.application.port.in.CriarPedidoInputPort;
import com.exemplo.pedidos.application.port.in.CriarPedidoRequest;
import com.exemplo.pedidos.application.port.in.ItemRequest;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
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("/pedidos")
public class PedidoController {
private final ObjectProvider<CriarPedidoInputPort> inputPortProvider;
private final ObjectProvider<PedidoPresenter> presenterProvider;
public PedidoController(ObjectProvider<CriarPedidoInputPort> inputPortProvider,
ObjectProvider<PedidoPresenter> presenterProvider) {
this.inputPortProvider = inputPortProvider;
this.presenterProvider = presenterProvider;
}
@PostMapping
public ResponseEntity<PedidoResponse> criar(@RequestBody CriarPedidoHttpRequest body) {
List<ItemRequest> itens = body.itens().stream()
.map(i -> new ItemRequest(i.produtoId(), i.quantidade(), i.precoUnitario()))
.toList();
CriarPedidoRequest request = new CriarPedidoRequest(body.clienteId(), itens);
PedidoPresenter presenter = presenterProvider.getObject();
CriarPedidoInputPort inputPort = inputPortProvider.getObject();
inputPort.executar(request);
return ResponseEntity.status(HttpStatus.CREATED).body(presenter.getResponse());
}
public record CriarPedidoHttpRequest(String clienteId, List<ItemHttp> itens) {}
public record ItemHttp(String produtoId, int quantidade, BigDecimal precoUnitario) {}
}
package com.exemplo.pedidos.infrastructure.persistence;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "pedidos")
public class PedidoJpaEntity {
@Id
private String id;
@Column(nullable = false)
private String clienteId;
@Column(nullable = false)
private String status;
@OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<ItemPedidoJpaEntity> itens = new ArrayList<>();
protected PedidoJpaEntity() {
}
public PedidoJpaEntity(String id, String clienteId, String status) {
this.id = id;
this.clienteId = clienteId;
this.status = status;
}
public void addItem(ItemPedidoJpaEntity item) {
item.setPedido(this);
this.itens.add(item);
}
public String getId() {
return id;
}
public String getClienteId() {
return clienteId;
}
public String getStatus() {
return status;
}
public List<ItemPedidoJpaEntity> getItens() {
return itens;
}
}
package com.exemplo.pedidos.infrastructure.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
@Entity
@Table(name = "itens_pedido")
public class ItemPedidoJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String produtoId;
@Column(nullable = false)
private int quantidade;
@Column(nullable = false)
private BigDecimal precoUnitario;
@ManyToOne
@JoinColumn(name = "pedido_id")
private PedidoJpaEntity pedido;
protected ItemPedidoJpaEntity() {
}
public ItemPedidoJpaEntity(String produtoId, int quantidade, BigDecimal precoUnitario) {
this.produtoId = produtoId;
this.quantidade = quantidade;
this.precoUnitario = precoUnitario;
}
public void setPedido(PedidoJpaEntity pedido) {
this.pedido = pedido;
}
public String getProdutoId() {
return produtoId;
}
public int getQuantidade() {
return quantidade;
}
public BigDecimal getPrecoUnitario() {
return precoUnitario;
}
}
package com.exemplo.pedidos.infrastructure.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PedidoJpaRepository extends JpaRepository<PedidoJpaEntity, String> {
}
package com.exemplo.pedidos.infrastructure.persistence;
import com.exemplo.pedidos.application.port.out.PedidoGateway;
import com.exemplo.pedidos.domain.ItemPedido;
import com.exemplo.pedidos.domain.Pedido;
import org.springframework.stereotype.Component;
@Component
public class PedidoGatewayJpaAdapter implements PedidoGateway {
private final PedidoJpaRepository repository;
public PedidoGatewayJpaAdapter(PedidoJpaRepository repository) {
this.repository = repository;
}
@Override
public void salvar(Pedido pedido) {
PedidoJpaEntity jpaEntity = new PedidoJpaEntity(
pedido.getId(),
pedido.getClienteId(),
pedido.getStatus().name());
for (ItemPedido item : pedido.getItens()) {
jpaEntity.addItem(new ItemPedidoJpaEntity(
item.getProdutoId(),
item.getQuantidade(),
item.getPrecoUnitario().valor()));
}
repository.save(jpaEntity);
}
}
package com.exemplo.pedidos.infrastructure.config;
import com.exemplo.pedidos.application.port.in.CriarPedidoInputPort;
import com.exemplo.pedidos.application.port.out.PedidoGateway;
import com.exemplo.pedidos.application.usecase.CriarPedidoInteractor;
import com.exemplo.pedidos.infrastructure.web.PedidoPresenter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.web.context.WebApplicationContext;
@Configuration
public class BeanConfig {
@Bean
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public CriarPedidoInputPort criarPedidoInputPort(PedidoGateway gateway, PedidoPresenter presenter) {
return new CriarPedidoInteractor(gateway, presenter);
}
}
sequenceDiagram
    participant Cliente
    participant Controller as PedidoController
    participant Input as CriarPedidoInputPort
    participant Interactor as CriarPedidoInteractor
    participant Dominio as Pedido (Entity)
    participant Gateway as PedidoGateway
    participant Jpa as PedidoGatewayJpaAdapter
    participant Output as PedidoPresenter

    Cliente->>Controller: POST /pedidos
    Controller->>Controller: monta CriarPedidoRequest (DTO)
    Controller->>Input: executar(request)
    Input->>Interactor: (implementacao)
    Interactor->>Dominio: new Pedido(...), confirmar()
    Dominio-->>Interactor: total(), status
    Interactor->>Gateway: salvar(pedido)
    Gateway->>Jpa: (implementacao)
    Jpa->>Jpa: mapeia para PedidoJpaEntity e persiste
    Interactor->>Output: apresentar(PedidoResponse)
    Output-->>Controller: getResponse()
    Controller-->>Cliente: 201 Created + PedidoResponse

Note que PedidoGateway e CriarPedidoOutputPort são interfaces do núcleo; suas implementações (PedidoGatewayJpaAdapter, PedidoPresenter) vivem na infraestrutura. Toda dependência de código aponta para dentro — a essência da A Regra da Dependência.

  • Anotar Pedido com @Entity: reusar o domínio como entidade JPA amarra o núcleo ao Hibernate. Por isso existe PedidoJpaEntity separada, com mapper no adapter.
  • @RequestBody CriarPedidoRequest direto: usar o DTO da aplicação como payload HTTP acopla o contrato interno à API. Aqui há CriarPedidoHttpRequest só para a web.
  • Controller chamando PedidoJpaRepository: pular o interactor colapsa a arquitetura em camadas comuns.
  • Presenter singleton guardando estado: o presenter carrega o response de uma requisição em campo mutável; como singleton, seria compartilhado entre requisições concorrentes. Por isso ambos são request-scoped — o input port no BeanConfig, o presenter via @Scope na própria classe.
  • Interactor com @Service: aceitável por pragmatismo, mas aqui foi montado por @Bean para manter o núcleo livre de anotações Spring.

1. Por que Pedido e PedidoJpaEntity são classes separadas?

Resposta

Para manter o domínio livre de JPA/Hibernate. Pedido é a Entity pura do anel 1; PedidoJpaEntity é um detalhe de persistência do anel externo. O PedidoGatewayJpaAdapter faz o mapeamento entre elas.

2. Onde está a inversão de dependência neste exemplo?

Resposta

O interactor depende das interfaces PedidoGateway e CriarPedidoOutputPort, ambas no núcleo. As implementações (PedidoGatewayJpaAdapter, PedidoPresenter) estão na infraestrutura e dependem do núcleo, apontando para dentro.

3. Por que existe CriarPedidoHttpRequest além de CriarPedidoRequest?

Resposta

CriarPedidoHttpRequest é o DTO da fronteira web (anel externo), acoplado ao formato HTTP. CriarPedidoRequest é o request model do caso de uso. Separá-los evita que o contrato da API vaze para o núcleo e vice-versa.

4. Por que o input port e o presenter são request-scoped?

Resposta

Porque o presenter guarda o response daquela requisição específica. Um escopo singleton compartilharia estado entre requisições concorrentes, causando race conditions. O input port é declarado request-scoped no BeanConfig; o presenter, via @Scope na própria classe — ambos com proxy.