Runtime Adapter Hot-Swapping with Ports & Adapters — The Pattern Alistair Cockburn Didn't Document

Published: (February 12, 2026 at 04:07 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

When Alistair Cockburn described the Hexagonal Architecture in 2005, he documented adapter interchangeability as a core property of the pattern. In Chapter 5 of his work, he writes:

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

The original pattern leaves something open: what happens when you need to switch adapters at runtime, across a distributed system, in response to a failure, and before other instances encounter the same problem?

This article presents a concrete implementation of emergency adapter hot‑swapping using:

  • Spring Boot 4
  • Spring Cloud Bus
  • Resilience4j

When one service instance detects a failing adapter, it broadcasts the failure via a message bus. All other instances switch to a fallback adapter before they encounter the timeout themselves.

Cockburn’s reaction on LinkedIn:
“Fabulous! I got around to documenting live swaps for long‑running systems, but didn’t dare think about emergency hot‑swapping — thank you!”


The problem

Consider a standard Ports & Adapters setup where your domain service talks to an upstream API through an adapter. When that API starts timing out, every instance of your service will independently discover the failure, each burning through its own timeout window.

  • With 10 instances and a 3‑second timeout → 30 seconds of cumulative degraded experience (minimum).
  • In practice, retries, thread‑pool saturation, and cascading failures make this much worse.

The standard circuit‑breaker pattern (Hystrix, Resilience4j) solves this per instance: each service independently opens its circuit after detecting failures. There’s no coordination—instance 7 doesn’t know that instance 1 already hit the timeout 2 seconds ago.

The solution: broadcast‑driven adapter switching

Key insight: The first instance to detect a failure should warn all others.

Instance 1: timeout detected → broadcast "switch to fallback"
Instance 2: receives event → switches adapter (never hits the timeout)
Instance 3: receives event → switches adapter (never hits the timeout)
...
Instance N: receives event → switches adapter (never hits the timeout)

This is eventual consistency applied to infrastructure. The adapter state propagates across the cluster asynchronously, and every instance converges to the same adapter within milliseconds of the first detection.


Implementation

Full source code:

The Port

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

The Adapter Registry

The critical piece. An AtomicReference holds the active adapter, making the swap lock‑free and thread‑safe:

@Component
public class AdapterConfig {

    private final Map adapters;
    private final AtomicReference 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);
    }
}

The swap is a single atomic operation. Threads currently executing a request through the old adapter will finish their call; new requests immediately use the new adapter. No locks, no synchronized blocks.

Timeout detection + broadcast

The service layer wraps adapter calls with Resilience4j’s TimeLimiter. On timeout, it publishes a custom Spring Cloud Bus event:

@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()
        );

        // Java 25 virtual threads keep the timeout wrapper lightweight.
        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) {
            // First instance to detect failure → broadcast to all
            publisher.publishEvent(new AdapterSwitchEvent(
                    this,
                    busProperties.getId(),
                    "**",               // source identifier (could be omitted)
                    "fallback"
            ));
            // Fallback locally in case the broadcast is delayed
            return adapterConfig.getActiveAdapter().getAnimal();
        }
    }
}

Key decisions

DecisionReason
Virtual threads (Executors.newVirtualThreadPerTaskExecutor())Java 25’s virtual threads keep the timeout wrapper lightweight; no platform‑thread blocking.
AtomicReference for the active adapterGuarantees lock‑free, thread‑safe swaps.
Spring Cloud Bus event publishingProvides a simple, distributed message bus for broadcasting the switch.
Resilience4j TimeLimiterHandles per‑call timeout detection without coupling to a circuit‑breaker.

How it works end‑to‑end

  1. Instance 1 calls AnimalService.getAnimal().
  2. The call exceeds the 3‑second TimeLimiter.
  3. The catch block publishes an AdapterSwitchEvent on the bus.
  4. All instances (including Instance 1) receive the event and invoke AdapterConfig.switchTo("fallback").
  5. Subsequent calls on every instance use the fallback adapter without ever hitting the timeout.

Takeaways

  • Fast failure propagation across a cluster dramatically reduces cumulative latency during upstream outages.
  • Lock‑free adapter swaps avoid contention and keep request latency low.
  • Virtual threads make the timeout‑wrapper cheap enough to use per request.
  • The pattern can be generalized to any port/adapter pair where hot‑swapping is desirable.

Feel free to explore the full example repository and adapt the approach to your own Hexagonal Architecture projects!

Overview

  • Destination – the bus event targets all services, not a specific instance.
  • Immediate local fallback – after broadcasting, the current request falls back locally without waiting for the bus round‑trip.

The Bus Event

A custom RemoteApplicationEvent that carries the target adapter name:

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 serializes this to JSON, pushes it to RabbitMQ, and every connected instance receives it.
The listener on each instance performs the swap:

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

Recovery Detection

A scheduled health‑checker pings the primary service and broadcasts a switch‑back when it recovers:

@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
    }
}

Running the Demo

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

The demo runs two instances of the hotswap service, a flaky primary service (cyclic timeouts), and a stable fallback. Watch the logs for:

Response: Chien          ← primary adapter
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            ← fallback adapter
HEALTHCHECK: Primary adapter responding again
EVENT: Switching adapter from 'fallback' to 'primary'
Response: Chien          ← back to primary

Instance 2 switches without ever experiencing the timeout.


Limitations and Trade‑offs

This is a demo, not a production‑ready framework. Real‑world considerations include:

IssueDescriptionMitigation
Split‑brain riskIf RabbitMQ is partitioned, instances may diverge on which adapter is active.Use a consensus mechanism or accept eventual convergence via the health checker.
Thundering herd on recoveryAll instances hit the primary simultaneously when a “switch back” is broadcast.Add jitter to the health‑check interval, or use a canary approach where only one instance switches back first.
Single point of decisionThe first instance that detects a failure decides for the whole cluster; a false positive causes an unnecessary switch.Require N consecutive failures before broadcasting, or use a quorum‑based decision.
Bus latencyThere is a window between broadcast and reception where other instances may still hit the failing adapter.Accept eventual consistency; RabbitMQ propagation is typically

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

Author: Charles Hornick – Java consultant specializing in smart refactoring and legacy‑application rescue.

[charles-hornick.be](https://charles-hornick.be/)
0 views
Back to Blog

Related posts

Read more »

Cast Your Bread Upon the Waters

!Cover image for Cast Your Bread Upon the Watershttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-t...