@RefreshScope Internals and Live Configuration Updates
@RefreshScope Internals and Live Configuration Updates
The SaaS backend’s FeatureFlagService controls which tenants see the new bulk-order feature. Changing a feature flag currently requires redeploying the service. With 10 instances in production, that is a rolling restart that takes minutes. @RefreshScope lets you update the flag by posting to a single endpoint. No restart. No downtime. But the mechanism has sharp edges.
RefreshScope as a Custom Scope
Spring’s Scope interface defines how beans are created, stored, and destroyed (referenced in CH24). RefreshScope is a custom scope registered under the name "refresh":
public class RefreshScope extends GenericScope
implements ApplicationContextAware, Ordered {
@Override
public String getName() {
return "refresh";
}
}
GenericScope maintains an internal cache of bean instances. When a @RefreshScope bean is first requested, the scope creates it and caches it. On subsequent requests, it returns the cached instance. This is similar to singleton scope, except the cache can be cleared.
When the cache is cleared (via refreshAll() or refresh(beanName)), the next request for that bean triggers re-creation. The new instance is created with the current Environment values, picks up updated @Value injections, and is cached again.
The scope is registered during auto-configuration:
@Bean
@ConditionalOnMissingBean
public static RefreshScope refreshScope() {
return new RefreshScope();
}
The static modifier is critical. It ensures the RefreshScope bean is created early, before other beans that need to be registered in the "refresh" scope. Without static, circular dependency issues can arise.
The /actuator/refresh Endpoint
The refresh endpoint is exposed by RefreshEndpoint:
@Endpoint(id = "refresh")
public class RefreshEndpoint {
private final ContextRefresher contextRefresher;
@WriteOperation
public Collection<String> refresh() {
Set<String> keys = this.contextRefresher.refresh();
return keys;
}
}
POST to /actuator/refresh triggers ContextRefresher.refresh(). The return value is the set of property keys that changed.
ContextRefresher: The Refresh Sequence
ContextRefresher.refresh() executes a precise sequence:
Step 1: Capture current property values.
Map<String, Object> before = extract(
this.context.getEnvironment().getPropertySources());
Step 2: Re-fetch all property sources.
The refresher calls addConfigFilesToEnvironment(), which re-runs the ConfigData loading process. For Config Server, this makes a fresh HTTP request to fetch the latest properties. For local files, it re-reads them.
Step 3: Compare old and new values.
Set<String> keys = changes(before,
extract(this.context.getEnvironment().getPropertySources()))
.keySet();
The changes() method compares every property key. If a value changed, was added, or was removed, its key goes into the result set.
Step 4: Publish EnvironmentChangeEvent.
this.context.publishEvent(new EnvironmentChangeEvent(
this.context, keys));
Any bean that listens for EnvironmentChangeEvent can react to specific property changes. @ConfigurationProperties beans are automatically rebound by ConfigurationPropertiesRebinder, which listens for this event.
Step 5: Destroy RefreshScope beans.
this.scope.refreshAll();
refreshAll() clears the scope’s internal cache and calls destroy() on every cached bean. If the bean implements DisposableBean or has @PreDestroy methods, those run during destruction.
Step 6: Publish RefreshScopeRefreshedEvent.
this.context.publishEvent(
new RefreshScopeRefreshedEvent());
This signals that the refresh is complete. The next access to any @RefreshScope bean will trigger re-creation.
Bean Re-creation in Practice
Consider the SaaS backend’s FeatureFlagService:
@RefreshScope
@Component
public class FeatureFlagService {
private final boolean bulkOrdersEnabled;
private final int maxItemsPerOrder;
private final List<String> betaTenants;
public FeatureFlagService(
@Value("${app.feature.bulk-orders:false}")
boolean bulkOrdersEnabled,
@Value("${app.max-items-per-order:100}")
int maxItemsPerOrder,
@Value("${app.beta-tenants:}")
List<String> betaTenants) {
this.bulkOrdersEnabled = bulkOrdersEnabled;
this.maxItemsPerOrder = maxItemsPerOrder;
this.betaTenants = betaTenants;
}
public boolean isBulkOrdersEnabled(String tenantId) {
return bulkOrdersEnabled && betaTenants.contains(tenantId);
}
}
When you change app.feature.bulk-orders from false to true in the Config Server and POST to /actuator/refresh:
ContextRefresherdetectsapp.feature.bulk-orderschanged.RefreshScope.refreshAll()destroys the cachedFeatureFlagServiceinstance.- The next call to
isBulkOrdersEnabled()triggers re-creation. - The new instance’s constructor reads
@Value("${app.feature.bulk-orders}")from the updatedEnvironment. bulkOrdersEnabledis nowtrue.
The old instance is garbage collected. The new instance is cached in the scope.
Proxied Access
How does the next call to isBulkOrdersEnabled() trigger re-creation? The beans that inject FeatureFlagService hold a reference to it. If the old instance was destroyed, how does the reference point to the new one?
The answer: @RefreshScope beans are proxied. Other beans do not hold a direct reference to the FeatureFlagService instance. They hold a reference to a CGLIB proxy (referencing CH8’s proxy foundation). The proxy delegates to RefreshScope.get() on every method call:
// Simplified proxy behavior
public class FeatureFlagService$$SpringCGLIB extends FeatureFlagService {
@Override
public boolean isBulkOrdersEnabled(String tenantId) {
FeatureFlagService target = (FeatureFlagService)
refreshScope.get("featureFlagService", objectFactory);
return target.isBulkOrdersEnabled(tenantId);
}
}
refreshScope.get() checks the cache. If the bean exists in the cache, it returns it. If not (because refreshAll() cleared it), it creates a new instance using the ObjectFactory, caches it, and returns it.
This means every method call on a @RefreshScope bean goes through the proxy and the scope lookup. This is slightly more expensive than a direct call but negligible for most use cases.
@ConfigurationProperties and Refresh
@ConfigurationProperties beans get special treatment. They do not need @RefreshScope to pick up changes. ConfigurationPropertiesRebinder listens for EnvironmentChangeEvent and rebinds affected @ConfigurationProperties beans:
@ConfigurationProperties(prefix = "app.rate-limit")
public record RateLimitProperties(
int requestsPerSecond,
int burstCapacity
) {}
When app.rate-limit.requests-per-second changes, ConfigurationPropertiesRebinder detects the change, destroys the old RateLimitProperties bean, and creates a new one with the updated values. No @RefreshScope needed.
However, beans that inject RateLimitProperties hold a reference to the old instance. They will not see the new values unless they are also in @RefreshScope or re-created. The safest pattern: use @ConfigurationProperties for the configuration holder and let the consuming beans read from it on every access.
Failure Modes
Failure 1: @RefreshScope on @Scheduled Beans
// BROKEN: @Scheduled bean in @RefreshScope
@RefreshScope
@Component
public class MetricsCollector {
@Value("${app.metrics.interval-seconds:60}")
private int intervalSeconds;
@Scheduled(fixedRateString = "${app.metrics.interval-seconds:60}000")
public void collectMetrics() {
// Collect and publish metrics
}
}
The problem: Spring’s ScheduledAnnotationBeanPostProcessor registers the @Scheduled method during bean post-processing. It stores a reference to the original bean instance. When @RefreshScope destroys and re-creates the bean, the scheduler still holds a reference to the old instance. The new instance is never scheduled. The old instance continues running with the old interval.
// CORRECT: Separate the configuration from the scheduler
@RefreshScope
@Component
public class MetricsConfig {
@Value("${app.metrics.interval-seconds:60}")
private int intervalSeconds;
public int getIntervalSeconds() {
return intervalSeconds;
}
}
@Component
public class MetricsCollector {
private final MetricsConfig config;
public MetricsCollector(MetricsConfig config) {
this.config = config; // Proxy reference
}
@Scheduled(fixedRate = 10_000) // Fixed poll rate
public void collectMetrics() {
// config.getIntervalSeconds() reads through the proxy
// and gets the latest value after refresh
int interval = config.getIntervalSeconds();
// Use interval for actual collection logic
}
}
Failure 2: @RefreshScope on Thread Pool Beans
// BROKEN: Thread pool in @RefreshScope
@RefreshScope
@Bean
public ExecutorService taskExecutor(
@Value("${app.executor.pool-size:10}") int poolSize) {
return Executors.newFixedThreadPool(poolSize);
}
On refresh, the old ExecutorService is destroyed. If it implements DisposableBean or AutoCloseable, shutdown() is called. In-flight tasks are interrupted. Queued tasks are dropped. The new pool starts empty.
// CORRECT: React to changes without destroying the pool
@Bean
public ThreadPoolExecutor taskExecutor() {
return new ThreadPoolExecutor(10, 10,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000));
}
@Component
public class ExecutorConfigUpdater {
private final ThreadPoolExecutor executor;
public ExecutorConfigUpdater(ThreadPoolExecutor executor) {
this.executor = executor;
}
@EventListener
public void onEnvironmentChange(EnvironmentChangeEvent event) {
if (event.getKeys().contains("app.executor.pool-size")) {
int newSize = Integer.parseInt(
environment.getProperty("app.executor.pool-size", "10"));
executor.setCorePoolSize(newSize);
executor.setMaximumPoolSize(newSize);
}
}
}
ThreadPoolExecutor.setCorePoolSize() adjusts the pool without shutting it down. In-flight tasks continue. The pool grows or shrinks as tasks complete.
Failure 3: Dependency on @RefreshScope Bean in Constructor
// BROKEN: Reading @RefreshScope bean value at construction time
@Component
public class OrderValidator {
private final int maxItems;
public OrderValidator(FeatureFlagService flags) {
// flags is a proxy, but this reads the value once at construction
this.maxItems = flags.getMaxItemsPerOrder();
// After refresh, maxItems still holds the old value
}
}
// CORRECT: Read through the proxy on every access
@Component
public class OrderValidator {
private final FeatureFlagService flags;
public OrderValidator(FeatureFlagService flags) {
this.flags = flags; // Store the proxy
}
public void validate(Order order) {
int maxItems = flags.getMaxItemsPerOrder();
// Reads through proxy every time. Gets current value.
if (order.getItems().size() > maxItems) {
throw new OrderValidationException(
"Exceeds max items: " + maxItems);
}
}
}
The principle: never cache a value from a @RefreshScope bean. Always read through the proxy. The proxy is the mechanism that ensures you get the latest value. Short-circuit the proxy, and you short-circuit the refresh.