Skip to Content
01 FundamentalsPorts & Adapters

Ports & Adapters

Guide complet du pattern Ports & Adapters (Architecture Hexagonale) avec Micronaut.

Ce pattern permet de découpler votre logique métier de l’infrastructure technique (API, BDD, frameworks).


Qu’est-ce qu’un Port ?

Un Port est une interface qui définit un contrat entre deux couches de l’application.

Pensez aux ports comme des prises électriques : peu importe ce que vous branchez (mock, API, BDD), tant que ça respecte le contrat !

Types de Ports

Qui l’utilise ? Le monde extérieur (API REST, CLI, GraphQL)

Où ? application/port/input/

Exemple : Controllers REST

@Controller("/api/trends") @RequiredArgsConstructor public class TrendController { private final GetTrendsUseCase getTrendsUseCase; @Get public TrendResponseDto getTrends(@QueryValue String keyword) { return getTrendsUseCase.execute(keyword) .map(TrendResponseDto::fromDomain) .orElseThrow(() -> new NotFoundException()); } }

Qu’est-ce qu’un Adaptateur ?

Un Adaptateur est l’implémentation concrète d’un port.

L’adaptateur fait le lien entre votre logique métier (pure) et le monde technique (API, BDD, fichiers).

Exemple : Adaptateur Mock

@Singleton public class MockTrendRepositoryAdapter implements TrendRepository { @Override public Optional<TrendResult> getTrends(TrendQuery query) { // Génère des données mockées pour le développement TrendResult result = TrendResult.builder() .keyword(query.keyword()) .region(query.region()) .interestScore(generateRandomScore()) .build(); return Optional.of(result); } private int generateRandomScore() { return ThreadLocalRandom.current().nextInt(50, 101); } }

Principe d’Inversion de Dépendances

Règle d’or : Le domain ne dépend JAMAIS de l’infrastructure. C’est l’inverse !

UseCase → TrendRepository (interface) | implements Adapter
  • Le Use Case dépend de l’interface (port de sortie)
  • L’Adapter implémente l’interface
  • On peut changer d’adapter sans toucher au use case !

Structure des Packages

Comprendre la structure:

  • domain/port/output = Interfaces que le domain a besoin (repositories, services externes)
  • application/port/input = Points d’entrée (REST controllers, CLI, GraphQL)
  • infrastructure/adapter = Implémentations concrètes (Mock, API, BDD)

Flux Complet d’une Requête

Voyons comment une requête HTTP traverse toutes les couches.

Client envoie une requête

GET /api/trends?keyword=java&region=US

Port d’Entrée (Controller)

Le controller valide les paramètres et appelle le use case :

@Controller("/api/trends") public class TrendController { private final GetTrendsUseCase getTrendsUseCase; @Get public TrendResponseDto getTrends( @QueryValue @NotBlank String keyword, @QueryValue(defaultValue = "US") String region ) { return getTrendsUseCase.execute(keyword, region) .map(TrendResponseDto::fromDomain) .orElseThrow(() -> new ResourceNotFoundException()); } }

Use Case (Logique Métier)

Le use case crée les objets domain et appelle le port de sortie :

@Singleton public class GetTrendsUseCase { private final TrendRepository trendRepository; public Optional<TrendResult> execute(String keyword, String region) { TrendQuery query = new TrendQuery(keyword, region); return trendRepository.getTrends(query); } }

Port de Sortie (Interface)

L’interface définit le contrat sans implémentation :

public interface TrendRepository { Optional<TrendResult> getTrends(TrendQuery query); }

Adaptateur (Implémentation)

L’adaptateur implémente la logique technique :

@Singleton public class MockTrendRepositoryAdapter implements TrendRepository { @Override public Optional<TrendResult> getTrends(TrendQuery query) { // Génère des données mockées return Optional.of(generateMockData()); } }

Retour au Client

Le controller transforme TrendResultTrendResponseDto et retourne du JSON.

À chaque étape, les responsabilités sont clairement séparées : validation, logique métier, accès technique.


Pourquoi les Output Ports sont dans le Domain ?

C’est une question fréquente ! Voici pourquoi cette décision est architecturalement importante.

Règle Fondamentale

