Pular para conteúdo

Arquitetura Hexagonal

A Arquitetura Hexagonal — também chamada de Ports & Adapters — foi apresentada por Alistair Cockburn em 2005. A ideia é colocar o núcleo da aplicação no centro e expô-lo por meio de ports (interfaces), aos quais os adapters externos se conectam. O formato de hexágono é arbitrário; ele sinaliza que a aplicação tem vários pontos de entrada/saída equivalentes, sem nenhum lado privilegiado1.

flowchart LR
  subgraph hexagon [Núcleo da aplicação]
    domain[Modelo de domínio]
    app[Serviços de aplicação]
    inport[«port»\nInterfaces driving]:::port
    outport[«port»\nInterfaces driven]:::port
    domain <--> app
    app --> inport
    app --> outport
  end

  rest([Controller REST]):::adapter -->|adapter driving| inport
  cli([CLI]):::adapter -->|adapter driving| inport
  test([Suíte de testes]):::adapter -->|adapter driving| inport

  outport -->|adapter driven| db[(Banco de dados)]:::adapter
  outport -->|adapter driven| mq([Message broker]):::adapter
  outport -->|adapter driven| ext([API externa]):::adapter

  classDef port fill:#adf,stroke:#36c
  classDef adapter fill:#fd9,stroke:#a60

Ports

Um port é uma interface pura — um contrato definido pelo núcleo da aplicação, e não pela tecnologia externa. Há dois tipos:

Tipo Direção Definido por Implementado por Exemplo
Port primário / driving Fora → Aplicação A aplicação A própria aplicação Interface AuthService chamada pelo controller REST
Port secundário / driven Aplicação → Fora A aplicação Um adapter externo AccountRepository implementado pelo adapter JPA

A posse importa

Os dois tipos de port pertencem ao núcleo da aplicação e são definidos por ele. O adapter é a peça que fica do lado de fora e se encaixa no port — o núcleo nunca importa o adapter.

Ports primários (driving)

Os ports primários representam o que a aplicação sabe fazer — eles são a API dos casos de uso. Qualquer ator externo que queira interagir com a aplicação precisa passar por um port primário.

// Defined inside the application core
public interface AuthService {
    TokenOut login(String email, String password);
    void logout(String token);
}

A implementação dessa interface também fica dentro do núcleo — ela é o serviço de aplicação:

// Also inside the core — depends only on secondary ports
public class AuthServiceImpl implements AuthService {
    private final AccountRepository accounts; // ← secondary port

    @Override
    public TokenOut login(String email, String password) {
        Account account = accounts.findByEmail(email)
            .orElseThrow(() -> new InvalidCredentialsException());
        account.validatePassword(password);
        return TokenOut.issue(account);
    }
}

Ports secundários (driven)

Os ports secundários representam o que a aplicação precisa do mundo externo — persistência, mensageria, APIs externas. O núcleo define a interface; a infraestrutura fornece a implementação.

// Defined inside the application core
public interface AccountRepository {
    Optional<Account> findByEmail(String email);
    Account save(Account account);
}

Adapters

Um adapter é uma classe que traduz entre uma tecnologia externa e o contrato de um port. Ele fica fora do núcleo e depende dele — nunca o contrário.

// Adapter: translates HTTP → primary port
@RestController
public class AuthController {
    private final AuthService authService; // ← primary port

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/auth/login")
    public ResponseEntity<TokenOut> login(@RequestBody LoginIn in) {
        TokenOut token = authService.login(in.email(), in.password());
        return ResponseEntity.ok(token);
    }
}
// Any driver can plug into the same primary port
public class AuthCLI {
    private final AuthService authService;

    public void run(String[] args) {
        String email = args[0], password = args[1];
        TokenOut token = authService.login(email, password);
        System.out.println("Token: " + token.value());
    }
}
// Adapter: translates secondary port → JPA
@Repository
public class AccountJpaAdapter implements AccountRepository {

    private final AccountJpaRepository jpa;

    @Override
    public Optional<Account> findByEmail(String email) {
        return jpa.findByEmail(email).map(AccountMapper::toDomain);
    }

