Skip to content

Hexagonal Architecture

Look at the auth-service of Hands-on 3 from the outside. It is called by the gateway over HTTP, and could soon be called by a Kafka consumer or a CLI tool for administrators; your tests want to call it too. It calls other things: account-service via Feign, a JWT library, maybe Redis for revoked tokens. Layer diagrams draw HTTP "on top" and the database "at the bottom", but for the business logic they are the same kind of thing β€” the outside world β€” and each of them should be replaceable without touching the login rules.

Hexagonal Architecture β€” also called Ports & Adapters β€” was introduced by Alistair Cockburn in 2005. The idea is to place the application core at the centre and expose it through ports (interfaces), which external adapters connect to. The hexagon shape is arbitrary; it signals that the application has multiple equivalent entry/exit points with no privileged side β€” there is no "top" and "bottom", only inside and outside1.

flowchart LR
  subgraph hexagon [Application Core]
    domain[Domain Model]
    app[Application Services]
    inport[Β«portΒ»\nDriving Interfaces]:::port
    outport[Β«portΒ»\nDriven Interfaces]:::port
    domain <--> app
    app --> inport
    app --> outport
  end

  rest([REST Controller]):::adapter -->|driving adapter| inport
  cli([CLI]):::adapter -->|driving adapter| inport
  test([Test Suite]):::adapter -->|driving adapter| inport

  outport -->|driven adapter| db[(Database)]:::adapter
  outport -->|driven adapter| mq([Message Broker]):::adapter
  outport -->|driven adapter| ext([External API]):::adapter

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

Ports

A port is a pure interface β€” a contract defined by the application core, not by the external technology. There are two kinds:

Type Direction Defined by Implemented by Example
Primary / Driving port Outside β†’ App The application The application itself AuthService interface called by the REST controller
Secondary / Driven port App β†’ Outside The application An external adapter AccountRepository implemented by JPA adapter

Ownership matters

Both port types are owned and defined by the application core. The adapter is the piece that lives outside and plugs into the port β€” the core never imports the adapter.

Primary (driving) ports

Primary ports represent what the application can do β€” they are the use-case API. Any external actor that wants to interact with the application must go through a primary port.

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

The implementation of this interface also lives inside the core β€” it is the application service:

// Also inside the core β€” depends only on secondary ports
public class AuthServiceImpl implements AuthService {
    private final AccountRepository accounts; // ← secondary port
    private final JwtService jwt;

    @Override
    public TokenOut login(String email, String password) {
        Account account = accounts.findByEmail(email)
            .filter(a -> a.matchesPassword(password))
            .orElseThrow(InvalidCredentialsException::new);
        return new TokenOut(jwt.generate(account));
    }
}

Secondary (driven) ports

Secondary ports represent what the application needs from the outside world β€” persistence, messaging, external APIs. The core defines the interface; the infrastructure provides the implementation.

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

Adapters

An adapter is a class that translates between an external technology and a port contract. It lives outside the core and depends on it β€” never the reverse.

// 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.token());
    }
}
// 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;
    }
}

Hexagonal Architecture

Source: Wikipedia β€” Hexagonal Architecture

A request through the hexagon

A login crosses the hexagon twice: in through a primary port, out through a secondary port, and back.

sequenceDiagram
    autonumber
    participant GW as gateway
    participant In as AuthController (driving adapter)
    participant Core as AuthServiceImpl (core)
    participant Out as AccountJpaAdapter (driven adapter)
    participant DB as Postgres
    GW->>In: POST /auth/login {email, password}
    In->>Core: login(email, password) via AuthService port
    Core->>Out: findByEmail(email) via AccountRepository port
    Out->>DB: SELECT ... WHERE email = ?
    DB-->>Out: row
    Out-->>Core: Account (mapped from AccountTable)
    Core->>Core: matchesPassword, generate JWT
    Core-->>In: TokenOut
    In-->>GW: 200 OK + token