  • Output Ports → TOUJOURS dans domain/port/output/
  • Input Ports → TOUJOURS dans application/port/input/

Le Domain Définit Ses Besoins

Le domain dit : “J’ai besoin d’un repository pour sauvegarder mes trends”

Il ne sait pas et ne veut pas savoir :

  • Si c’est une BDD PostgreSQL
  • Si c’est un fichier JSON
  • Si c’est un appel API externe
  • Si c’est juste du mock
// domain/port/output/TrendRepository.java public interface TrendRepository { Optional<TrendResult> getTrends(TrendQuery query); }

Le domain exprime son besoin de manière abstraite.

Erreur courante :

Ne JAMAIS mettre un output port dans application/ ! C’est le domain qui définit ses besoins, pas l’application.


Remplacer un Adaptateur

C’est LE GRAND AVANTAGE de l’architecture hexagonale !

Créer un nouvel adaptateur

@Singleton @Requires(property = "app.trend-source", value = "google-api") public class GoogleTrendsApiAdapter implements TrendRepository { private final HttpClient httpClient; @Override public Optional<TrendResult> getTrends(TrendQuery query) { // Appelle la vraie API Google Trends String url = "https://trends.google.com/api/..."; GoogleApiResponse response = httpClient.toBlocking() .retrieve(HttpRequest.GET(url), GoogleApiResponse.class); return Optional.of(mapToTrendResult(response)); } }

Adaptateur avec Conversion DTO → Domain

L’adaptateur est responsable de la traduction entre le monde technique (DTOs) et le domain.

Exemple : CountryRepositoryAdapter

@Slf4j @Singleton public class CountryRepositoryAdapter implements CountryRepository { @Inject @Client(id = "countries", path = "/v3.1") HttpClient httpClient; @Override public List<Country> findAll() { try { // 1. Appeler l'API et désérialiser vers DTO (infrastructure) CountryApiDto[] countriesDto = httpClient.toBlocking() .retrieve("/all?fields=name,translations", CountryApiDto[].class); // 2. Convertir DTO → Domain return Stream.of(countriesDto) .map(this::toDomain) .collect(Collectors.toList()); } catch (Exception e) { log.error("Error retrieving countries", e); return List.of(); } } private Country toDomain(CountryApiDto dto) { // Conversion DTO (infrastructure) → Domain (pur) Name name = new Name( dto.getName().getCommon(), dto.getName().getOfficial() ); List<Translation> translations = convertTranslations( dto.getTranslations() ); return new Country(name, translations); } }

Où faire la conversion ?

