Skip to content

Hexagonal Architecture

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

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

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

Hexagonal Architecture

Source: Wikipedia — Hexagonal Architecture

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 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;
    }
}

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.

Too many ports

One port per use case leads to interface explosion. Group related operations — AccountRepository is one port, not separate ports for findByEmail, save, and delete.