    @Override
    public Account save(Account account) {
        AccountTable table = AccountMapper.toTable(account);
        return AccountMapper.toDomain(jpa.save(table));
    }
}
// Swap the JPA adapter for a map in tests — no Spring context needed
public class InMemoryAccountRepository implements AccountRepository {
    private final Map<String, Account> store = new HashMap<>();

    @Override
    public Optional<Account> findByEmail(String email) {
        return Optional.ofNullable(store.get(email));
    }

    @Override
    public Account save(Account account) {
        store.put(account.email(), account);
        return account;
    }
}

Arquitetura Hexagonal

Fonte: Wikipedia — Hexagonal Architecture

Exemplo completo funcionando

Os arquivos abaixo mostram a fatia completa de autenticação do auth-service. Cada arquivo está identificado com a sua posição em relação à fronteira do hexágono.

1
2
3
4
5
6
7
// Primary port — defined BY the application core.
// Expresses what the authentication use case can do, in domain language.
public interface AuthService {
    TokenOut login(String email, String password);
    void register(RegisterIn in);
    void logout(String token);
}
// Primary port implementation — also lives inside the application core.
// Depends only on secondary ports (AccountRepository, TokenRepository).
public class AuthServiceImpl implements AuthService {

    private final AccountRepository accounts;  // secondary port
    private final TokenRepository tokens;      // secondary port
    private final JwtService jwt;

    public AuthServiceImpl(AccountRepository accounts,
                           TokenRepository tokens,
                           JwtService jwt) {
        this.accounts = accounts;
        this.tokens   = tokens;
        this.jwt      = jwt;
    }

    @Override
    public TokenOut login(String email, String password) {
        Account account = accounts.findByEmail(email)
            .orElseThrow(() -> new InvalidCredentialsException("Account not found"));
        if (!account.matchesPassword(password))
            throw new InvalidCredentialsException("Wrong password");
        String token = jwt.generate(account);
        tokens.store(token, account.id());
        return new TokenOut(token);
    }

    @Override
    public void register(RegisterIn in) {
        if (accounts.findByEmail(in.email()).isPresent())
            throw new DomainException("Email already registered");
        accounts.save(Account.of(in));
    }

    @Override
    public void logout(String token) {
        tokens.revoke(token);
    }
}
1
2
3
4
5
6
7
// Secondary port — defined BY the application core, implemented by the infrastructure.
// Expressed in domain terms: findByEmail, not findByEmailAddress or selectByEmail.
public interface AccountRepository {
    Optional<Account> findByEmail(String email);
    Optional<Account> findById(String id);
    Account save(Account account);
}
// Driving adapter — translates HTTP requests into primary port calls.
// Lives outside the hexagon. Depends on the AuthService interface (primary port).
@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthService authService;  // primary port

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public ResponseEntity<TokenOut> login(@RequestBody @Valid LoginIn in) {
        TokenOut token = authService.login(in.email(), in.password());
        return ResponseEntity.ok(token);
    }

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    public void register(@RequestBody @Valid RegisterIn in) {
        authService.register(in);
    }

    @PostMapping("/logout")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void logout(@RequestHeader("Authorization") String bearer) {
        authService.logout(bearer.replace("Bearer ", ""));
    }
}
// Driven adapter — implements the AccountRepository secondary port using JPA.
// Lives outside the hexagon. The core never imports this class.
@Repository
public class AccountJpaAdapter implements AccountRepository {

    private final AccountJpaRepository jpa;

    public AccountJpaAdapter(AccountJpaRepository jpa) {
        this.jpa = jpa;
    }

    @Override
    public Optional<Account> findByEmail(String email) {
        return jpa.findByEmail(email).map(AccountMapper::toDomain);
    }

    @Override
    public Optional<Account> findById(String id) {
        return jpa.findById(id).map(AccountMapper::toDomain);
    }

    @Override
    public Account save(Account account) {
        return AccountMapper.toDomain(jpa.save(AccountMapper.toTable(account)));
    }
}
// Test adapter — in-memory implementation of the AccountRepository secondary port.
// Used in unit tests to drive the core without any infrastructure.
public class InMemoryAccountRepository implements AccountRepository {