  • ❌ MAUVAIS : Dans le DTO (créerait une dépendance DTO → Domain)
  • ✅ BON : Dans l’Adaptateur (responsabilité de traduction)

Testabilité

Tester le domaine devient ultra simple car on peut mocker les ports de sortie !

Tester un Use Case

@Test void shouldReturnTrendsWhenKeywordIsValid() { // ARRANGE - Mock du port de sortie TrendRepository mockRepository = mock(TrendRepository.class); TrendResult mockResult = TrendResult.builder() .keyword("java") .region("US") .interestScore(85) .build(); when(mockRepository.getTrends(any())).thenReturn(Optional.of(mockResult)); GetTrendsUseCase useCase = new GetTrendsUseCase(mockRepository); // ACT Optional<TrendResult> result = useCase.execute("java", "US"); // ASSERT assertThat(result).isPresent(); assertThat(result.get().getKeyword()).isEqualTo("java"); }

Avantages :

  • ✅ Pas besoin d’API réelle
  • ✅ Pas besoin de BDD
  • ✅ Tests ultra rapides
  • ✅ Focus sur la logique métier uniquement

Principes Clés

1. Inversion de Dépendances

UseCase → Adapter (implémentation concrète)

Problème : Impossible de changer l’adapter sans modifier le use case.

2. Le Domain au Centre

Le domain ne dépend de RIEN :

  • ❌ Pas de dépendance vers infrastructure/
  • ❌ Pas de dépendance vers application/
  • ❌ Pas de framework (Micronaut, Spring, Jackson)
  • ✅ Seulement des interfaces (ports de sortie)

3. Séparation des Responsabilités

CoucheResponsabilitéExemple
Port InputValidation, conversion HTTP → DomainTrendController
Use CaseLogique métier, orchestrationGetTrendsUseCase
Port OutputContrat d’accès aux ressourcesTrendRepository
AdapterImplémentation techniqueMockTrendRepositoryAdapter

Variantes d’Organisation des Ports

Il existe 3 approches principales pour organiser les ports. Notre choix actuel est l’Approche Séparée.

Approche Séparée ✅

domain/ └── port/ └── output/ ← Ports de SORTIE application/ └── port/ └── input/ ← Ports d'ENTRÉE

Avantages :

  • ✅ Clarté maximale
  • ✅ Domain indépendant
  • ✅ Tests d’architecture stricts

Quand l’utiliser :

  • Le controller EST le port d’entrée (pas d’interface use case)
  • Tu veux une séparation forte domain/application

Notre choix (Séparée) privilégie la clarté et le respect strict du DDD, mais les 3 approches sont valides selon ton contexte !


Comparaison : Architecture Classique vs Hexagonale

AspectArchitecture ClassiqueArchitecture Hexagonale
StructureController → Service → Repository → BDDPort In → UseCase → Port Out → Adapter
DépendancesCouplage fortCouplage faible (interfaces)
TestabilitéDifficile (besoin de BDD/API)Facile (mock des interfaces)
FlexibilitéDifficile de changer de BDD/APIFacile (juste changer l’adapter)
ComplexitéPlus simple pour petits projetsPlus de fichiers mais mieux organisé
IndépendanceCouplé au frameworkDomain indépendant

Cas d’Usage Pratiques

Ajouter un Cache

Créer un Cached Adapter

@Singleton @Primary public class CachedTrendRepositoryAdapter implements TrendRepository { private final TrendRepository delegate; private final Map<String, TrendResult> cache = new ConcurrentHashMap<>(); public CachedTrendRepositoryAdapter( @Named("mock") TrendRepository delegate ) { this.delegate = delegate; } @Override public Optional<TrendResult> getTrends(TrendQuery query) { String cacheKey = query.keyword() + "_" + query.region(); // Check cache if (cache.containsKey(cacheKey)) { return Optional.of(cache.get(cacheKey)); } // Sinon, déléguer Optional<TrendResult> result = delegate.getTrends(query); result.ifPresent(r -> cache.put(cacheKey, r)); return result; } }

Profit !

Aucun changement dans le code métier. Le cache est transparent.

Basculer entre Mock et API selon l’environnement

# application-dev.yml app: trend-source: mock # application-prod.yml app: trend-source: google-api
@Singleton @Requires(property = "app.trend-source", value = "mock") public class MockTrendRepositoryAdapter implements TrendRepository { } @Singleton @Requires(property = "app.trend-source", value = "google-api") public class GoogleTrendsApiAdapter implements TrendRepository { }

Micronaut injecte automatiquement le bon adapter !


Checklist Architecture Hexagonale

Pour vérifier que ton architecture est bien hexagonale :

  • Le domain ne dépend de rien (pas d’import depuis infrastructure ou application)
  • Les ports de sortie sont des interfaces dans le domain
  • Les adaptateurs implémentent les ports de sortie
  • Les use cases dépendent des interfaces (pas des implémentations)
  • On peut remplacer un adapter sans toucher au domaine
  • Les tests du domaine n’ont pas besoin d’infrastructure réelle

Résumé

Le pattern Ports & Adapters permet de protéger votre logique métier et de la rendre testable et flexible.

ConceptEmplacementRôleExemple
Port d’entréeapplication/port/input/Recevoir les requêtes externesTrendController
Port de sortiedomain/port/output/Interface vers ressources externesTrendRepository
Adaptateurinfrastructure/adapter/Implémentation techniqueMockTrendRepositoryAdapter
Use Caseapplication/usecase/Logique métier orchestréeGetTrendsUseCase
Domainedomain/model/Entités métier puresTrendQuery, TrendResult

Règle d’or : Le domaine est au centre et ne dépend de rien. Tout dépend du domaine (inversion de dépendances).


Ressources

Prochaines étapes

Maintenant que vous comprenez le pattern Ports & Adapters, explorez comment organiser chaque couche de l’architecture.

Last updated on