DEV Community

Thiago Souza
Thiago Souza

Posted on

Hexagonal Architecture na Prática: Construindo Software Sustentável com Java e Spring Boot

Introdução

Proposta por Alistair Cockburn, a Hexagonal Architecture (Ports & Adapters) promete algo que todo desenvolvedor deseja: código que sobrevive ao teste do tempo.

Neste artigo, vou mostrar como implementei essa abordagem no Sauron, um serviço de registro e avaliação de clientes, e como você pode aplicar os mesmos princípios nos seus projetos.

O Problema que Hexagonal Architecture Resolve

Quantas vezes você já viu (ou escreveu) código assim?

@Service
public class CustomerService {
    @Autowired
    private CustomerRepository repository; // JPA vazando por todo lado

    public Customer createCustomer(CustomerDTO dto) {
        Customer customer = new Customer();
        customer.setName(dto.getName());
        // ... mais 50 linhas misturando validação, negócio e persistência
    }
}
Enter fullscreen mode Exit fullscreen mode

Os problemas:

  • Alto acoplamento com frameworks (Spring, JPA, etc.)
  • Impossível testar sem subir o contexto Spring
  • Mudança de banco de dados? Prepare-se para refatorar metade do sistema
  • Regras de negócio perdidas em meio a anotações e configurações

Hexagonal Architecture resolve isso separando claramente as responsabilidades.

A Estrutura de Camadas

No projeto Sauron, a estrutura é dividida em três camadas principais:

src/main/java/com/github/thrsouza/sauron/
├── domain/           # Camada de Domínio (núcleo)
├── application/      # Camada de Aplicação (casos de uso)
└── infrastructure/   # Camada de Infraestrutura (adaptadores)
Enter fullscreen mode Exit fullscreen mode

Apenas para esclarecer:

  • Essa é a forma como eu gosto de organizar o meu código.
  • Arquitetura de software não diz respeito a como diretórios ou pacotes são organizados ou nomeados.

1. Domain Layer: O Coração do Sistema

O domínio é o núcleo do seu sistema. Aqui vivem as regras de negócio puras, sem dependências externas.

package com.github.thrsouza.sauron.domain.customer;

public class Customer extends AggregateRoot {
    private final UUID id;
    private final String document;
    private final String name;
    private final String email;
    private CustomerStatus status;

    public static Customer create(String document, String name, String email) {
        UUID id = UUID.randomUUID();
        Customer customer = new Customer(id, document, name, email, CustomerStatus.PENDING);
        customer.recordDomainEvent(new CustomerCreated(customer.id()));
        return customer;
    }

    public void approve() {
        if (this.status != CustomerStatus.PENDING) {
            throw new IllegalArgumentException("Customer status is not pending");
        }
        this.status = CustomerStatus.APPROVED;
        this.recordDomainEvent(new CustomerApproved(this.id()));
    }
}
Enter fullscreen mode Exit fullscreen mode

Características importantes:

  • Zero anotações de frameworks (@Entity, @Service, etc.)
  • Lógica de negócio clara e testável
  • Invariantes protegidas (não pode aprovar cliente não-pendente)
  • Factory methods autodescritivos (Customer.create())

2. Application Layer: Orquestrando o Fluxo

A camada de aplicação contém os casos de uso, que orquestram o domínio sem conter lógica de negócio.

package com.github.thrsouza.sauron.application.customer;

public class CreateCustomerUseCase {
    private final CustomerRepository customerRepository;
    private final DomainEventPublisher domainEventPublisher;

    public CreateCustomerUseCase(CustomerRepository customerRepository, 
                                 DomainEventPublisher domainEventPublisher) {
        this.customerRepository = customerRepository;
        this.domainEventPublisher = domainEventPublisher;
    }

    public Output handle(Input input) {
        // Verifica se já existe (idempotência)
        Optional<Customer> existingCustomer = 
            customerRepository.findByDocument(input.document());

        if (existingCustomer.isPresent()) {
            return new Output(existingCustomer.get().id());
        }

        // Cria novo cliente
        Customer customer = Customer.create(
            input.document(),
            input.name(),
            input.email()
        );

        // Persiste e publica eventos
        customerRepository.save(customer);
        domainEventPublisher.publishAll(customer.pullDomainEvents());

        return new Output(customer.id());
    }

    public record Input(String document, String name, String email) {}
    public record Output(UUID id) {}
}
Enter fullscreen mode Exit fullscreen mode

Repare que:

  • ✅ É um POJO puro (Plain Old Java Object)
  • ✅ Dependências explícitas via construtor
  • ✅ Input/Output com Records (imutáveis)
  • ✅ Orquestra domínio, repositório e eventos

3. Infrastructure Layer: Adaptadores para o Mundo Externo

A infraestrutura implementa as interfaces definidas no domínio.

Exemplo: Repository Adapter

@Component
public class CustomerRepositoryAdapter implements CustomerRepository {
    private final CustomerJpaRepository jpaRepository;
    private final CustomerJpaMapper mapper;

    @Override
    public Optional<Customer> findById(UUID id) {
        return jpaRepository.findById(id)
            .map(mapper::toDomain);
    }