    private final Map<String, Account> byId    = new HashMap<>();
    private final Map<String, Account> byEmail = new HashMap<>();

    @Override
    public Optional<Account> findByEmail(String email) {
        return Optional.ofNullable(byEmail.get(email));
    }

    @Override
    public Optional<Account> findById(String id) {
        return Optional.ofNullable(byId.get(id));
    }

    @Override
    public Account save(Account account) {
        byId.put(account.id(), account);
        byEmail.put(account.email(), account);
        return account;
    }
}

Estratégia de testes

A principal sacada da Arquitetura Hexagonal para os testes é que o núcleo da aplicação pode ser totalmente testado sem nenhuma infraestrutura. Substitua cada adapter driven por um stub em memória e acione o port primário diretamente a partir de um teste.

flowchart LR
  test([Teste JUnit]):::adapter -->|aciona| AuthService
  AuthService --> AccountRepository
  AccountRepository -->|implementado por| InMemory([InMemoryAccountRepository]):::adapter

  classDef adapter fill:#fd9,stroke:#a60
AuthServiceTest.java
// Pure unit test — no Spring context, no database, no HTTP server.
// The in-memory adapter replaces JPA; the core is tested in isolation.
class AuthServiceTest {

    private final InMemoryAccountRepository accounts = new InMemoryAccountRepository();
    private final InMemoryTokenRepository   tokens   = new InMemoryTokenRepository();
    private final JwtService                jwt      = new JwtService("test-secret");
    private final AuthService               service  =
        new AuthServiceImpl(accounts, tokens, jwt);

    @Test
    void loginSucceeds_whenCredentialsMatch() {
        accounts.save(Account.of(new RegisterIn("Ada", "ada@example.com", "securepass")));

        TokenOut result = service.login("ada@example.com", "securepass");

        assertThat(result.token()).isNotBlank();
    }

    @Test
    void loginFails_whenPasswordIsWrong() {
        accounts.save(Account.of(new RegisterIn("Ada", "ada@example.com", "securepass")));

        assertThatThrownBy(() -> service.login("ada@example.com", "wrongpassword"))
            .isInstanceOf(InvalidCredentialsException.class);
    }

    @Test
    void register_failsWhenEmailAlreadyExists() {
        var in = new RegisterIn("Ada", "ada@example.com", "securepass");
        service.register(in);

        assertThatThrownBy(() -> service.register(in))
            .isInstanceOf(DomainException.class)
            .hasMessageContaining("already registered");
    }
}

Sem @SpringBootTest do Spring, sem H2, sem porta 8080 — o teste roda em milissegundos e cobre a regra de negócio diretamente.


Estrutura de pacotes sugerida

com.example.auth/
├── domain/                         ← Domain Model
│   └── Account.java                # Aggregate root — no framework annotations
├── application/                    ← Application Services + Ports
│   ├── AuthService.java            # Primary port (interface)
│   ├── AuthServiceImpl.java        # Primary port implementation
│   └── port/
│       └── AccountRepository.java  # Secondary port (interface)
├── adapter/
│   ├── in/
│   │   └── AuthController.java     # Driving adapter (REST)
│   └── out/
│       ├── AccountJpaAdapter.java  # Driven adapter (JPA)
│       ├── AccountTable.java       # @Entity — stays in the adapter
│       └── AccountMapper.java

Erros comuns

Ports definidos pelo adapter

Se a interface AccountRepository espelha os nomes de métodos do JpaRepository do Spring Data, ela está sendo moldada pelo JPA, e não pelas necessidades da aplicação. Defina os ports em termos de conceitos do domínio, não de conceitos de persistência.

O núcleo importando o adapter

Qualquer import com.example.adapter.* dentro do pacote de domínio ou de aplicação é uma violação imediata. A injeção de dependência (Spring, CDI) deve resolver o adapter em tempo de execução, não em tempo de compilação.

Ports demais

Um port por caso de uso leva a uma explosão de interfaces. Agrupe as operações relacionadas — AccountRepository é um único port, e não ports separados para findByEmail, save e delete.