Replace the first participant with a test and the fourth with InMemoryAccountRepository, and the core runs exactly the same code β€” that is the whole point.

The store's services as hexagons

Outside actor Kind Adapter in the store
Gateway calling POST /auth/login driving AuthResource / AuthController (REST)
Kafka topic accounts (see Hands-on 9) driving a @KafkaListener consumer
JUnit test driving the test class itself
account-service driven Feign client (AccountController in Hands-on 3)
Postgres driven AccountJpaAdapter
Redis (cache, revoked tokens) driven a Redis adapter (see Hands-on 8)
Kafka (publishing AccountCreated) driven a KafkaTemplate adapter

Hands-on 3, read through this lens

In Hands-on 3, AuthService calls the Feign interface AccountController directly. That interface is defined by the account API module, not by the auth core β€” so the port is shaped by the remote service, and a change in account's API reaches the login logic. The strict hexagonal version declares a small port in the auth core (for example AccountLookup.findByEmail) and makes the Feign client one adapter behind it.


Full working example

The files below show the complete authentication slice of the auth-service. Each file is annotated with its location relative to the hexagon boundary.

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 on secondary ports (AccountRepository, TokenRepository) and on JwtService.
// In a stricter design JwtService would also be a port (e.g. TokenIssuer) with a JJWT adapter.
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) {
        // Same error for "unknown email" and "wrong password": telling them apart
        // would let an attacker discover which emails are registered.
        Account account = accounts.findByEmail(email)
            .filter(a -> a.matchesPassword(password))
            .orElseThrow(() -> new InvalidCredentialsException("Invalid credentials"));
        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;
    }
}

Testing strategy

The key testing insight of Hexagonal Architecture is that the application core can be fully tested without any infrastructure. Replace every driven adapter with an in-memory stub; drive the primary port directly from a test.

flowchart LR
  test([JUnit Test]):::adapter -->|drives| AuthService
  AuthService --> AccountRepository
  AccountRepository -->|implemented by| 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");
    }
}

No Spring @SpringBootTest, no H2, no port 8080 β€” the test runs in milliseconds and covers the business rule directly.


Suggested package layout

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

Common mistakes

Ports defined by the adapter

If the AccountRepository interface mirrors the Spring Data JpaRepository method names, it is being shaped by JPA rather than by the application's needs. Define ports in terms of domain concepts, not persistence concepts.

The core importing the adapter

Any import com.example.adapter.* inside the domain or application package is an immediate violation. Dependency injection (Spring, CDI) should resolve the adapter at runtime, not at compile time.

Wrong port granularity

One port per method (FindAccountByEmailPort, SaveAccountPort, DeleteAccountPort…) leads to interface explosion. Group operations by cohesion β€” AccountRepository is one port for one concept. On the driving side, one interface per use case (RegisterAccountUseCase, LoginUseCase) is a legitimate style that keeps each interface small (ISP); a single AuthService with a few related methods is also fine. Pick one convention per service and keep it.

Leaking domain objects through the driving adapter

Returning the domain Account (with its passwordHash) straight from the REST controller couples the HTTP contract to the domain β€” and here leaks a secret. Driving adapters convert domain results into response records (TokenOut, AccountOut).


Key takeaways

  • The application has an inside (domain + application services) and an outside (everything else); there is no privileged top or bottom.
  • Ports are interfaces owned by the core: driving/primary ports say what the application offers; driven/secondary ports say what it needs.
  • Adapters translate between a technology and a port: REST, CLI, Kafka listeners and tests drive the core; JPA, Feign, Redis and Kafka producers are driven by it.
  • Ports are named in domain terms, never shaped by the technology behind them.
  • Swapping driven adapters for in-memory ones lets the core be tested with plain JUnit in milliseconds β€” compare with the layers of Clean Architecture, which describe the inside of the hexagon.

Video introductions: CodeOpinion2 (English) and Fernanda Kipper3 (Portuguese).