ConfigSource, ConfigClient, and ConfigChangeListener using the Observer pattern so components react to config changes without polling, plus fallback-to-default behavior when the config source is unreachable.
Published September 23, 2026
interface ConfigSource { Map<String, String> fetchAll(); }
interface ConfigChangeListener { void onChange(String key, String oldValue, String newValue); }
class ConfigClient {
ConfigSource source;
Map<String, String> cache = new ConcurrentHashMap<>();
List<ConfigChangeListener> listeners = new CopyOnWriteArrayList<>();
}
ConfigSource as an interface (rather than a hardcoded connection to one specific backend) is what lets the SAME ConfigClient work against different backing stores — a local file for tests, a remote config service in production — without any calling code changing, a direct application of Dependency Inversion.
class ConfigClient {
void registerListener(ConfigChangeListener listener) { listeners.add(listener); }
void refresh() {
Map<String, String> latest = source.fetchAll();
for (var entry : latest.entrySet()) {
String oldValue = cache.get(entry.getKey());
if (!Objects.equals(oldValue, entry.getValue())) {
cache.put(entry.getKey(), entry.getValue());
listeners.forEach(l -> l.onChange(entry.getKey(), oldValue, entry.getValue())); // notify subscribers
}
}
}
}
Components that care about a specific config value REGISTER as listeners once, then get notified automatically whenever that value actually changes — rather than each component independently polling ConfigClient.get("someKey") on every operation to check for a change. This is the same Observer-pattern benefit as Design a Feature Flag System's push-based invalidation: components stay decoupled from HOW/WHEN refresh happens, they just react when notified.
String get(String key, String defaultValue) {
return cache.getOrDefault(key, defaultValue); // last-known-good value, or an explicit default
}
If refresh() fails (the remote config source is temporarily unreachable), the client should keep serving the LAST SUCCESSFULLY FETCHED values from its cache rather than failing every config lookup — this is a direct application of Health Checks' essential-vs-non-essential reasoning: config staleness (serving slightly outdated values during an outage) is almost always preferable to config UNAVAILABILITY (every part of the application that reads config suddenly breaking). A genuinely never-yet-successfully-fetched key falls back to an explicit, caller-provided default rather than throwing.
A Map<String, String> is where configuration bugs start: typos in keys, parsing scattered across the code, and no way to tell a missing value from an intentionally empty one. A better client offers typed accessors and layered sources, with a clear precedence order:
interface ConfigSource {
String name();
Map<String, String> fetchAll() throws Exception;
}
final class ConfigClient {
private final List<ConfigSource> sources; // highest precedence FIRST: env vars, remote service, file, defaults
private volatile Map<String, String> snapshot = Map.of();
ConfigClient(List<ConfigSource> sources) { this.sources = sources; }
String getString(String key, String defaultValue) {
return snapshot.getOrDefault(key, defaultValue);
}
int getInt(String key, int defaultValue) {
String raw = snapshot.get(key);
if (raw == null) return defaultValue;
try { return Integer.parseInt(raw.trim()); }
catch (NumberFormatException e) {
log.warn("Config {}='{}' is not an int; using default {}", key, raw, defaultValue);
return defaultValue;
}
}
Duration getDuration(String key, Duration defaultValue) { /* parse "30s", "5m", "PT1H" … */ return defaultValue; }
boolean getBoolean(String key, boolean defaultValue) { /* accept true/false only; warn otherwise */ return defaultValue; }
}
Merging layers. Read the sources from lowest to highest precedence, so that higher layers override lower ones key by key:
private Map<String, String> merge(List<Map<String, String>> fromLowestToHighest) {
Map<String, String> merged = new HashMap<>();
fromLowestToHighest.forEach(merged::putAll);
return Map.copyOf(merged);
}
This is the same precedence idea Spring Boot applies to its property sources (command-line arguments over environment variables over application.yml). A typical layering: built-in defaults → a file shipped with the app → the remote config service → environment variables for per-deployment overrides.
Configuration values often only make sense together: a new database host with a new port, or a feature flag with its rollout percentage. If a refresh updated keys one at a time, a reader could see the new host with the old port. The fix is to build a completely new immutable map, and swap it in with a single volatile write:
synchronized void refresh() {
List<Map<String, String>> layers = new ArrayList<>();
for (int i = sources.size() - 1; i >= 0; i--) { // lowest precedence first
ConfigSource s = sources.get(i);
try { layers.add(s.fetchAll()); }
catch (Exception e) {
log.warn("Config source {} unavailable, keeping last known values", s.name(), e);
layers.add(lastKnown.getOrDefault(s.name(), Map.of())); // last-known-good per source
}
}
Map<String, String> next = merge(layers);
Map<String, String> previous = snapshot;
snapshot = next; // one atomic publication
notifyChanges(previous, next);
}
Readers never lock. They read whichever complete snapshot is current. Listeners receive only the keys whose values changed, computed by comparing the two maps.
A bad value pushed to the config service (maxConnections=-5, or a malformed URL) shouldn't take down every instance. Register validators per key, and reject a whole refresh if any fails, keeping the previous snapshot:
private final Map<String, Predicate<String>> validators = Map.of(
"db.pool.max", v -> v.matches("\\d+") && Integer.parseInt(v) > 0,
"payments.url", v -> v.startsWith("https://"));
Log and alert on the rejection. It's a signal that a bad change was made centrally, and the fleet is protected by staying on the last good configuration.
Configuration and secrets often travel together, but secrets (passwords, API keys) should come from a secret manager, be excluded from change-listener logs and debug endpoints, and ideally be referenced by name rather than stored as plain values in the config service. Mask any key that matches *password*, *secret* or *token* whenever values are printed.
Q: How does the client know WHEN to call refresh() — polling, or something else? A: Either a periodic poll (simplest, same freshness/latency trade-off as Design a Feature Flag System) or a push-based mechanism (the config source notifying the client of a change via a webhook or a long-lived connection) — the Observer pattern here is about how CHANGES PROPAGATE TO INTERNAL LISTENERS once detected, independent of whether detection itself is poll-based or push-based.
Q: Is there a risk in notifying listeners synchronously, inline within refresh()? A: Yes — a slow or misbehaving listener could block the entire refresh cycle (and delay other listeners from being notified); a more robust implementation dispatches notifications asynchronously (each listener notified on its own thread/task) so one slow listener doesn't degrade the whole notification pipeline.
Q: How would you avoid every application instance hammering the config source with its own independent polling? A: At real scale, a common pattern layers a caching/pub-sub tier BETWEEN the config source and many client instances — instances subscribe to that intermediate tier rather than each polling the ultimate source directly, similar in spirit to how a CDN sits between many viewers and one origin server.
Q: What's a concrete failure mode if listeners aren't properly unregistered when a component shuts down? A: A classic memory leak — the listener list holds a reference to the component indefinitely, preventing garbage collection even after the component is logically done, the same lifecycle-management discipline as MDC/ThreadLocal cleanup covered in Centralized Logging, applied to a different kind of resource.
Q: Why swap an immutable snapshot rather than updating a ConcurrentHashMap key by key? A: Related keys must change together. Key-by-key updates let readers observe a mix of old and new values. Publishing a new immutable map through a volatile reference gives every reader a consistent view, with no locking.
Q: What should the client do if the config service is down at application startup? A: Fall back to a locally cached copy of the last successful fetch (written to disk), then to shipped defaults. Decide explicitly whether the app may start in that state. Critical settings with no safe default should make startup fail loudly rather than run misconfigured.
Q: How do you roll out a risky config change safely? A: Validate it before applying, stage it (to a canary group of instances or a percentage of users via flags), watch metrics, and keep the previous version so you can roll back quickly. The config service should version every change, with who changed it and why.