Charles Hornick

Hot-Swap d'Adapters à Runtime avec Ports & Adapters

Le pattern qu'Alistair Cockburn n'avait pas documenté

🇬🇧 English version on dev.to

Quand Alistair Cockburn a décrit l'Architecture Hexagonale en 2005, il a documenté l'interchangeabilité des adapters comme propriété fondamentale du pattern. Dans le chapitre 5 de son ouvrage, il écrit :

"For each port, there may be multiple adapters, for various technologies that may plug into that port."

Ce que le pattern original ne traite pas : que se passe-t-il quand on doit changer d'adapter à runtime, à travers un système distribué, en réponse à une panne, et avant que les autres instances ne rencontrent le même problème ?

Cet article présente une implémentation concrète de hot-swap d'urgence d'adapters avec Spring Boot 4, Spring Cloud Bus et Resilience4j. Quand une instance détecte un adapter défaillant, elle broadcast la panne via un bus de messages. Toutes les autres instances switchent vers un adapter de fallback avant de rencontrer le timeout elles-mêmes.

La réaction de Cockburn quand je lui ai expliqué le concept sur LinkedIn :

"Fabulous! I got around to documenting live swaps for long-running systems, but didn't dare think about emergency hot-swapping — thank you!"

Le problème

Prenons un setup Ports & Adapters classique : votre service métier appelle une API upstream à travers un adapter. Quand cette API commence à timeout, chaque instance de votre service va découvrir la panne indépendamment, chacune brûlant sa propre fenêtre de timeout.

Avec 10 instances et un timeout de 3 secondes, c'est 30 secondes d'expérience dégradée cumulée sur votre cluster, au minimum. En pratique, les retries, la saturation des thread pools et les pannes en cascade aggravent largement la situation.

Le pattern circuit breaker standard (Hystrix, Resilience4j) résout ça par instance : chaque service ouvre indépendamment son circuit après avoir détecté des pannes. Mais il n'y a aucune coordination. L'instance 7 ne sait pas que l'instance 1 a déjà touché le timeout 2 secondes plus tôt.

La solution : switch d'adapter piloté par broadcast

L'insight clé : la première instance à détecter une panne doit prévenir toutes les autres.

Instance 1 : timeout détecté → broadcast "switch vers fallback"
Instance 2 : reçoit l'event → switch l'adapter (ne touche jamais le timeout)
Instance 3 : reçoit l'event → switch l'adapter (ne touche jamais le timeout)
...
Instance N : reçoit l'event → switch l'adapter (ne touche jamais le timeout)

C'est de l'eventual consistency appliquée à l'infrastructure. L'état de l'adapter se propage à travers le cluster de façon asynchrone, et chaque instance converge vers le même adapter en quelques millisecondes après la première détection.

Implémentation

Le Port

Rien de spécial ici, juste une interface de port standard :

public interface AnimalPort {
    String getAnimal();
    String name();
}

Le registre d'adapters

La pièce critique. Un AtomicReference contient l'adapter actif, rendant le swap lock-free et thread-safe :

@Component
public class AdapterConfig {

    private final Map<String, AnimalPort> adapters;
    private final AtomicReference<AnimalPort> activeAdapter;

    public AdapterConfig(
            @Qualifier("primaryAdapter") final AnimalPort primary,
            @Qualifier("fallbackAdapter") final AnimalPort fallback) {
        this.adapters = Map.of("primary", primary, "fallback", fallback);
        this.activeAdapter = new AtomicReference<>(primary);
    }

    public AnimalPort getActiveAdapter() {
        return activeAdapter.get();
    }

    public boolean switchTo(String adapterName) {
        final var target = adapters.get(adapterName);
        final var previous = activeAdapter.getAndSet(target);
        return !previous.name().equals(adapterName);
    }
}

Le swap est une opération atomique unique. Les threads en cours de requête via l'ancien adapter terminent leur appel ; les nouvelles requêtes utilisent immédiatement le nouvel adapter. Pas de locks, pas de blocs synchronized.

Détection de timeout + broadcast

La couche service wrappe les appels adapter avec le TimeLimiter de Resilience4j. En cas de timeout, elle publie un event custom Spring Cloud Bus :

@Service
public class AnimalService {

    private final AdapterConfig adapterConfig;
    private final ApplicationEventPublisher publisher;
    private final BusProperties busProperties;
    private final TimeLimiter timeLimiter;
    private final ExecutorService executor;

    public AnimalService(final AdapterConfig adapterConfig,
                         final ApplicationEventPublisher publisher,
                         final BusProperties busProperties) {
        this.adapterConfig = adapterConfig;
        this.publisher = publisher;
        this.busProperties = busProperties;

        this.timeLimiter = TimeLimiter.of(
                TimeLimiterConfig.custom()
                        .timeoutDuration(Duration.ofSeconds(3))
                        .cancelRunningFuture(true)
                        .build()
        );

        this.executor = Executors.newVirtualThreadPerTaskExecutor();
    }

    public String getAnimal() {
        final var adapter = adapterConfig.getActiveAdapter();
        try {
            final var future = executor.submit(adapter::getAnimal);
            return timeLimiter.executeFutureSupplier(() -> future);
        } catch (final Exception e) {
            // Première instance à détecter → broadcast à toutes
            publisher.publishEvent(new AdapterSwitchEvent(
                this, busProperties.getId(), "**", "fallback"
            ));
            return adapterConfig.getActiveAdapter().getAnimal();
        }
    }
}

