Design de Software

Clean Architecture

Organiza o código em camadas concêntricas onde a dependência sempre aponta para o domínio — guia completo com exemplos, estrutura de pacotes e testabilidade

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.java

Testabilidade 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 Created

Vantagens 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 @Service com @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.