Skip to Content
06 AdvancedAdapters Multiples et Composition

Adapters Multiples et Composition

Cette page présente les patterns avancés pour combiner plusieurs adapters : cache, fallback, retry et decorator pattern.


Pourquoi des Adapters Multiples ?

Dans une architecture hexagonale mature, il est courant de composer plusieurs adapters pour gérer :

  • Cache : éviter des appels réseau répétitifs
  • Fallback : basculer vers un adapter secondaire en cas d’échec
  • Retry : réessayer automatiquement en cas d’erreur temporaire
  • Monitoring : logger ou mesurer les performances

Pattern Decorator : envelopper un adapter existant pour ajouter un comportement sans modifier son code.


Pattern Decorator

Le Decorator Pattern permet d’ajouter des fonctionnalités autour d’un adapter existant.

Port de sortie

public interface UserRepository { Optional<User> findById(Long id); List<User> findAll(); User save(User user); }

Attention : ne cachez pas les opérations d’écriture (save, update, delete) pour éviter les incohérences.


Adapter avec Fallback

Le fallback bascule vers un adapter secondaire si le premier échoue.

Définir les adapters primaire et secondaire

// Adapter primaire (API externe) @Singleton @Primary public class ExternalApiUserRepository implements UserRepository { // ... } // Adapter secondaire (BDD locale) @Singleton @Secondary public class DatabaseUserRepository implements UserRepository { // ... }

Créer le Fallback Adapter

