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
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
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 MockTrendRepositoryAdapterMicronaut 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: 10sCré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 prodAvantage : 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 DatabaseTrendAdapterRègles de Conversion DTO ↔ Domain
Règle d’or : La conversion se fait TOUJOURS dans l’adapter, JAMAIS dans le DTO.
❌ Mauvais
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
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
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.
Configuration
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-apiChangez d’adapter sans toucher au code, juste en modifiant la configuration !
Bonnes Pratiques
Nommage
✅ Bon
// ✅ BON : Suffixe "Adapter"
MockTrendRepositoryAdapter.java
GoogleTrendsApiAdapter.java
DatabaseTrendAdapter.java
FileTrendAdapter.java
CachedTrendRepositoryAdapter.javaConvention : {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 importantesWARN: Anomalies non bloquantesERROR: 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
@Singletonpour 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
@Requirespour 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’Adapter | Cas d’Usage | Exemple |
|---|---|---|
| Mock | Dev, tests, démo | MockTrendRepositoryAdapter |
| API Externe | Consommer API REST | GoogleTrendsApiAdapter |
| Base de Données | Persister données | DatabaseTrendAdapter |
| Fichiers | Lire/écrire JSON/CSV | FileTrendAdapter |
| Cache | Optimiser perf | CachedTrendRepositoryAdapter |
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.