Skip to Content
03 ImplementationGuide d'Implémentation des Adapters

Guide d’Implémentation des Adapters

Guide pratique pour créer des adapters qui implémentent les ports de sortie de votre architecture hexagonale.

Les adapters sont la couche technique : ils implémentent les interfaces définies par le domain et gèrent les détails d’infrastructure (API, BDD, fichiers, cache).


Qu’est-ce qu’un Adapter ?

Un Adapter est une classe qui implémente un port de sortie (interface du domain) en gérant les détails techniques.

Rôle de l’Adapter

L’adapter fait le pont entre le domain (pur) et le monde technique (API, BDD, fichiers).

┌─────────────────┐ │ Use Case │ │ (Application) │ └────────┬────────┘ │ utilise ┌─────────────────┐ │ TrendRepository │ ← Port de sortie (interface) │ (Domain) │ └────────▲────────┘ │ implements ┌────────┴────────┐ │ Mock Adapter │ ← Adapter (implémentation) │(Infrastructure) │ └─────────────────┘

Types d’Adapters

Il existe 4 types principaux d’adapters selon la source de données.

Mock Adapter

Utilisé pour : Développement, tests, démo

Avantages :

  • ✅ Pas de dépendance externe
  • ✅ Rapide (pas d’appel réseau)
  • ✅ Données prévisibles

Exemple :

@Singleton @Requires(property = "app.trend-source", value = "mock") public class MockTrendRepositoryAdapter implements TrendRepository { @Override public Optional<TrendResult> getTrends(TrendQuery query) { return Optional.of(generateMockData(query)); } }

Implémenter un Mock Adapter

Créer la Classe dans infrastructure/adapter/