    @Override
    public void save(Customer customer) {
        CustomerJpaEntity entity = mapper.toEntity(customer);
        jpaRepository.save(entity);
    }
}
Enter fullscreen mode Exit fullscreen mode

O segredo: Inversão de Dependência

A interface CustomerRepository está no domínio, mas a implementação está na infraestrutura:

// Domain layer (define o contrato)
package com.github.thrsouza.sauron.domain.customer;

public interface CustomerRepository {
    Optional<Customer> findById(UUID id);
    void save(Customer customer);
}

// Infrastructure layer (implementa o contrato)
package com.github.thrsouza.sauron.infrastructure.persistence.jpa.adapter;

@Component
public class CustomerRepositoryAdapter implements CustomerRepository {
    // implementação
}
Enter fullscreen mode Exit fullscreen mode

Isso é Dependency Inversion Principle em ação: módulos de alto nível (domínio) não dependem de módulos de baixo nível (infraestrutura). Ambos dependem de abstrações.

Configuração Manual: Controle Total

No Sauron, os casos de uso são configurados manualmente via @Configuration:

@Configuration
public class UseCaseConfig {
    private final DomainEventPublisher domainEventPublisher;
    private final CustomerRepository customerRepository;

    @Bean
    public CreateCustomerUseCase createCustomerUseCase() {
        return new CreateCustomerUseCase(customerRepository, domainEventPublisher);
    }
}
Enter fullscreen mode Exit fullscreen mode

Por que não usar @Service nos casos de uso?

  • Mantém a camada de aplicação independente do Spring
  • Facilita testes unitários (sem contexto Spring)
  • Deixa as dependências explícitas
  • Controle total sobre a criação dos objetos

Benefícios Reais

1. Testabilidade

Testar o domínio é trivial:

@Test
void shouldApproveCustomer() {
    Customer customer = Customer.create("12345678900", "John", "john@example.com");
    customer.approve();
    assertEquals(CustomerStatus.APPROVED, customer.status());
}
Enter fullscreen mode Exit fullscreen mode

Sem Spring, sem banco, sem Kafka. Testes rápidos e confiáveis.

2. Substituição de Frameworks

Quer trocar JPA por MongoDB? Apenas crie um novo adapter:

@Component
public class CustomerMongoRepositoryAdapter implements CustomerRepository {
    // Nova implementação
}
Enter fullscreen mode Exit fullscreen mode

O domínio e a aplicação permanecem intocados.

3. Independência de Entrega

O mesmo domínio pode ser exposto via:

  • REST API (Spring MVC)
  • GraphQL
  • gRPC
  • CLI
  • Mensageria (Kafka, RabbitMQ)

Basta criar adaptadores diferentes!

Armadilhas Comuns

❌ Domínio Anêmico

// ERRADO: Apenas getters e setters
public class Customer {
    private CustomerStatus status;

    public void setStatus(CustomerStatus status) {
        this.status = status; // Sem validação!
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ Domínio Rico

// CORRETO: Comportamento e validação
public class Customer {
    public void approve() {
        if (this.status != CustomerStatus.PENDING) {
            throw new IllegalArgumentException("Customer status is not pending");
        }
        this.status = CustomerStatus.APPROVED;
    }
}
Enter fullscreen mode Exit fullscreen mode

❌ Vazamento de Infraestrutura

// ERRADO: JPA vazando para o domínio
public interface CustomerRepository {
    Page<Customer> findAll(Pageable pageable); // Pageable é do Spring Data!
}
Enter fullscreen mode Exit fullscreen mode

✅ Abstrações Puras

// CORRETO: Abstrações do domínio
public interface CustomerRepository {
    List<Customer> findAll(int page, int size);
}
Enter fullscreen mode Exit fullscreen mode

Quando NÃO usar Hexagonal Architecture

Hexagonal Architecture não é bala de prata. Evite em:

  • Protótipos ou MVPs descartáveis
  • CRUD simples sem lógica de negócio
  • Scripts ou ferramentas internas
  • Projetos com deadline apertado e time inexperiente

Para esses casos, um MVC tradicional pode ser mais adequado.

Conclusão

Hexagonal Architecture não é sobre estrutura de pastas ou quantidade de camadas. É sobre separação de responsabilidades, testabilidade e independência de frameworks.

Lembre-se: Arquitetura é sobre trade-offs. Hexagonal Architecture adiciona complexidade inicial em troca de sustentabilidade a longo prazo. Escolha sabiamente!


Recursos

Top comments (2)

Collapse
 
carl231 profile image
carl

Hexagonal faz mais sentido quando você começa a sofrer com aquele acoplamento pesado do Spring espalhado pelo código. A ideia de deixar o domínio “limpo” e jogar toda a bagunça de frameworks pros adapters realmente muda o jogo — os testes ficam leves e trocar infraestrutura vira quase plug-and-play

Collapse
 
thrsouza profile image
Thiago Souza • Edited

Exatamente! Fácil de testar e, quando o domínio está livre do peso do framework, mudar a infraestrutura deixa de ser uma dor de cabeça. 😉