Skip to Content
02 ArchitectureCouche Infrastructure

Couche Infrastructure

La couche Infrastructure contient les adaptateurs qui implémentent les détails techniques.

L’infrastructure est la couche la plus externe : elle fait le lien entre votre application et le monde extérieur (BDD, APIs, fichiers).


Rôle de la Couche Infrastructure

La couche Infrastructure a 3 responsabilités principales :

1. Implémenter les Ports de Sortie

L’infrastructure implémente les interfaces définies par le domain.

// domain/port/output/TrendRepository.java (interface) public interface TrendRepository { Optional<TrendResult> getTrends(TrendQuery query); } // infrastructure/adapter/MockTrendRepositoryAdapter.java (implémentation) @Singleton public class MockTrendRepositoryAdapter implements TrendRepository { @Override public Optional<TrendResult> getTrends(TrendQuery query) { return Optional.of(generateMockData()); } }

Structure de la Couche Infrastructure

      • MockTrendRepositoryAdapter.java
      • GoogleTrendsApiAdapter.java
      • CountryRepositoryAdapter.java

Organisation :

  • adapter/ = Implémentations des ports de sortie
  • dto/ = DTOs techniques (API externe, BDD)
  • config/ = Configuration (HttpClient, ObjectMapper)

Types d’Adapters

1. Mock Adapter (Développement)

Utilisé pour le développement sans dépendre d’API externe.