Décisions techniques clés :

  • Virtual threads (Executors.newVirtualThreadPerTaskExecutor()) : les virtual threads de Java 25 gardent le wrapper de timeout léger. Pas de blocage de platform thread.
  • Destination "**" : l'event bus cible tous les services, pas une instance spécifique.
  • Fallback local immédiat : après le broadcast, la requête en cours fallback localement sans attendre le round-trip du bus.

L'event bus

Un RemoteApplicationEvent custom qui transporte le nom de l'adapter cible :

public class AdapterSwitchEvent extends RemoteApplicationEvent {

    private final String targetAdapter;

    public AdapterSwitchEvent(final Object source,
                              final String originService,
                              final String destinationService,
                              final String targetAdapter) {
        super(source != null ? source : new Object(), originService, () -> destinationService != null ? destinationService : "**");
        this.targetAdapter = targetAdapter;
    }

    // getter
}

Spring Cloud Bus sérialise ça en JSON, le pousse dans RabbitMQ, et chaque instance connectée le reçoit. Le listener sur chaque instance effectue le swap :

@EventListener
public void onAdapterSwitch(final AdapterSwitchEvent event) {
    adapterConfig.switchTo(event.getTargetAdapter());
}

Détection de recovery

Un health checker planifié ping le service primaire et broadcast un switch-back quand il récupère :

@Scheduled(fixedDelayString = "${health.check.interval:5000}")
public void checkPrimaryHealth() {
    if ("primary".equals(adapterConfig.getActiveAdapterName())) {
        return; // already on primary, nothing to check
    }
    try {
        String response = restClient.get().uri("/health")
            .retrieve().body(String.class);
        if ("UP".equals(response)) {
            publisher.publishEvent(new AdapterSwitchEvent(
                this, busProperties.getId(), "**", "primary"
            ));
        }
    } catch (Exception e) {
        // still down, do nothing
    }
}

Lancer la démo

git clone https://github.com/charles-hornick/adapter-hotswap-spring.git
cd adapter-hotswap-spring
docker-compose up --build

La démo lance deux instances du service hotswap, un service primaire instable (timeouts cycliques) et un fallback stable. Dans les logs :

Response: Chien          ← adapter primaire
Response: Chien
EVENT: Primary adapter timeout - broadcasting switch to fallback
EVENT: Switching adapter from 'primary' to 'fallback'
(instance 2) EVENT RECEIVED: Switch to 'fallback'
Response: Chat            ← adapter fallback
HEALTHCHECK: Primary adapter responding again
EVENT: Switching adapter from 'fallback' to 'primary'
Response: Chien          ← retour au primaire

L'instance 2 switche sans jamais avoir subi le timeout.

Limites et compromis

C'est une démo, pas un framework de production. Considérations pour le monde réel :

Risque de split-brain. Si RabbitMQ est partitionné, les instances peuvent diverger sur l'adapter actif. Mitigation : utiliser un mécanisme de consensus ou accepter la convergence éventuelle via le health checker.

Thundering herd au recovery. Quand le health checker broadcast "retour au primaire", toutes les instances tapent le primaire simultanément. Mitigation : ajouter du jitter à l'intervalle du health check, ou approche canary où une seule instance switche d'abord.

Point de décision unique. L'instance qui détecte la panne en premier prend la décision pour tout le cluster. Si cette détection est un faux positif (glitch réseau), tout le cluster switche inutilement. Mitigation : exiger N pannes consécutives avant de broadcaster, ou décision par quorum.

Latence du bus. Il y a une fenêtre entre le broadcast et la réception où d'autres instances peuvent encore toucher l'adapter défaillant. C'est inhérent à l'eventual consistency. Pour la plupart des use cases, le délai de propagation RabbitMQ (typiquement <100ms) est négligeable comparé à un timeout de 3 secondes.

Pas de persistance de l'état. Si une instance redémarre, elle revient à l'adapter primaire par défaut, indépendamment de l'état du cluster. Mitigation : persister l'état dans un store partagé (Redis, base de données) ou rejouer le dernier event du bus au démarrage.

Quand utiliser ce pattern

Ce pattern apporte le plus de valeur quand :

  • Vous avez plusieurs instances du même service
  • Les pannes d'adapter sont détectables (timeout, HTTP 5xx, connection refused)
  • Le coût de la détection indépendante sur chaque instance est significatif
  • Vous disposez d'un adapter de fallback viable (service dégradé, cache, fournisseur alternatif)

C'est overkill pour les déploiements mono-instance ou quand les pannes sont assez rares pour que des circuit breakers indépendants suffisent.

Relation avec le travail original de Cockburn

L'Architecture Hexagonale décrit les adapters comme interchangeables, pour qu'on puisse swapper un adapter base de données pour un adapter in-memory, ou un adapter REST pour un adapter gRPC. Mais le pattern original est muet sur quand et comment ce swap se fait à runtime.

Cette implémentation étend le pattern dans deux directions :

  1. Swap à runtime : les adapters changent pendant que le système tourne, sans restart ni redéploiement.
  2. Coordination cluster-wide : le swap se propage à toutes les instances, transformant une détection de panne locale en décision d'infrastructure globale.

L'adapter reste un concept architectural de première classe. On lui donne simplement des capacités opérationnelles que le pattern original impliquait mais n'a jamais spécifiées.

Stack : Java 25, Spring Boot 4.0.2, Spring Cloud 2025.1.0, Resilience4j 2.3.0, RabbitMQ