Clean Architecture, proposta por Robert C. Martin (Uncle Bob), organiza o sistema em camadas concêntricas onde a regra fundamental é: dependências só apontam para dentro. O domínio não sabe que Spring, JPA, Kafka ou qualquer framework existe.
O objetivo é que as decisões de framework sejam detalhes — o negócio pode sobreviver à troca de banco de dados, de framework HTTP, ou de provedor de mensageria.
Diagrama das Camadas
┌─────────────────────────────────────────────────────────────┐
│ Frameworks & Drivers │
│ Spring Boot, JPA, Kafka, REST controllers, CLI │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ Controllers, Presenters, Gateways, Mappers │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ Application / Use Cases │ │ │
│ │ │ Orquestra entidades, define ports (interfaces) │ │ │
│ │ │ │ │ │
│ │ │ ┌───────────────────────────────────────────┐ │ │ │
│ │ │ │ Domain / Entities │ │ │ │
│ │ │ │ Regras de negócio puras, Value Objects, │ │ │ │
│ │ │ │ Domain Services, Domain Events │ │ │ │
│ │ │ └───────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Regra de Dependência: setas sempre apontam para DENTRO ↑A Dependency Rule
A única regra que não pode ser violada: código de uma camada externa NUNCA pode ser importado por uma camada interna.
// ❌ VIOLAÇÃO — entidade importa anotação JPA (framework externo)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity // annotation de framework dentro do domínio — violação!
public class Order {
@Id
private String id;
// ...
}
// ❌ VIOLAÇÃO — use case conhece HttpServletRequest
import jakarta.servlet.http.HttpServletRequest;
public class PlaceOrderUseCase {
public void execute(HttpServletRequest request) { // violação!
// ...
}
}
// ✅ CORRETO — domínio sem nenhuma dependência de framework
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
public Order(CustomerId customerId, List<OrderItem> items) {
this.id = OrderId.generate();
this.customerId = customerId;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new InvalidOrderStateException("Pedido não está pendente");
}
this.status = OrderStatus.CONFIRMED;
}
public Money total() {
return items.stream()
.map(OrderItem::subtotal)
.reduce(Money.ZERO, Money::add);
}
}Domain Layer — Entidades, Value Objects e Domain Services
A camada mais interna contém as regras de negócio que existiriam mesmo que não houvesse computador — as regras do negócio em si.
Entidades têm identidade e ciclo de vida. Value Objects são imutáveis e definidos pelos seus atributos. Domain Services contêm lógica que não pertence naturalmente a uma entidade.
// ENTIDADE — tem identidade (OrderId), muta de estado
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
private final Instant createdAt;
public Order(CustomerId customerId, List<OrderItem> items) {
if (items == null || items.isEmpty()) {
throw new DomainException("Pedido deve ter ao menos um item");
}
this.id = OrderId.generate();
this.customerId = customerId;
this.items = new ArrayList<>(items);
this.status = OrderStatus.PENDING;
this.createdAt = Instant.now();
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new InvalidOrderStateException(
"Pedido " + id.value() + " não pode ser confirmado no estado " + status
);
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new InvalidOrderStateException("Pedido enviado não pode ser cancelado");
}
this.status = OrderStatus.CANCELLED;
}
// getters...
}
// VALUE OBJECT — sem identidade, imutável, igualdade por valor
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new DomainException("Valor monetário não pode ser negativo");
}
this.amount = amount.setScale(2, RoundingMode.HALF_UP);
this.currency = Objects.requireNonNull(currency);
}
public static Money of(double amount) {
return new Money(BigDecimal.valueOf(amount), Currency.BRL);
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new DomainException("Não é possível somar moedas diferentes");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(double factor) {
return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency);
}
public boolean isGreaterThan(Money other) {
return this.amount.compareTo(other.amount) > 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money m = (Money) o;
return amount.equals(m.amount) && currency.equals(m.currency);
}
@Override
public int hashCode() { return Objects.hash(amount, currency); }
}
// DOMAIN SERVICE — lógica que envolve múltiplas entidades
public class OrderPricingService {
// Calcula desconto aplicando regras que envolvem Order + Customer + Promotions
public Money calculateDiscount(Order order, Customer customer, List<Promotion> activePromotions) {
Money discount = Money.ZERO;
if (customer.isVip()) {
discount = discount.add(order.total().multiply(0.10));
}
for (Promotion promo : activePromotions) {
if (promo.appliesTo(order)) {
discount = discount.add(promo.calculate(order.total()));
}
}
return discount.isGreaterThan(order.total()) ? order.total() : discount;
}
}Application Layer — Use Cases e Ports
A camada de aplicação orquestra entidades para realizar casos de uso do sistema. Ela define as interfaces (ports) que a infraestrutura vai implementar.
// PORT de saída — interface definida pela aplicação, implementada pela infra
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findPendingByCustomer(CustomerId customerId);
}
public interface PaymentGateway {
PaymentResult charge(Money amount, CustomerId customerId, String paymentMethodId);
}
public interface OrderEventPublisher {
void publishOrderPlaced(Order order);
void publishOrderConfirmed(Order order);
}
// INPUT DTO — dados que chegam do mundo externo (sem tipos de domínio complexos)
public record CreateOrderCommand(
String customerId,
List<OrderItemRequest> items,
String paymentMethodId
) {}
public record OrderItemRequest(String productId, int quantity, BigDecimal unitPrice) {}
// OUTPUT DTO — dados que saem para o mundo externo
public record CreateOrderResult(String orderId, BigDecimal total, String status) {}
// USE CASE — orquestra o fluxo, sem saber nada de HTTP ou banco
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final OrderEventPublisher eventPublisher;
private final CustomerRepository customerRepository;
public CreateOrderUseCase(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
OrderEventPublisher eventPublisher,
CustomerRepository customerRepository) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.eventPublisher = eventPublisher;
this.customerRepository = customerRepository;
}
public CreateOrderResult execute(CreateOrderCommand cmd) {
// 1. Buscar e validar entidades do domínio
CustomerId customerId = new CustomerId(cmd.customerId());
Customer customer = customerRepository.findById(customerId)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
// 2. Construir entidade via domínio
List<OrderItem> items = cmd.items().stream()
.map(i -> new OrderItem(
new ProductId(i.productId()),
i.quantity(),
Money.of(i.unitPrice().doubleValue())
))
.toList();
Order order = new Order(customerId, items);
// 3. Processar pagamento via port
PaymentResult payment = paymentGateway.charge(
order.total(), customerId, cmd.paymentMethodId()
);
if (payment.failed()) {
throw new PaymentFailedException(payment.errorMessage());
}
// 4. Persistir via port
orderRepository.save(order);
// 5. Publicar evento via port
eventPublisher.publishOrderPlaced(order);
// 6. Retornar DTO de saída
return new CreateOrderResult(
order.id().value(),
order.total().amount(),
order.status().name()
);
}
}Infrastructure Layer — Repositórios e Adaptadores
A camada mais externa contém todas as implementações concretas: JPA, Kafka, HTTP clients, email services. Ela depende do domínio, nunca o contrário.
// REPOSITÓRIO JPA — implementa port definido pela aplicação
@Repository
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderJpaRepo jpaRepo;
private final OrderMapper mapper;
public JpaOrderRepository(SpringDataOrderJpaRepo jpaRepo, OrderMapper mapper) {
this.jpaRepo = jpaRepo;
this.mapper = mapper;
}
@Override
public void save(Order order) {
OrderEntity entity = mapper.toEntity(order);
jpaRepo.save(entity);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepo.findById(id.value()).map(mapper::toDomain);
}
@Override
public List<Order> findPendingByCustomer(CustomerId customerId) {
return jpaRepo.findByCustomerIdAndStatus(customerId.value(), "PENDING")
.stream()
.map(mapper::toDomain)
.toList();
}
}
// ENTIDADE JPA — separada da entidade de domínio (sem acoplamento)
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private String id;
private String customerId;
private String status;
private BigDecimal total;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private List<OrderItemEntity> items = new ArrayList<>();
// getters e setters para JPA
}
// MAPPER — converte entre entidade de domínio e entidade de persistência
@Component
public class OrderMapper {
public OrderEntity toEntity(Order order) {
OrderEntity entity = new OrderEntity();
entity.setId(order.id().value());
entity.setCustomerId(order.customerId().value());
entity.setStatus(order.status().name());
entity.setTotal(order.total().amount());
entity.setItems(order.items().stream().map(this::toItemEntity).toList());
return entity;
}
public Order toDomain(OrderEntity entity) {
// recria a entidade de domínio a partir da entidade de persistência
// ...
}
}
// ADAPTADOR KAFKA — implementa port de eventos
@Component
public class KafkaOrderEventPublisher implements OrderEventPublisher {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Override
public void publishOrderPlaced(Order order) {
try {
String payload = objectMapper.writeValueAsString(new OrderPlacedEvent(
order.id().value(),
order.customerId().value(),
order.total().amount(),
Instant.now()
));
kafkaTemplate.send("orders.placed", order.id().value(), payload);
} catch (JsonProcessingException e) {
throw new EventPublishingException("Falha ao serializar evento", e);
}
}
}Organização de Pacotes por Camada
Há duas abordagens principais para organizar pacotes: por camada e por feature.
// Organização por camada
com.empresa.ecommerce/
├── domain/
│ ├── order/
│ │ ├── Order.java
│ │ ├── OrderId.java
│ │ ├── OrderItem.java
│ │ ├── OrderStatus.java
│ │ └── OrderRepository.java ← interface (port)
│ ├── customer/
│ │ ├── Customer.java
│ │ └── CustomerRepository.java ← interface (port)
│ └── shared/
│ ├── Money.java
│ └── DomainException.java
├── application/
│ ├── order/
│ │ ├── CreateOrderUseCase.java
│ │ ├── CreateOrderCommand.java
│ │ ├── CreateOrderResult.java
│ │ └── PaymentGateway.java ← port de saída
│ └── customer/
│ └── RegisterCustomerUseCase.java
└── infrastructure/
├── persistence/
│ ├── JpaOrderRepository.java ← implementa OrderRepository
│ ├── OrderEntity.java
│ └── OrderMapper.java
├── payment/
│ └── StripePaymentGateway.java ← implementa PaymentGateway
├── messaging/
│ └── KafkaOrderEventPublisher.java
└── web/
└── OrderController.java ← adapter HTTP
// Organização por feature (vertical slices) — preferida para sistemas maiores
com.empresa.ecommerce/
├── order/
│ ├── domain/
│ │ ├── Order.java
│ │ └── OrderRepository.java
│ ├── application/
│ │ └── CreateOrderUseCase.java
│ └── infrastructure/
│ ├── JpaOrderRepository.java
│ └── OrderController.java
└── customer/
├── domain/
│ └── Customer.java
├── application/
│ └── RegisterCustomerUseCase.java
└── infrastructure/
└── JpaCustomerRepository.javaTestabilidade por Camada
Clean Architecture permite testar cada camada de forma independente, com velocidade e fidelidade adequadas a cada nível.
// TESTE UNITÁRIO — domínio puro, sem mocks, sem spring
class OrderTest {
@Test
void shouldCalculateTotalCorrectly() {
Order order = new Order(
new CustomerId("cust-1"),
List.of(
new OrderItem(new ProductId("p1"), 2, Money.of(50.00)),
new OrderItem(new ProductId("p2"), 1, Money.of(30.00))
)
);
assertThat(order.total()).isEqualTo(Money.of(130.00));
}
@Test
void shouldNotAllowConfirmingCancelledOrder() {
Order order = buildOrder();
order.cancel("motivo");
assertThatThrownBy(order::confirm)
.isInstanceOf(InvalidOrderStateException.class);
}
}
// TESTE DE USE CASE — com fakes (sem banco, sem HTTP)
class CreateOrderUseCaseTest {
private final InMemoryOrderRepository orderRepo = new InMemoryOrderRepository();
private final FakePaymentGateway paymentGateway = new FakePaymentGateway();
private final FakeEventPublisher eventPublisher = new FakeEventPublisher();
private final InMemoryCustomerRepository customerRepo = new InMemoryCustomerRepository();
private final CreateOrderUseCase useCase = new CreateOrderUseCase(
orderRepo, paymentGateway, eventPublisher, customerRepo
);
@BeforeEach
void setUp() {
customerRepo.save(new Customer(new CustomerId("cust-1"), "João"));
}
@Test
void shouldCreateOrderAndPublishEvent() {
CreateOrderCommand cmd = new CreateOrderCommand(
"cust-1",
List.of(new OrderItemRequest("prod-1", 2, BigDecimal.valueOf(50))),
"pm_test_123"
);
CreateOrderResult result = useCase.execute(cmd);
assertThat(result.status()).isEqualTo("PENDING");
assertThat(orderRepo.count()).isEqualTo(1);
assertThat(eventPublisher.publishedEvents()).hasSize(1);
assertThat(paymentGateway.chargedAmount()).isEqualTo(Money.of(100));
}
@Test
void shouldNotSaveOrderWhenPaymentFails() {
paymentGateway.simulateFailure("Fondos insuficientes");
CreateOrderCommand cmd = buildValidCommand();
assertThatThrownBy(() -> useCase.execute(cmd))
.isInstanceOf(PaymentFailedException.class);
assertThat(orderRepo.count()).isZero();
}
}
// TESTE DE INTEGRAÇÃO — com banco real (Testcontainers)
@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
@Autowired
private JpaOrderRepository repository;
@Test
void shouldPersistAndRetrieveOrder() {
Order order = buildOrder();
repository.save(order);
Optional<Order> found = repository.findById(order.id());
assertThat(found).isPresent();
assertThat(found.get().total()).isEqualTo(order.total());
}
}
// TESTE E2E — com Spring completo + banco
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class CreateOrderE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldCreateOrderViaHttp() {
CreateOrderRequest body = new CreateOrderRequest(/*...*/);
ResponseEntity<CreateOrderResponse> response = restTemplate
.postForEntity("/api/orders", body, CreateOrderResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().orderId()).isNotNull();
}
}Exemplo Completo — CreateOrder End-to-End
Fluxo completo de uma requisição HTTP passando por todas as camadas:
HTTP POST /api/orders
│
▼
[OrderController] ← Interface Adapter: deserializa request, chama use case
│
▼
[CreateOrderCommand] ← DTO de entrada (sem tipos de domínio)
│
▼
[CreateOrderUseCase] ← Application: orquestra o fluxo de negócio
│ │ │
│ │ └──→ [PaymentGateway] ← Port de saída
│ │ │
│ │ ▼
│ │ [StripePaymentGateway] ← Infrastructure: implementação concreta
│ │
│ └──────→ [OrderRepository] ← Port de saída
│ │
│ ▼
│ [JpaOrderRepository] ← Infrastructure
│
└──────────→ [Order] ← Domain entity
[OrderItem]
[Money]
│
▼
[CreateOrderResult] ← DTO de saída
│
▼
HTTP 201 CreatedVantagens e Custo — Quando NÃO Usar Clean Architecture
Vantagens:
- Domínio testável sem subir infraestrutura — testes de use case rodam em milissegundos
- Trocar banco, framework ou provedor sem tocar em regras de negócio
- Dependências explícitas e rastreáveis
- Times diferentes podem trabalhar em camadas diferentes com menos conflitos
Custo:
- Mais arquivos e classes — um fluxo simples precisa de Entity, UseCase, Port, Adapter, DTO, Mapper
- Curva de aprendizado — desenvolvedores novos precisam entender o modelo mental
- Mapeamento entre entidades de domínio e entidades de persistência gera código de plumbing
Quando NÃO usar:
- CRUDs simples sem regras de negócio — um
@Servicecom@Repositoryé suficiente - Projetos pequenos ou prototipações — overhead não compensa
- Times pequenos sem disciplina para manter as regras das camadas — vira arquitetura de papel
- Microserviços muito pequenos — o tamanho do boilerplate pode ser maior que a lógica
Regra prática: use Clean Architecture quando a complexidade do domínio justifica o investimento. Se você não consegue nomear ao menos 5 regras de negócio não triviais, provavelmente é cedo demais.