@Singleton @Requires(property = "app.trend-source", value = "mock") public class MockTrendRepositoryAdapter implements TrendRepository { @Override public Optional<TrendResult> getTrends(TrendQuery query) { // Générer des données mockées TrendResult result = TrendResult.builder() .keyword(query.getKeyword()) .region(query.getRegion()) .interestScore(generateRandomScore()) .relatedTopics(List.of()) .queriedAt(LocalDateTime.now()) .build(); return Optional.of(result); } private int generateRandomScore() { return ThreadLocalRandom.current().nextInt(50, 101); } }

@Requires permet de choisir l’adapter selon la configuration :

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

2. API Adapter (HTTP Client)

Appelle une API externe REST.

@Slf4j @Singleton @Requires(property = "app.trend-source", value = "google-api") public class GoogleTrendsApiAdapter implements TrendRepository { @Inject @Client(id = "google-trends", path = "/trends") HttpClient httpClient; @Override public Optional<TrendResult> getTrends(TrendQuery query) { try { log.info("Calling Google Trends API for keyword={}", query.getKeyword()); // 1. Appeler l'API (Jackson désérialise automatiquement) String url = String.format( "/explore?keyword=%s&region=%s", query.getKeyword(), query.getRegion() ); GoogleApiDto dto = httpClient.toBlocking() .retrieve(HttpRequest.GET(url), GoogleApiDto.class); // 2. Convertir DTO → Domain TrendResult result = toDomain(dto, query); log.info("Got trend score={}", result.getInterestScore()); return Optional.of(result); } catch (HttpClientException e) { log.error("Failed to call Google Trends API", e); return Optional.empty(); } } private TrendResult toDomain(GoogleApiDto dto, TrendQuery query) { return TrendResult.builder() .keyword(query.getKeyword()) .region(query.getRegion()) .interestScore(dto.getScore()) .relatedTopics(convertRelatedTopics(dto.getRelated())) .queriedAt(LocalDateTime.now()) .build(); } private List<RelatedTopic> convertRelatedTopics(List<GoogleRelatedDto> dtos) { if (dtos == null) { return List.of(); } return dtos.stream() .map(dto -> new RelatedTopic(dto.getTopic(), dto.getValue())) .collect(Collectors.toList()); } }

3. Database Adapter (JPA/JDBC)

Accède à une base de données.

@Slf4j @Singleton public class JpaUserRepositoryAdapter implements UserRepository { @Inject EntityManager entityManager; @Override public Optional<User> findById(Long id) { UserEntity entity = entityManager.find(UserEntity.class, id); if (entity == null) { return Optional.empty(); } return Optional.of(toDomain(entity)); } @Override @Transactional public void save(User user) { UserEntity entity = fromDomain(user); entityManager.persist(entity); } private User toDomain(UserEntity entity) { return new User( entity.getId(), entity.getName(), new Email(entity.getEmail()), entity.getCreatedAt() ); } private UserEntity fromDomain(User user) { UserEntity entity = new UserEntity(); entity.setId(user.getId()); entity.setName(user.getName()); entity.setEmail(user.getEmail().getValue()); entity.setCreatedAt(user.getCreatedAt()); return entity; } }

4. File Adapter

Lit/Écrit des fichiers.

@Slf4j @Singleton public class FileConfigurationAdapter implements ConfigurationRepository { private final ObjectMapper objectMapper; private final String configPath; @Inject public FileConfigurationAdapter( ObjectMapper objectMapper, @Value("${app.config.path}") String configPath ) { this.objectMapper = objectMapper; this.configPath = configPath; } @Override public Optional<Configuration> load() { try { File file = new File(configPath); if (!file.exists()) { log.warn("Configuration file not found: {}", configPath); return Optional.empty(); } ConfigurationDto dto = objectMapper.readValue(file, ConfigurationDto.class); return Optional.of(toDomain(dto)); } catch (IOException e) { log.error("Failed to load configuration", e); return Optional.empty(); } } @Override public void save(Configuration config) { try { ConfigurationDto dto = fromDomain(config); objectMapper.writeValue(new File(configPath), dto); log.info("Configuration saved to {}", configPath); } catch (IOException e) { log.error("Failed to save configuration", e); throw new ConfigurationException("Failed to save", e); } } }

DTOs Infrastructure

Les DTOs Infrastructure mappent les structures techniques (JSON API, BDD).

DTO pour API Externe

@Data @NoArgsConstructor public class GoogleApiDto { @JsonProperty("keyword") private String keyword; @JsonProperty("interest_score") private Integer score; @JsonProperty("related_queries") private List<GoogleRelatedDto> related; } @Data @NoArgsConstructor public class GoogleRelatedDto { @JsonProperty("query") private String topic; @JsonProperty("value") private Integer value; }

Entity JPA (pour BDD)

@Entity @Table(name = "users") @Data public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(nullable = false, unique = true) private String email; @Column(name = "created_at") private LocalDateTime createdAt; }

Conversion DTO ↔ Domain

La conversion DOIT être dans l’adaptateur, PAS dans le DTO ou le domain.

Exemple Complet

@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 → DTO (Jackson automatique) CountryApiDto[] dtos = httpClient.toBlocking() .retrieve("/all?fields=name,translations", CountryApiDto[].class); // 2. Convertir DTO → Domain return Stream.of(dtos) .map(this::toDomain) .collect(Collectors.toList()); } catch (HttpClientException e) { log.error("Failed to fetch countries", e); return List.of(); } } /** * Convertit CountryApiDto (infrastructure) → Country (domain). */ private Country toDomain(CountryApiDto dto) { Name name = new Name( dto.getName().getCommon(), dto.getName().getOfficial() ); List<Translation> translations = convertTranslations( dto.getTranslations() ); return new Country(name, translations); } /** * Convertit Map<String, TranslationDto> → List<Translation>. */ private List<Translation> convertTranslations( Map<String, TranslationDto> translationsMap ) { if (translationsMap == null) { return List.of(); } return translationsMap.entrySet().stream() .map(entry -> new Translation( entry.getKey(), entry.getValue().getCommon(), entry.getValue().getOfficial() )) .collect(Collectors.toList()); } }

Configuration

HttpClient Configuration

# application.yml micronaut: application: name: google-trends-app server: port: 8080 http: client: read-timeout: 30s services: google-trends: urls: - https://trends.google.com read-timeout: 30s countries: urls: - https://restcountries.com read-timeout: 10s

ObjectMapper Factory

@Factory public class ObjectMapperFactory { @Bean @Named("logging") @Singleton public ObjectMapper loggingObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.enable(SerializationFeature.INDENT_OUTPUT); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } }

Choisir l’Adapter avec @Requires

Utilisez @Requires pour basculer entre adapters selon l’environnement.

Mock pour dev

@Singleton @Requires(property = "app.trend-source", value = "mock") public class MockTrendRepositoryAdapter implements TrendRepository { // ... }

API pour prod

@Singleton @Requires(property = "app.trend-source", value = "google-api") public class GoogleTrendsApiAdapter implements TrendRepository { // ... }

Configuration

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

Micronaut injecte automatiquement le bon adapter selon la configuration !


Gestion des Erreurs

Try-Catch dans l’Adapter

@Override public Optional<TrendResult> getTrends(TrendQuery query) { try { GoogleApiDto dto = httpClient.toBlocking() .retrieve(buildUrl(query), GoogleApiDto.class); return Optional.of(toDomain(dto, query)); } catch (HttpClientException e) { log.error("API call failed for keyword={}", query.getKeyword(), e); return Optional.empty(); } }

Retry avec @Retryable

@Retryable( attempts = "3", delay = "1s", multiplier = "2.0" ) @Override public Optional<TrendResult> getTrends(TrendQuery query) { // Retry automatique en cas d'erreur GoogleApiDto dto = httpClient.toBlocking() .retrieve(buildUrl(query), GoogleApiDto.class); return Optional.of(toDomain(dto, query)); }

Bonnes Pratiques

1. Un Adapter = Un Port

// ✅ BON : Un adapter par port @Singleton public class MockTrendRepositoryAdapter implements TrendRepository { // ... } // ❌ MAUVAIS : Un adapter pour plusieurs ports @Singleton public class MockAdapter implements TrendRepository, UserRepository { // Trop de responsabilités }

2. Logs dans l’Adapter

@Override public Optional<TrendResult> getTrends(TrendQuery query) { log.info("Calling API for keyword={}, region={}", query.getKeyword(), query.getRegion()); TrendResult result = // ... API call log.info("Got score={} for keyword={}", result.getInterestScore(), query.getKeyword()); return Optional.of(result); }

3. Méthodes de Conversion Privées

// ✅ BON : Méthodes privées dans l'adapter private TrendResult toDomain(GoogleApiDto dto) { } private GoogleApiDto fromDomain(TrendResult result) { } // ❌ MAUVAIS : Méthodes publiques dans le DTO // public class GoogleApiDto { // public TrendResult toDomain() { } // DTO ne doit pas connaître le domain // }

Checklist Couche Infrastructure

Pour vérifier que votre infrastructure est bien conçue :

  • Les adapters implémentent des interfaces du domain
  • Les adapters ont des logs (info, error)
  • La conversion DTO → Domain est dans l’adapter
  • Les DTOs sont dans infrastructure/dto/
  • Les DTOs utilisent @JsonProperty (Jackson)
  • Les erreurs sont gérées (try-catch ou @Retryable)
  • Utilisation de @Requires pour choisir l’adapter
  • Configuration HTTP dans application.yml

Résumé

L’infrastructure implémente les ports de sortie et gère tous les détails techniques.

Type d’AdapterTechnologieExemple
MockDonnées en durMockTrendRepositoryAdapter
API RESTHttpClientGoogleTrendsApiAdapter
Base de donnéesJPA, JDBCJpaUserRepositoryAdapter
FichiersObjectMapperFileConfigurationAdapter
CacheRedis, HazelcastRedisCacheAdapter

Principe clé : L’infrastructure traduit entre le monde technique et le domain pur.


Prochaines étapes

Maintenant que vous maîtrisez les 3 couches, explorez comment implémenter concrètement vos adapters.

Last updated on