Skip to main content

What is Hexagonal Architecture?

Hexagonal Architecture (also known as Ports and Adapters) is an architectural pattern that aims to create loosely coupled application components that can be easily connected to their software environment by means of ports and adapters.

Core Concept

The application core (domain + application layers) is isolated from external concerns through:
  • Ports: Interfaces that define how the application interacts with the outside world
  • Adapters: Concrete implementations that connect ports to specific technologies

Ports in the Blackjack API

Ports are interfaces defined in the domain layer that specify what the application needs from the outside world, without knowing the implementation details.

GameRepositoryPort

Defines persistence operations for games. Location: domain/port/GameRepositoryPort.java:1-11
package cat.itacademy.s05.t01.blackjack_api.domain.port;

import cat.itacademy.s05.t01.blackjack_api.domain.model.Game;
import cat.itacademy.s05.t01.blackjack_api.domain.model.GameId;
import reactor.core.publisher.Mono;

public interface GameRepositoryPort {
    Mono<Game> save(Game game);
    Mono<Game> findById(GameId id);
    Mono<Void> deleteById(GameId id);
}
Notice the port:
  • Lives in the domain layer (not infrastructure)
  • Works with domain models (Game, GameId)
  • Returns reactive types (Mono<T>)
  • Has no implementation details about MongoDB

PlayerRepositoryPort

Defines persistence and query operations for players. Location: domain/port/PlayerRepositoryPort.java:1-15
package cat.itacademy.s05.t01.blackjack_api.domain.port;

import cat.itacademy.s05.t01.blackjack_api.domain.model.Player;
import cat.itacademy.s05.t01.blackjack_api.domain.model.PlayerId;
import cat.itacademy.s05.t01.blackjack_api.domain.model.PlayerName;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface PlayerRepositoryPort {
    Mono<Player> save(Player player);
    Mono<Player> findById(PlayerId id);
    Mono<Player> findOrCreateByName(PlayerName name);
    Flux<Player> findRanking();
}
The findOrCreateByName() method encapsulates a common business operation as a port method, allowing adapters to optimize the implementation.

Adapters in the Blackjack API

Adapters are concrete implementations in the infrastructure layer that connect ports to specific technologies.

MongoDB Game Repository Adapter

Implements GameRepositoryPort using MongoDB as the persistence mechanism. Location: infrastructure/persistence/mongo/MongoGameRepositoryAdapter.java:1-36
package cat.itacademy.s05.t01.blackjack_api.infrastructure.persistence.mongo;

import cat.itacademy.s05.t01.blackjack_api.domain.exception.GameNotFoundException;
import cat.itacademy.s05.t01.blackjack_api.domain.model.*;
import cat.itacademy.s05.t01.blackjack_api.domain.port.GameRepositoryPort;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
public class MongoGameRepositoryAdapter implements GameRepositoryPort {

    private final SpringDataMongoGameRepository repo;

    public MongoGameRepositoryAdapter(SpringDataMongoGameRepository repo) {
        this.repo = repo;
    }

    @Override
    public Mono<Game> save(Game game) {
        MongoGameDocument doc = MongoGameMapper.toDocument(game);
        return repo.save(doc).thenReturn(game);
    }

    @Override
    public Mono<Game> findById(GameId id) {
        return repo.findById(id.value())
                .map(MongoGameMapper::toDomain);
    }

    @Override
    public Mono<Void> deleteById(GameId id) {
        return repo.findById(id.value())
                .switchIfEmpty(Mono.error(new GameNotFoundException(id.value())))
                .flatMap(doc -> repo.deleteById(id.value()));
    }
}

Key Adapter Responsibilities

1

Implement Port Interface

The adapter implements GameRepositoryPort defined in the domain layer.
2

Translate Models

Converts between domain models (Game) and database documents (MongoGameDocument) using a mapper.
3

Handle Technology Details

Uses Spring Data MongoDB’s ReactiveMongoRepository internally.
4

Maintain Reactive Contract

Returns Mono<T> to preserve reactive behavior.

MySQL Player Repository Adapter

Implements PlayerRepositoryPort using MySQL with R2DBC for reactive access. Location: infrastructure/persistence/mysql/MySqlPlayerRepositoryAdapter.java:1-64
package cat.itacademy.s05.t01.blackjack_api.infrastructure.persistence.mysql;

import cat.itacademy.s05.t01.blackjack_api.domain.model.Player;
import cat.itacademy.s05.t01.blackjack_api.domain.model.PlayerId;
import cat.itacademy.s05.t01.blackjack_api.domain.model.PlayerName;
import cat.itacademy.s05.t01.blackjack_api.domain.port.PlayerRepositoryPort;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Component
public class MySqlPlayerRepositoryAdapter implements PlayerRepositoryPort {

    private final SpringDataR2dbcPlayerRepository repo;

    public MySqlPlayerRepositoryAdapter(SpringDataR2dbcPlayerRepository repo) {
        this.repo = repo;
    }

    @Override
    public Mono<Player> save(Player player) {
        return repo.findByExternalId(player.id().value())
                .defaultIfEmpty(new PlayerRow(
                        null,
                        player.id().value(),
                        player.name().value(),
                        player.wins(),
                        player.losses()
                ))
                .flatMap(existing -> {
                    existing.setName(player.name().value());
                    existing.setWins(player.wins());
                    existing.setLosses(player.losses());
                    return repo.save(existing).map(this::toDomain);
                });
    }

    @Override
    public Mono<Player> findById(PlayerId id) {
        return repo.findByExternalId(id.value())
                .map(this::toDomain);
    }

    @Override
    public Mono<Player> findOrCreateByName(PlayerName name) {
        return repo.findByName(name.value())
                .map(this::toDomain)
                .switchIfEmpty(Mono.defer(() -> save(Player.newPlayer(name))));
    }

