Skip to main content
spring internals

ReactorLoadBalancer and the @LoadBalanced Qualifier Mechanism

7 min read Chapter 60 of 78

ReactorLoadBalancer and the @LoadBalanced Qualifier Mechanism

The question is simple: how does putting @LoadBalanced on a RestTemplate bean cause http://order-service/api/orders to resolve to http://192.168.1.10:8080/api/orders? The answer involves a qualifier, an auto-configuration class, an interceptor, and a load balancer. Each has a specific role.

@LoadBalanced Is a @Qualifier

Open the source of @LoadBalanced:

@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Qualifier
public @interface LoadBalanced {
}

It is a meta-annotation on @Qualifier. When you annotate a @Bean method with @LoadBalanced, you are creating a qualified bean. When you inject a parameter annotated with @LoadBalanced, Spring only injects beans that carry that same qualifier.

This is the same mechanism you use with custom qualifiers in plain Spring (CH8’s proxy discussion). There is nothing load-balancer-specific about the injection. The qualifier just separates “load-balanced RestTemplate” beans from “regular RestTemplate” beans.

LoadBalancerAutoConfiguration: The Wiring

LoadBalancerAutoConfiguration is the auto-configuration class that ties everything together. It is activated when RestTemplate is on the classpath and spring.cloud.loadbalancer.enabled is not false.

The critical field:

@LoadBalanced
@Autowired(required = false)
private List<RestTemplate> restTemplates = Collections.emptyList();

This field uses @LoadBalanced as a qualifier on the injection point. Spring collects all RestTemplate beans in the context that are also qualified with @LoadBalanced and injects them here. Unqualified RestTemplate beans are ignored.

Then the auto-configuration creates a SmartInitializingSingleton that adds LoadBalancerInterceptor to every collected RestTemplate:

@Bean
public SmartInitializingSingleton loadBalancedRestTemplateInitializerDeprecated(
        final ObjectProvider<List<RestTemplateCustomizer>> restTemplateCustomizers) {
    return () -> restTemplateCustomizers.ifAvailable(customizers -> {
        for (RestTemplate restTemplate : LoadBalancerAutoConfiguration.this.restTemplates) {
            for (RestTemplateCustomizer customizer : customizers) {
                customizer.customize(restTemplate);
            }
        }
    });
}

RestTemplateCustomizer adds the LoadBalancerInterceptor to the RestTemplate’s interceptor list. After this runs, every @LoadBalanced RestTemplate has the interceptor installed.

SmartInitializingSingleton.afterSingletonsInstantiated() runs after all singleton beans are created. This ensures all RestTemplate beans exist before the customizer runs. If you create a RestTemplate lazily (via ObjectProvider or @Lazy), it might miss the customization window.

LoadBalancerInterceptor: The Interception

LoadBalancerInterceptor implements ClientHttpRequestInterceptor. Every request made through the RestTemplate passes through it:

public class LoadBalancerInterceptor implements ClientHttpRequestInterceptor {

    private final LoadBalancerClient loadBalancer;
    private final LoadBalancerRequestFactory requestFactory;

    @Override
    public ClientHttpResponse intercept(
            HttpRequest request,
            byte[] body,
            ClientHttpRequestExecution execution) throws IOException {

        URI originalUri = request.getURI();
        String serviceName = originalUri.getHost();

        return this.loadBalancer.execute(serviceName,
            this.requestFactory.createRequest(request, body, execution));
    }
}

The interceptor extracts the hostname from the URL. When you call http://order-service/api/orders, the hostname is order-service. The interceptor passes this to LoadBalancerClient.execute(), which:

  1. Calls ReactorLoadBalancer.choose("order-service") to select an instance.
  2. Gets a ServiceInstance (e.g., host=192.168.1.10, port=8080).
  3. Reconstructs the URI: http://192.168.1.10:8080/api/orders.
  4. Executes the actual HTTP request with the resolved URL.

If choose() returns no instance, LoadBalancerClient throws an IllegalStateException with “No instances available for order-service.” If you see this in your logs, either the service is not registered, or the instance list supplier returned an empty list (possibly filtered out by health checks).

ReactorLoadBalancer.choose()

ReactorServiceInstanceLoadBalancer is the interface:

public interface ReactorServiceInstanceLoadBalancer {
    Mono<Response<ServiceInstance>> choose(Request request);
}

The default implementation is RoundRobinLoadBalancer:

public class RoundRobinLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    private final AtomicInteger position;
    private final String serviceId;
    private ObjectProvider<ServiceInstanceListSupplier> supplierProvider;

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        ServiceInstanceListSupplier supplier = supplierProvider.getIfAvailable();
        return supplier.get(request)
            .next()
            .map(serviceInstances -> processInstanceResponse(
                supplier, serviceInstances));
    }

    private Response<ServiceInstance> processInstanceResponse(
            ServiceInstanceListSupplier supplier,
            List<ServiceInstance> serviceInstances) {
        if (serviceInstances.isEmpty()) {
            return new EmptyResponse();
        }
        int pos = this.position.incrementAndGet() & Integer.MAX_VALUE;
        ServiceInstance instance = serviceInstances.get(
            pos % serviceInstances.size());
        return new DefaultResponse(instance);
    }
}

The position counter is an AtomicInteger shared across all threads. Each call increments it and picks the instance at position % size. The & Integer.MAX_VALUE ensures the value stays positive after overflow.

