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.
Interface Port
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 maximumdelay = "1s": 1 seconde entre chaque tentativemultiplier = "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
1️⃣ Adapter Principal
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
| Pattern | Cas d’usage | Avantages | Inconvénients |
|---|---|---|---|
| Cache | Éviter appels répétitifs | Performance ⚡ | Données obsolètes |
| Retry | Erreurs temporaires (timeout, 5xx) | Résilience | Latence accrue |
| Fallback | API externe indisponible | Haute disponibilité | Complexité |
| Monitoring | Observabilité | Visibilité sur les performances | Overhead |
Bonnes Pratiques
Règles d’or pour la composition d’adapters :
- Ordre de composition : Cache → Fallback → Retry → Adapter principal
- Cache : uniquement sur les lectures (
findById,findAll) - Retry : pas sur les écritures (
save,update,delete) - Fallback : prévoir un adapter secondaire fiable (BDD locale, mock)
- Monitoring : logger les événements importants (fallback activé, retry)
- 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