package org.smoka.infrastructure.adapter; import io.micronaut.context.annotation.Requires; import jakarta.inject.Singleton; import org.smoka.domain.model.trends.TrendQuery; import org.smoka.domain.model.trends.TrendResult; import org.smoka.domain.port.output.TrendRepository; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; @Singleton @Requires(property = "app.trend-source", value = "mock") public class MockTrendRepositoryAdapter implements TrendRepository { // Implémentation à venir... }

@Requires : Cette annotation permet d’activer l’adapter uniquement si app.trend-source=mock dans application.yml.

Implémenter le Port de Sortie

@Override public Optional<TrendResult> getTrends(TrendQuery query) { // Générer des données mockées TrendResult result = TrendResult.builder() .keyword(query.keyword()) .region(query.region()) .interestScore(generateRandomScore()) .relatedTopics(generateRelatedTopics(query.keyword())) .queriedAt(LocalDateTime.now()) .build(); return Optional.of(result); }

Ajouter les Méthodes Utilitaires

private int generateRandomScore() { return ThreadLocalRandom.current().nextInt(50, 101); } private List<String> generateRelatedTopics(String keyword) { return List.of( keyword + " tutorial", keyword + " framework", keyword + " best practices", "learn " + keyword, keyword + " vs competitors" ); }

Configurer dans application.yml

app: trend-source: mock # Active MockTrendRepositoryAdapter

Micronaut injecte automatiquement MockTrendRepositoryAdapter car il implémente TrendRepository et respecte la condition @Requires.


Implémenter un API Adapter

Créer le DTO dans infrastructure/dto/

Le DTO mappe exactement la structure JSON de l’API externe.

// infrastructure/dto/GoogleApiDto.java package org.smoka.infrastructure.dto; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor public class GoogleApiDto { @JsonProperty("keyword") private String keyword; @JsonProperty("geo") private String region; @JsonProperty("interest_over_time") private InterestData interestData; @Data @NoArgsConstructor public static class InterestData { @JsonProperty("score") private Integer score; @JsonProperty("related_queries") private String[] relatedQueries; } }

Important : Les DTOs utilisent Jackson (@JsonProperty) car ils sont dans l’infrastructure. Le domain reste pur !

Configurer le HttpClient

# application.yml micronaut: http: services: google-trends: url: https://trends.google.com/api read-timeout: 30s connect-timeout: 10s

Créer l’Adapter

package org.smoka.infrastructure.adapter; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.client.HttpClient; import io.micronaut.http.client.annotation.Client; import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; import org.smoka.domain.model.trends.TrendQuery; import org.smoka.domain.model.trends.TrendResult; import org.smoka.domain.port.output.TrendRepository; import org.smoka.infrastructure.dto.GoogleApiDto; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import java.util.Optional; @Slf4j @Singleton @Requires(property = "app.trend-source", value = "google-api") public class GoogleTrendsApiAdapter implements TrendRepository { @Inject @Client(id = "google-trends") HttpClient httpClient; @Override public Optional<TrendResult> getTrends(TrendQuery query) { try { // 1. Construire la requête HTTP String path = String.format( "/trends?q=%s&geo=%s", query.keyword(), query.region() ); HttpRequest<Object> request = HttpRequest.GET(path); // 2. Appeler l'API → Jackson désérialise automatiquement GoogleApiDto dto = httpClient.toBlocking() .retrieve(request, GoogleApiDto.class); // 3. Convertir DTO → Domain TrendResult result = toDomain(dto); return Optional.of(result); } catch (Exception e) { log.error("Error calling Google Trends API for keyword: {}", query.keyword(), e); return Optional.empty(); } } private TrendResult toDomain(GoogleApiDto dto) { List<String> relatedTopics = dto.getInterestData() != null ? Arrays.asList(dto.getInterestData().getRelatedQueries()) : List.of(); return TrendResult.builder() .keyword(dto.getKeyword()) .region(dto.getRegion()) .interestScore(dto.getInterestData().getScore()) .relatedTopics(relatedTopics) .queriedAt(LocalDateTime.now()) .build(); } }

Activer l’Adapter

# application-prod.yml app: trend-source: google-api # Active GoogleTrendsApiAdapter en prod

Avantage : Changer app.trend-source: mock en app.trend-source: google-api bascule automatiquement d’un adapter à l’autre sans changer une ligne de code !


Implémenter un Database Adapter

Créer l’Entity JPA

// infrastructure/entity/TrendEntity.java package org.smoka.infrastructure.entity; import jakarta.persistence.*; import lombok.Data; import java.time.LocalDateTime; @Data @Entity @Table(name = "trends") public class TrendEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String keyword; @Column(nullable = false) private String region; @Column(name = "interest_score") private Integer interestScore; @Column(name = "queried_at") private LocalDateTime queriedAt; @Column(name = "related_topics", columnDefinition = "TEXT") private String relatedTopicsJson; // JSON sérialisé }

Créer le JPA Repository

// infrastructure/repository/TrendEntityRepository.java package org.smoka.infrastructure.repository; import io.micronaut.data.annotation.Repository; import io.micronaut.data.jpa.repository.JpaRepository; import org.smoka.infrastructure.entity.TrendEntity; import java.util.Optional; @Repository public interface TrendEntityRepository extends JpaRepository<TrendEntity, Long> { Optional<TrendEntity> findByKeywordAndRegion(String keyword, String region); }

Créer l’Adapter avec Conversion

// infrastructure/adapter/DatabaseTrendAdapter.java package org.smoka.infrastructure.adapter; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.micronaut.context.annotation.Requires; import jakarta.inject.Inject; import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; import org.smoka.domain.model.trends.TrendQuery; import org.smoka.domain.model.trends.TrendResult; import org.smoka.domain.port.output.TrendRepository; import org.smoka.infrastructure.entity.TrendEntity; import org.smoka.infrastructure.repository.TrendEntityRepository; import java.util.List; import java.util.Optional; @Slf4j @Singleton @Requires(property = "app.trend-source", value = "database") public class DatabaseTrendAdapter implements TrendRepository { @Inject TrendEntityRepository repository; @Inject ObjectMapper objectMapper; @Override public Optional<TrendResult> getTrends(TrendQuery query) { return repository.findByKeywordAndRegion(query.keyword(), query.region()) .map(this::toDomain); } private TrendResult toDomain(TrendEntity entity) { List<String> relatedTopics = deserializeRelatedTopics( entity.getRelatedTopicsJson() ); return TrendResult.builder() .keyword(entity.getKeyword()) .region(entity.getRegion()) .interestScore(entity.getInterestScore()) .relatedTopics(relatedTopics) .queriedAt(entity.getQueriedAt()) .build(); } private List<String> deserializeRelatedTopics(String json) { try { return objectMapper.readValue( json, new TypeReference<List<String>>() {} ); } catch (Exception e) { log.warn("Failed to deserialize related topics", e); return List.of(); } } }

Configuration BDD

# application.yml datasources: default: url: jdbc:postgresql://localhost:5432/trends_db username: postgres password: secret driver-class-name: org.postgresql.Driver jpa: default: properties: hibernate: hbm2ddl: auto: update show_sql: true app: trend-source: database # Active DatabaseTrendAdapter

Règles de Conversion DTO ↔ Domain

Règle d’or : La conversion se fait TOUJOURS dans l’adapter, JAMAIS dans le DTO.

Conversion dans le DTO (INTERDIT)

// infrastructure/dto/GoogleApiDto.java @Data public class GoogleApiDto { private String keyword; private Integer score; // ❌ MAUVAIS : Le DTO dépendrait du Domain ! public TrendResult toDomain() { return new TrendResult(keyword, score); } }

Problèmes :

  • Le DTO dépend du domain (dépendance circulaire)
  • Viole l’architecture hexagonale
  • Difficile à tester

Conversion de Structures Complexes

Map → List

L’API renvoie une Map, le domain attend une List.

// DTO (structure de l'API) @Data public class CountryApiDto { @JsonProperty("translations") private Map<String, TranslationDto> translations; } // Adapter : Conversion Map → List private List<Translation> convertTranslations( Map<String, TranslationDto> translationsMap ) { if (translationsMap == null) { return List.of(); } return translationsMap.entrySet().stream() .map(entry -> new Translation( entry.getKey(), // languageCode entry.getValue().getCommon(), entry.getValue().getOfficial() )) .collect(Collectors.toList()); }

Gestion des Erreurs

Try-Catch avec Logs

@Override public Optional<TrendResult> getTrends(TrendQuery query) { try { GoogleApiDto dto = httpClient.toBlocking() .retrieve(request, GoogleApiDto.class); return Optional.of(toDomain(dto)); } catch (HttpClientResponseException e) { if (e.getStatus().getCode() == 404) { log.info("No trends found for keyword: {}", query.keyword()); } else { log.error("API error ({}): {}", e.getStatus(), e.getMessage()); } return Optional.empty(); } catch (Exception e) { log.error("Unexpected error calling API", e); return Optional.empty(); } }

Utilisez Optional.empty() pour indiquer qu’aucune donnée n’a été trouvée (pas une erreur critique).


Plusieurs Adapters pour le Même Port

Un des grands avantages de l’architecture hexagonale : avoir plusieurs implémentations.

Basculer par Configuration

// Mock (dev) @Singleton @Requires(property = "app.trend-source", value = "mock") public class MockTrendRepositoryAdapter implements TrendRepository { } // API (prod) @Singleton @Requires(property = "app.trend-source", value = "google-api") public class GoogleTrendsApiAdapter implements TrendRepository { } // BDD (analyse) @Singleton @Requires(property = "app.trend-source", value = "database") public class DatabaseTrendAdapter implements TrendRepository { }

Configuration :

# application-dev.yml app: trend-source: mock # application-prod.yml app: trend-source: google-api

Changez d’adapter sans toucher au code, juste en modifiant la configuration !


Bonnes Pratiques

Nommage

// ✅ BON : Suffixe "Adapter" MockTrendRepositoryAdapter.java GoogleTrendsApiAdapter.java DatabaseTrendAdapter.java FileTrendAdapter.java CachedTrendRepositoryAdapter.java

Convention : {Source}{Port}Adapter

Logs

@Slf4j @Singleton public class GoogleTrendsApiAdapter implements TrendRepository { @Override public Optional<TrendResult> getTrends(TrendQuery query) { log.debug("Calling Google Trends API for keyword: {}", query.keyword()); try { GoogleApiDto dto = httpClient.toBlocking() .retrieve(request, GoogleApiDto.class); log.info("Successfully retrieved trends for: {}", query.keyword()); return Optional.of(toDomain(dto)); } catch (Exception e) { log.error("Failed to retrieve trends for: {}", query.keyword(), e); return Optional.empty(); } } }

Niveaux de logs :

  • DEBUG : Détails techniques (requêtes HTTP)
  • INFO : Opérations réussies importantes
  • WARN : Anomalies non bloquantes
  • ERROR : Erreurs techniques nécessitant attention

Tests

@MicronautTest class GoogleTrendsApiAdapterTest { @Inject @Client("/") HttpClient httpClient; @MockBean(GoogleTrendsApiAdapter.class) TrendRepository trendRepository() { return mock(TrendRepository.class); } @Test void shouldReturnTrendsWhenApiSucceeds() { // Arrange TrendQuery query = new TrendQuery("java", "US", LocalDateTime.now()); TrendResult expected = TrendResult.builder() .keyword("java") .region("US") .interestScore(85) .build(); when(trendRepository.getTrends(query)) .thenReturn(Optional.of(expected)); // Act Optional<TrendResult> result = trendRepository.getTrends(query); // Assert assertThat(result).isPresent(); assertThat(result.get().getKeyword()).isEqualTo("java"); } }

Checklist Adapter

Avant de considérer votre adapter terminé :

  • L’adapter est dans infrastructure/adapter/
  • Il implémente une interface du domain (TrendRepository)
  • Il utilise @Singleton pour l’injection
  • Il gère les erreurs avec try-catch et logs
  • Il convertit DTO → Domain dans des méthodes privées toDomain()
  • Il ne contient AUCUNE logique métier
  • Il utilise @Requires pour l’activation conditionnelle (optionnel)
  • Les DTOs sont dans infrastructure/dto/ (pas dans l’adapter)
  • Les logs sont pertinents (DEBUG, INFO, ERROR)
  • Il est testé (unitaire ou intégration)

Résumé

Les adapters sont la couche technique qui implémente les ports. Ils permettent de changer de source de données sans toucher au domain ni aux use cases.

Type d’AdapterCas d’UsageExemple
MockDev, tests, démoMockTrendRepositoryAdapter
API ExterneConsommer API RESTGoogleTrendsApiAdapter
Base de DonnéesPersister donnéesDatabaseTrendAdapter
FichiersLire/écrire JSON/CSVFileTrendAdapter
CacheOptimiser perfCachedTrendRepositoryAdapter

Règle d’or : L’adapter traduit entre le monde technique (DTO) et le domain (entités pures). La conversion se fait TOUJOURS dans l’adapter.


Prochaines étapes

Maintenant que vous savez créer des adapters, explorez la gestion des exceptions et l’utilisation avancée de ObjectMapper.

Last updated on