This is stateless round-robin. It does not track which instances are slow. It does not account for in-flight requests. It distributes calls evenly across the instance list. For most services, this is sufficient.

Custom Load Balancer

For the SaaS backend, the order-service needs tenant-aware routing. Premium tenants should prefer dedicated instances. Implement a custom load balancer:

public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
    private final String serviceId;
    private final AtomicInteger position = new AtomicInteger();

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        ServiceInstanceListSupplier supplier = supplierProvider.getIfAvailable();
        return supplier.get(request)
            .next()
            .map(instances -> selectInstance(instances, request));
    }

    private Response<ServiceInstance> selectInstance(
            List<ServiceInstance> instances, Request request) {
        if (instances.isEmpty()) {
            return new EmptyResponse();
        }

        String tenantId = extractTenantId(request);

        // Prefer instances with matching tenant affinity
        List<ServiceInstance> preferred = instances.stream()
            .filter(i -> tenantId.equals(
                i.getMetadata().get("tenant-affinity")))
            .toList();

        List<ServiceInstance> target = preferred.isEmpty()
            ? instances : preferred;

        int pos = position.incrementAndGet() & Integer.MAX_VALUE;
        return new DefaultResponse(
            target.get(pos % target.size()));
    }
}

Register it per service:

@LoadBalancerClient(
    name = "order-service",
    configuration = OrderServiceLBConfig.class
)
public class LoadBalancerConfiguration {}

// NOT @Configuration - must not be in component scan
public class OrderServiceLBConfig {
    @Bean
    public ReactorLoadBalancer<ServiceInstance> tenantAwareLoadBalancer(
            Environment environment,
            LoadBalancerClientFactory clientFactory) {
        String name = environment.getProperty(
            LoadBalancerClientFactory.PROPERTY_NAME);
        return new TenantAwareLoadBalancer(
            clientFactory.getLazyProvider(name,
                ServiceInstanceListSupplier.class),
            name);
    }
}

The Common Mistakes

Mistake 1: Inline RestTemplate Construction

// BROKEN: Creating RestTemplate inline bypasses load balancing entirely
@Service
public class OrderService {

    public OrderDto getOrder(String orderId) {
        RestTemplate rt = new RestTemplate();  // No interceptor
        return rt.getForObject(
            "http://order-service/api/orders/" + orderId,
            OrderDto.class);
        // Throws UnknownHostException: "order-service" is not a DNS name
    }
}
// CORRECT: Inject the @LoadBalanced-qualified bean
@Service
public class OrderService {

    private final RestTemplate restTemplate;

    public OrderService(@LoadBalanced RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public OrderDto getOrder(String orderId) {
        return restTemplate.getForObject(
            "http://order-service/api/orders/" + orderId,
            OrderDto.class);
        // Interceptor resolves "order-service" to actual host:port
    }
}

Mistake 2: One RestTemplate for Everything

// BROKEN: @LoadBalanced RestTemplate used for external API calls
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
    return new RestTemplate();
}

@Service
public class PaymentGateway {
    @Autowired
    private RestTemplate restTemplate;

    public PaymentResult charge(PaymentRequest req) {
        // The interceptor extracts "api.stripe.com" as a service name
        // and looks it up in the service registry. Not found.
        // Throws: "No instances available for api.stripe.com"
        return restTemplate.postForObject(
            "https://api.stripe.com/v1/charges",
            req, PaymentResult.class);
    }
}
// CORRECT: Separate beans for internal and external HTTP clients
@Configuration
public class HttpClientConfig {

    @Bean
    @LoadBalanced
    public RestTemplate serviceRestTemplate() {
        return new RestTemplate();
    }

    @Bean
    public RestTemplate externalRestTemplate(RestTemplateBuilder builder) {
        return builder
            .connectTimeout(Duration.ofSeconds(5))
            .readTimeout(Duration.ofSeconds(10))
            .build();
    }
}

@Service
public class PaymentGateway {
    private final RestTemplate externalClient;

    public PaymentGateway(
            @Qualifier("externalRestTemplate") RestTemplate externalClient) {
        this.externalClient = externalClient;
    }

    public PaymentResult charge(PaymentRequest req) {
        return externalClient.postForObject(
            "https://api.stripe.com/v1/charges",
            req, PaymentResult.class);
    }
}

Mistake 3: @LoadBalanced on @Configuration Class Scan

// BROKEN: LB config class in component scan applies to ALL services
@Configuration  // <-- This makes it a global configuration
public class OrderServiceLBConfig {
    @Bean
    public ReactorLoadBalancer<ServiceInstance> customLB(...) {
        // Applied to every service, not just order-service
    }
}
// CORRECT: No @Configuration, referenced only via @LoadBalancerClient
public class OrderServiceLBConfig {
    @Bean
    public ReactorLoadBalancer<ServiceInstance> customLB(...) {
        // Applied only to order-service
    }
}

The @LoadBalancerClient(configuration = ...) attribute creates a child context for that specific service. The configuration class is instantiated in that child context, not in the main application context. If you put @Configuration on it and it is in the component scan, Spring creates it in the main context too, where it overrides the default load balancer for all services.

This is the same pattern as Feign’s @FeignClient(configuration = ...). The configuration class is not a configuration class in the traditional sense. It is a recipe that gets instantiated in an isolated context. Keep it out of the component scan.