    @Override
    public Flux<Player> findRanking() {
        return repo.findRanking().map(this::toDomain);
    }

    private Player toDomain(PlayerRow row) {
        return new Player(
                new PlayerId(row.getExternalId()),
                new PlayerName(row.getName()),
                row.getWins(),
                row.getLosses()
        );
    }
}
The adapter handles the impedance mismatch between:
  • Domain model: Immutable Player with value objects
  • Database model: Mutable PlayerRow JPA entity with primitive types

Web Controller Adapter

Controllers act as driving adapters that receive input from the outside world and invoke use cases. Location: infrastructure/web/controller/GameController.java:43-47
@PostMapping("/new")
@ResponseStatus(HttpStatus.CREATED)
public Mono<CreateGameResult> create(@Valid @RequestBody CreateGameRequest request) {
    return createNewGame.execute(new CreateGameCommand(request.playerName()));
}
The controller:
  1. Receives HTTP request (infrastructure concern)
  2. Validates and converts to application CreateGameCommand
  3. Delegates to CreateNewGameUseCase
  4. Returns reactive Mono<CreateGameResult>

Dependency Inversion

The key benefit of hexagonal architecture is dependency inversion:

Before (Traditional)

public class CreateGameUseCase {
    private final MongoGameRepository repo; // Direct dependency on infrastructure!
    
    public CreateGameUseCase(MongoGameRepository repo) {
        this.repo = repo;
    }
}

After (Hexagonal)

public class CreateNewGameUseCase {
    private final GameRepositoryPort gameRepo; // Depends on abstraction!
    
    public CreateNewGameUseCase(GameRepositoryPort gameRepo) {
        this.gameRepo = gameRepo;
    }
}
The use case depends on the port interface (abstraction), not the concrete adapter implementation.

Benefits

Technology Independence

Switch from MongoDB to PostgreSQL without changing use cases or domain logic.

Testability

Mock ports easily in tests without needing real databases or HTTP servers.

Parallel Development

Teams can work on adapters and core logic independently.

Clear Boundaries

Explicit contracts between layers prevent coupling.

Testing Example

With ports, testing becomes straightforward:
public class CreateNewGameUseCaseTest {
    @Test
    void shouldCreateGameForNewPlayer() {
        // Mock the port, not the adapter!
        GameRepositoryPort mockGameRepo = mock(GameRepositoryPort.class);
        PlayerRepositoryPort mockPlayerRepo = mock(PlayerRepositoryPort.class);
        
        CreateNewGameUseCase useCase = new CreateNewGameUseCase(
            mockPlayerRepo, 
            mockGameRepo
        );
        
        // Test business logic without MongoDB or MySQL
        when(mockPlayerRepo.findOrCreateByName(any()))
            .thenReturn(Mono.just(testPlayer));
        when(mockGameRepo.save(any()))
            .thenReturn(Mono.just(testGame));
            
        StepVerifier.create(useCase.execute(command))
            .assertNext(result -> {
                assertNotNull(result.gameId());
                assertEquals("IN_PROGRESS", result.status());
            })
            .verifyComplete();
    }
}

Adapter Configuration

Adapters are wired together using Spring configuration: Location: infrastructure/config/UseCaseConfig.java
@Configuration
public class UseCaseConfig {
    
    @Bean
    public CreateNewGameUseCase createNewGameUseCase(
            PlayerRepositoryPort playerRepo,
            GameRepositoryPort gameRepo) {
        return new CreateNewGameUseCase(playerRepo, gameRepo);
    }
    
    // Spring auto-detects @Component adapters:
    // - MongoGameRepositoryAdapter implements GameRepositoryPort
    // - MySqlPlayerRepositoryAdapter implements PlayerRepositoryPort
}
Spring’s dependency injection wires the correct adapter implementations to the use cases at runtime, but the application core remains unaware of which adapters are being used.

Port Types

Driven Ports (Secondary)

Driven by the application to perform operations.
  • GameRepositoryPort - Database operations
  • PlayerRepositoryPort - Database operations

Driving Ports (Primary)

Drive the application by invoking use cases.
  • REST Controllers - HTTP requests
  • Message Listeners - Events (if implemented)
  • CLI - Command line (if implemented)

Real-World Flow

Let’s trace a complete request through the hexagonal architecture: POST /game/new with {"playerName": "Alice"}
1

Web Request (Driving Adapter)

GameController.create() receives HTTP request
2

Convert to Domain Concept

Controller creates CreateGameCommand("Alice")
3

Invoke Use Case

CreateNewGameUseCase.execute(command) is called
4

Use Case Logic

Use case calls playerRepo.findOrCreateByName(name) through port interface
5

Driven Adapter: Player

MySqlPlayerRepositoryAdapter queries MySQL via R2DBC
6

Domain Logic

Game.newGame(playerId) creates new game with blackjack rules
7

Driven Adapter: Game

MongoGameRepositoryAdapter saves to MongoDB
8

Return Result

CreateGameResult flows back through layers to HTTP response
At no point does the domain or application layer know about MongoDB, MySQL, or HTTP. All coupling is through ports!

Summary

ConceptLocationResponsibility
Portdomain/port/Define contracts (interfaces)
Driven Adapterinfrastructure/persistence/Implement ports with real tech
Driving Adapterinfrastructure/web/controller/Invoke use cases from outside
Use Caseapplication/usecase/Orchestrate domain via ports
Domaindomain/model/Business logic (no dependencies)

Next Steps

Domain-Driven Design

Explore domain models and DDD patterns

Reactive Programming

Learn about reactive patterns with Mono and Flux

Build docs developers (and LLMs) love