import io.micronaut.retry.annotation.Fallback; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @RequiredArgsConstructor public class FallbackUserRepository implements UserRepository { private final ExternalApiUserRepository primary; private final DatabaseUserRepository fallback; @Override public Optional<User> findById(Long id) { try { log.debug("Trying primary adapter..."); return primary.findById(id); } catch (Exception e) { log.warn("Primary adapter failed, using fallback: {}", e.getMessage()); return fallback.findById(id); } } @Override public List<User> findAll() { try { return primary.findAll(); } catch (Exception e) { log.warn("Primary adapter failed, using fallback"); return fallback.findAll(); } } @Override public User save(User user) { // Save uniquement dans le fallback (BDD locale) return fallback.save(user); } }

Injecter le Fallback Adapter

@RequiredArgsConstructor public class UserService { private final FallbackUserRepository userRepository; // Fallback automatique public User getUserById(Long id) { return userRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("User not found")); } }

Avantage : résilience face aux pannes d’API externes.


Adapter avec Retry

Le retry réessaye automatiquement en cas d’échec temporaire (timeout, 5xx).

Utiliser @Retryable

import io.micronaut.retry.annotation.Retryable; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @RequiredArgsConstructor public class RetryableUserRepository implements UserRepository { private final HttpClient httpClient; @Override @Retryable( attempts = "3", delay = "1s", multiplier = "2.0", includes = {HttpClientException.class, TimeoutException.class} ) public Optional<User> findById(Long id) { log.debug("Fetching user {} from API", id); HttpResponse<User> response = httpClient.toBlocking() .exchange(HttpRequest.GET("/users/" + id), User.class); return Optional.ofNullable(response.body()); } @Override @Retryable(attempts = "3", delay = "1s") public List<User> findAll() { // ... } @Override public User save(User user) { // Pas de retry sur save (risque de duplication) return httpClient.toBlocking().retrieve( HttpRequest.POST("/users", user), User.class ); } }

Configuration du retry :

  • attempts = "3" : 3 tentatives maximum
  • delay = "1s" : 1 seconde entre chaque tentative
  • multiplier = "2.0" : délai exponentiel (1s, 2s, 4s)
  • includes : exceptions à gérer

Ne pas utiliser de retry sur les opérations d’écriture pour éviter les doublons.


Composition Complète : Cache + Fallback + Retry

Voici une architecture complète avec 3 adapters composés :

        • ExternalApiUserRepository.java
        • DatabaseUserRepository.java
        • RetryableUserRepository.java
        • FallbackUserRepository.java
        • CachedUserRepository.java

Adapter API Externe

@Singleton public class ExternalApiUserRepository implements UserRepository { private final HttpClient httpClient; @Override public Optional<User> findById(Long id) { return httpClient.toBlocking() .retrieve(HttpRequest.GET("/users/" + id), User.class); } }

Flux de Données

Controller UserService (Use Case) CachedUserRepository (Cache) FallbackUserRepository (Fallback) RetryableUserRepository (Retry) ExternalApiUserRepository (API) (si échec) → DatabaseUserRepository (BDD locale)

Résultat : architecture robuste avec cache, retry et fallback automatiques.


Monitoring et Logging

Ajoutez du monitoring pour observer le comportement des adapters.

Decorator Monitoring

import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import jakarta.inject.Singleton; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Singleton @RequiredArgsConstructor public class MonitoredUserRepository implements UserRepository { private final UserRepository delegate; private final MeterRegistry meterRegistry; @Override public Optional<User> findById(Long id) { Timer.Sample sample = Timer.start(meterRegistry); try { log.debug("Fetching user {}", id); Optional<User> result = delegate.findById(id); sample.stop(Timer.builder("user.repository.findById") .tag("status", result.isPresent() ? "found" : "not_found") .register(meterRegistry)); return result; } catch (Exception e) { sample.stop(Timer.builder("user.repository.findById") .tag("status", "error") .register(meterRegistry)); throw e; } } @Override public List<User> findAll() { log.debug("Fetching all users"); List<User> users = delegate.findAll(); meterRegistry.counter("user.repository.findAll", "count", String.valueOf(users.size())).increment(); return users; } @Override public User save(User user) { log.info("Saving user: {}", user.getEmail()); User saved = delegate.save(user); meterRegistry.counter("user.repository.save").increment(); return saved; } }

Métriques disponibles :

  • user.repository.findById (timer)
  • user.repository.findAll (counter)
  • user.repository.save (counter)

Comparaison des Patterns

PatternCas d’usageAvantagesInconvénients
CacheÉviter appels répétitifsPerformance ⚡Données obsolètes
RetryErreurs temporaires (timeout, 5xx)RésilienceLatence accrue
FallbackAPI externe indisponibleHaute disponibilitéComplexité
MonitoringObservabilitéVisibilité sur les performancesOverhead

Bonnes Pratiques

Règles d’or pour la composition d’adapters :

  1. Ordre de composition : Cache → Fallback → Retry → Adapter principal
  2. Cache : uniquement sur les lectures (findById, findAll)
  3. Retry : pas sur les écritures (save, update, delete)
  4. Fallback : prévoir un adapter secondaire fiable (BDD locale, mock)
  5. Monitoring : logger les événements importants (fallback activé, retry)
  6. Tests : tester chaque decorator indépendamment avec des mocks

Tests Unitaires

Tester le Fallback

@Test void shouldFallbackWhenPrimaryFails() { // Given ExternalApiUserRepository primary = mock(ExternalApiUserRepository.class); DatabaseUserRepository fallback = mock(DatabaseUserRepository.class); FallbackUserRepository repository = new FallbackUserRepository(primary, fallback); User user = new User(1L, "john.doe@example.com"); when(primary.findById(1L)).thenThrow(new HttpClientException("API down")); when(fallback.findById(1L)).thenReturn(Optional.of(user)); // When Optional<User> result = repository.findById(1L); // Then assertThat(result).isPresent(); assertThat(result.get().getEmail()).isEqualTo("john.doe@example.com"); verify(primary).findById(1L); verify(fallback).findById(1L); }

Tester le Cache

@MicronautTest class CachedUserRepositoryTest { @Inject CachedUserRepository repository; @MockBean(UserRepository.class) UserRepository delegate() { return mock(UserRepository.class); } @Test void shouldCacheSecondCall() { // Given User user = new User(1L, "john.doe@example.com"); when(delegate.findById(1L)).thenReturn(Optional.of(user)); // When repository.findById(1L); // Appel 1 (cache miss) repository.findById(1L); // Appel 2 (cache hit) // Then verify(delegate, times(1)).findById(1L); // Appelé une seule fois } }

Références


Prochaine étape : Architecture Event-Driven

Last updated on