This document describes the architecture of the Switcher Client SDK, from the API surface exposed to consumers down to the internal building blocks and design patterns used to implement it. It complements README.md (usage-oriented) with an engineering-oriented view of how the SDK is built.
- 1. Design Goals
- 2. API-First View
- 3. High-Level Architecture
- 4. Package Structure
- 5. Core Domain Model
- 6. Execution Pipeline (Criteria Evaluation)
- 7. Operating Modes
- 8. Configuration Architecture
- 9. Remote Communication Layer
- 10. Local Snapshot Layer
- 11. Strategy Validators
- 12. Concurrency & Background Workers
- 13. Resilience: Silent/Circuit-Breaker Mode
- 14. Testing Support
- 15. Error Handling
- 16. Design Patterns Summary
- 17. Native Image / GraalVM Considerations
The SDK is built around a set of goals that shape most design decisions:
- API-first ergonomics — feature flags are declared as typed constants and consumed through a
fluent, chainable API (
getSwitcher(KEY).checkValue(...).isItOn()). - Location transparency — the same
SwitcherAPI works whether the criteria is resolved remotely (Switcher API), locally (snapshot file/in-memory), or a hybrid of both, without the caller needing to change code. - Resilience by default — network failures must not necessarily break feature evaluation; the SDK offers default results, silent/circuit-breaker mode, and local snapshot fallback.
- Low overhead — thread pools, throttling, and caching prevent the SDK from becoming a bottleneck in hot code paths.
- Testability — first-class support for bypassing/mocking switcher results in unit tests
(
SwitcherBypass,@SwitcherTest) without needing a live API or snapshot. - Zero reflection at runtime where possible — designed to be compatible with GraalVM Native Image.
From a consumer's perspective, the SDK exposes three API surfaces, layered in order of typical usage:
Applications declare feature flags as public static final String constants annotated with
@SwitcherKey inside a class that extends SwitcherContext (properties-based) or
SwitcherContextBase (programmatic, e.g. Spring Boot). This class acts as a static registry/facade
for all switchers in the application.
getSwitcher(FEATURE_PREMIUM_ACCESS)
.checkValue("premium_user")
.checkNetwork("192.168.1.0/24")
.checkDate("2024-01-01")
.throttle(1000)
.isItOn();Every check* call appends a StrategyValidator input (Entry) to the request. Terminal operations
(isItOn(), submit()) trigger evaluation and return either a boolean or a rich SwitcherResult
(reason, metadata, execution history).
Lifecycle and operational concerns are exposed as static methods: initializeClient(),
validateSnapshot(), watchSnapshot(), checkSwitchers(), scheduleSnapshotAutoUpdate(...),
configure(ContextBuilder). These map directly to the Key Features advertised in the README
(real-time snapshot updates, smoke testing, performance tuning).
SwitcherBypass (programmatic mocking) and the JUnit Jupiter extension @SwitcherTest /
@SwitcherTestValue / @SwitcherTestWhen provide a declarative way to control switcher outcomes in
tests, entirely bypassing the executor pipeline.
This layering means the public contract is the Switcher interface and the static context class;
everything below (SwitcherExecutor, ClientRemote, ClientLocal, validators) is an internal
implementation detail that can evolve independently.
┌─────────────────────────────────────────────────────────────────────────┐
│ Application Code │
│ MyAppFeatures extends SwitcherContext / SwitcherContextBase │
│ @SwitcherKey public static final String FEATURE_X = "FEATURE_X"; │
└───────────────────────────────┬───────────────────────────────────────-─┘
│ getSwitcher(KEY)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ SwitcherContext / SwitcherContextBase (SwitcherConfig) │
│ • Static registry of SwitcherRequest instances (cache) │
│ • Lifecycle: initializeClient(), configure(ContextBuilder) │
│ • Scheduling: snapshot auto-update, snapshot watcher, token refresh │
└───────────────────────────────┬─────────────────────────────────────────┘
│ delegates evaluation to
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ SwitcherRequest (Switcher / SwitcherBuilder) │
│ • Fluent builder for Entry (strategy inputs) │
│ • Bypass check (SwitcherBypass) → Async/Throttle (AsyncSwitcher) → │
│ SwitcherExecutor.executeCriteria(this) │
└───────────────────────────────┬─────────────────────────────────────────┘
│
┌──────────────┴─────────────────┐
▼ ▼
┌────────────────────────────┐ ┌────────────────────────────────┐
│ SwitcherRemoteService │ │ SwitcherLocalService │
│ (Remote Mode / Hybrid) │◄───►│ (Local Mode / Hybrid fallback) │
│ implements SwitcherExecutor│ │ implements SwitcherExecutor │
└──────────────┬─────────────┘ └────────────────┬───────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────────┐
│ ClientRemote │ │ ClientLocal │
│ (ClientRemoteService) │ │ (ClientLocalService) │
│ • Auth/token lifecycle │ │ • Criteria evaluation │
│ • Silent mode │ │ against in-memory Domain │
└──────────┬─────────---─┘ │ • SwitcherValidator │
▼ │ (Strategy dispatch) │
┌────────────────────────┐ └──────────────┬─────────────┘
│ ClientWS (HTTP) │ ▼
│ ClientWSImpl (java.net │ ┌───────────────────────────┐
│ .http.HttpClient) │ │ SnapshotLoader / Watcher │
└───────────────────────-┘ │ (criteria/Domain model) │
└───────────────────────────┘
| Package | Responsibility |
|---|---|
com.switcherapi.client |
Public entry points: SwitcherContext, SwitcherContextBase, SwitcherConfig, ContextBuilder, SwitcherExecutor(+Impl), SwitcherProperties(+Impl) |
com.switcherapi.client.model |
Domain/API model: Switcher, SwitcherBuilder, SwitcherRequest, SwitcherResult, Entry, StrategyValidator, AsyncSwitcher, ContextKey |
com.switcherapi.client.model.criteria |
Snapshot data model mirroring the Switcher API domain: Snapshot, Domain, Group, Config, StrategyConfig, Relay, SwitcherElement |
com.switcherapi.client.service |
Service-level contracts: SwitcherFactory (result builder), SwitcherValidator, ValidatorService, WorkerName |
com.switcherapi.client.service.local |
Local execution: ClientLocal(+Service), SwitcherLocalService |
com.switcherapi.client.service.remote |
Remote execution: ClientRemote(+Service), SwitcherRemoteService |
com.switcherapi.client.service.validators |
Strategy pattern implementations: ValueValidator, NetworkValidator, DateValidator, TimeValidator, DateTimeValidator, NumericValidator, RegexValidator, PayloadValidator |
com.switcherapi.client.remote |
HTTP transport: ClientWS(+Impl), ClientWSBuilder, Constants |
com.switcherapi.client.remote.dto |
Wire DTOs: AuthRequest/Response, CriteriaRequest/Response, SnapshotDataResponse, SnapshotVersionResponse, SwitchersCheck |
com.switcherapi.client.utils |
Cross-cutting utilities: SnapshotLoader, SnapshotSerializer, SnapshotWatcher, SnapshotEventHandler, Mapper, SwitcherUtils |
com.switcherapi.client.exception |
Exception hierarchy rooted at SwitcherException |
com.switcherapi.client.test |
Testing utilities: SwitcherBypass, @SwitcherTest, SwitcherTestExtension, SwitcherTestTemplate |
Switcher— the public interface describing the operations a caller can perform on a feature flag (isItOn,submit,prepareEntry,flushExecutions,getLastExecutionResult).SwitcherBuilder(abstract) — implements the fluent, chainable configuration surface (checkValue,checkDate,throttle,remote,bypassMetrics,restrictRelay,defaultResult,keepExecutions). This is the Builder pattern applied to per-call configuration.SwitcherRequest(final) — concreteSwitcher/SwitcherBuilderimplementation and the object returned bygetSwitcher(KEY). It owns: the switcher key, a reference to the resolvedSwitcherExecutor, anexecutionsMap(execution history keyed by input list,ConcurrentHashMapfor thread safety), and lazyAsyncSwitchercreation for throttled calls.Entry— an immutable strategy input pair (StrategyValidatortype + value), the atomic unit of criteria input (e.g.,Entry.of(StrategyValidator.DATE, "2024-01-01")).SwitcherResult— the evaluation outcome:result(boolean),reason,metadata(arbitrary JSON-deserializable payload viagetMetadata(Class)), and switcher key/input echo for debugging.model.criteriapackage — a typed mirror of the Switcher API's snapshot JSON contract (Snapshot→Domain→Group[]→Config[]→StrategyConfig[]), used identically by both the remote criteria cache and the local snapshot evaluator.SwitcherElementis the common base (description,activated) shared byDomain,Group,Config.
Switchers registered via @SwitcherKey are instantiated once and cached in the static
switchers map inside SwitcherContextBase; getSwitcher(key) looks them up rather than
allocating a new object per call, keeping the hot path allocation-light.
Calling switcher.submit() (or isItOn(), which delegates to it) triggers this pipeline, in order:
- Test bypass check —
SwitcherBypass.getBypass()is consulted first; if the key is mocked, the pipeline short-circuits and returns the mockedSwitcherResultimmediately. This guarantees test mocks always take precedence over real evaluation, with no network/IO cost. - Throttle/async check — if
throttle(ms)was configured and prior executions exist,AsyncSwitcherdecides whether enough time has elapsed to trigger a new async evaluation; otherwise it returns the last cached result fromexecutionsMapwithout blocking the caller thread. This follows a Stale-While-Revalidate (SWR) strategy: the caller always receives immediately available data (the last cachedSwitcherResult), while a fresh evaluation — when the throttle window has elapsed — is kicked off asynchronously in the background (AsyncSwitcher#run) and simply replaces the cached entry inexecutionsMapfor the next call to pick up, rather than blocking the current one. - Criteria execution —
SwitcherExecutor.executeCriteria(SwitcherRequest)is invoked. The concrete executor (SwitcherRemoteServiceorSwitcherLocalService) was selected once atinitializeClient()time based onswitcher.localconfiguration. - Execution bookkeeping — if
keepExecutions()was set, the result is stored inexecutionsMapkeyed by the exactList<Entry>used, enablinggetLastExecutionResult().
SwitcherFactory centralizes SwitcherResult construction (buildResultEnabled,
buildResultDisabled, buildFromDefault) so that reason strings and metadata propagation stay
consistent across remote and local paths.
The mode is decided once, at initializeClient(), by SwitcherContextBase#buildInstance():
if (contextBol(ContextKey.LOCAL_MODE)) {
return new SwitcherLocalService(clientRemote, clientLocal, switcherProperties);
}
return new SwitcherRemoteService(clientRemote, new SwitcherLocalService(clientRemote, clientLocal, switcherProperties));- Remote Mode (default) —
SwitcherRemoteServiceis the activeSwitcherExecutor. It always attempts the HTTP call first (ClientRemote.executeCriteria); aSwitcherLocalServiceinstance is still constructed internally to support silent-mode fallback anddefaultResult. - Local Mode —
SwitcherLocalServiceis the active executor. It loads aDomainsnapshot from a file (SnapshotLoader.loadSnapshot) or from the remote API at startup (snapshotAutoLoad), then evaluates entirely in-memory viaClientLocal. - Hybrid Mode — Local mode is active, but per-call
switcher.forceRemote()(SwitcherBuilder#remote(true)) routes that specific evaluation throughClientLocalService#executeCriteria, which detectsswitcherRequest.isRemote()and delegates toClientRemote.executeCriteriainstead of the in-memoryDomain. Snapshot auto-update (snapshotAutoUpdateInterval) keeps the in-memory snapshot fresh in the background regardless.
In both Remote and Local modes, SwitcherExecutor is the common abstraction the rest of the SDK
depends on (Strategy pattern at the executor level) — callers never know which concrete
implementation is active.
Two parallel but convergent configuration entry points exist, unified by SwitcherProperties:
SwitcherContext— properties-file driven (switcherapi.properties), parsed bySwitcherPropertiesImpl, which supports${ENV_VAR:default}substitution.SwitcherContextBase— programmatic, via theContextBuilderfluent builder (singleton-per-JVMcontextreference), or Spring-style@ConfigurationPropertiesbeans whose setters populate protected fields consumed byconfigureClient().
Both paths converge on a single SwitcherProperties instance (backed by a ContextKey → value map),
which is the single source of truth read by executors, validators, and the remote client.
SwitcherContextValidator enforces required properties (url, apikey, domain, component, etc.)
before initializeClient() proceeds, failing fast with SwitcherContextException.
ContextKey is an enum acting as a typed key registry for all configuration parameters,
avoiding stringly-typed property access throughout the codebase.
ClientWS/ClientWSImpl— thin HTTP transport built on Java's built-injava.net.http.HttpClient(no external HTTP dependency). Handles the four Switcher API endpoints: auth, criteria execution, snapshot resolution, and snapshot version check/switchers check.ClientWSBuilderconfigures the client (executor thread pool, timeout, optional custom truststore for TLS).ClientRemote/ClientRemoteService— sits aboveClientWSand owns authentication lifecycle: token acquisition, expiration tracking (AuthResponse.isExpired()), optional auto-refresh (switcher.auth.autorefresh) via a dedicated scheduled executor, and silent-mode token forgery (a fakeAuthResponsewithSILENT_MODEas the token sentinel) used to short-circuit repeated remote calls during an outage window.- Thread pools are partitioned by concern:
SWITCHER_REMOTE_WORKER(HTTP call execution pool, sized viaswitcher.poolsize),SWITCHER_TOKEN_WORKER(auth refresh),SNAPSHOT_UPDATE_WORKER(scheduled snapshot polling),SNAPSHOT_WATCH_WORKER(filesystem watch),SWITCHER_ASYNC_WORKER(per-SwitcherRequestthrottle execution). All are daemon threads named viaWorkerName, so they never block JVM shutdown.
SnapshotLoader— reads/writes the JSON snapshot file ({environment}.json) using Gson, deserializing into thecriteria.Snapshot→Domaingraph.SnapshotSerializerhandles custom (de)serialization concerns for the criteria model.SnapshotWatcher— aRunnableusingjava.nio.file.WatchServiceto detect file system modifications to the snapshot file and triggerSwitcherLocalService.notifyChange(...), which reloads theDomainin place and invokes aSnapshotEventHandler(onSuccess/onErrorcallback interface).ClientLocal/ClientLocalService— the in-memory criteria evaluator. WalksDomain → Group[] → Config[] → StrategyConfig[], short-circuiting on the first disabled level (domain/group/config/relay-restricted), then delegates strategy validation toSwitcherValidator.
Each StrategyValidator enum value (VALUE, NUMERIC, NETWORK, REGEX, TIME, DATE, PAYLOAD,
...) maps to a Validator subclass in service.validators (ValueValidator, NumericValidator,
NetworkValidator, RegexValidator, TimeValidator, DateValidator/DateTimeValidator,
PayloadValidator). This is a textbook Strategy pattern:
Validator(abstract) defines the template:execute(...)logs and delegates to the abstractprocess(StrategyConfig, Entry).ValidatorService(implementsSwitcherValidator) is the dispatcher: given aStrategyConfig.getStrategy()name, it resolves and invokes the matchingValidatorinstance.- Regex evaluation uses a bounded "Timed Match Worker" (
switcher.regextimeout, v1 only) as a ReDoS protection mechanism for user-supplied patterns.
Adding a new strategy type means adding one Validator implementation and registering it — existing
callers (SwitcherBuilder.check*, ClientLocalService.processOperation) require no changes.
The SDK relies on cooperative background workers rather than blocking the caller thread. Summary of executor services and their trigger points:
| Executor Service | Created in | Purpose |
|---|---|---|
scheduledTokenExecutorService |
initTokenExecutorService() |
Auto-refresh remote auth token before expiry |
Remote pool (ClientWS) |
initRemotePoolExecutorService() |
Executes HTTP calls (switcher.poolsize threads) |
scheduledSnapshotExecutorService |
scheduleSnapshotAutoUpdate(...) |
Periodic validateSnapshot() + local snapshot refresh |
watcherExecutorService |
watchSnapshot(...) |
Runs SnapshotWatcher to observe file system changes |
AsyncSwitcher's internal ExecutorService |
Lazily, per SwitcherRequest, on first throttle |
Executes criteria off the caller thread when throttling |
All thread factories create daemon threads with descriptive names (WorkerName enum), ensuring
they don't prevent JVM shutdown and are identifiable in thread dumps.
switcher.silent (ContextKey.SILENT_MODE) configures a duration (e.g. "5m") during which, after a
remote failure, the SDK stops attempting remote calls and instead evaluates against the local snapshot
(SwitcherRemoteService.tryExecuteLocalCriteria). Mechanically:
- A
SwitcherRemoteExceptionfromClientRemotetriggerssetSilentModeExpiration()inClientRemoteService, which installs a syntheticAuthResponsewhose token equals theSILENT_MODEsentinel and whose expiry isnow + silent duration. - Subsequent calls see
isTokenValid() == SILENTand immediately throwSwitcherRemoteExceptionwithout hitting the network, whichSwitcherRemoteServicecatches and routes to the internalSwitcherLocalService(switcherLocal.executeCriteria). - Once the silent window expires, the next call naturally falls back to
INVALIDtoken state and attempts a real re-authentication, "closing" the circuit.
If silent mode is not configured, a defaultResult(boolean) set on the SwitcherBuilder acts as the
last-resort fallback (SwitcherFactory.buildFromDefault); otherwise the original exception propagates.
SwitcherBypass— a static in-memory map (key → SwitcherResult) checked first inSwitcherRequest.submit().assume(key, result[, metadata])/forget(key)let tests force outcomes without touching the executor pipeline at all.@SwitcherTest+SwitcherTestExtension(JUnit JupiterTestTemplateInvocationContextProvider+Before/AfterTestExecutionCallback) — a declarative wrapper aroundSwitcherBypass: installs the mock(s) before the test, removes them after, and (forabTest = true) runs the test twice, inverting the result each time viaSwitcherTestTemplate, so both branches of a feature toggle are exercised in one test method.- Smoke testing (
checkSwitchers()) — delegates toSwitcherExecutor.checkSwitchers(Set<String>), which validates (remotely or against the local snapshot) that every declared@SwitcherKeymaps to a real Switcher API key, throwingSwitchersValidationExceptionlisting anything missing. Can run on demand or automatically at startup (switcher.check=true).
All SDK-specific exceptions extend SwitcherException (unchecked), giving callers a single type to
catch if desired, while specific subclasses allow fine-grained handling:
SwitcherContextException— invalid/missing configuration.SwitcherKeyNotFoundException— key not registered or not found in domain/snapshot.SwitcherRemoteException— HTTP/network failure.SwitcherSnapshotLoadException/SwitcherSnapshotWriteException— local snapshot I/O issues.SwitchersValidationException— one or more keys failedcheckSwitchers().SwitcherInvalid*Exception(DateTimeArgument,NumericFormat,Operation,OperationInput,Strategy,Validator,TimeFormat) — malformed criteria configuration or input during strategy evaluation.
This hierarchy lets the executor layer decide, per exception type, whether to fall back
(defaultResult, silent mode) or propagate to the caller.
| Pattern | Where |
|---|---|
| Facade / Registry | SwitcherContext/SwitcherContextBase static methods hide executor wiring, scheduling, and property loading behind a small static API |
| Builder | ContextBuilder (client configuration), SwitcherBuilder (per-call criteria configuration) |
| Strategy | SwitcherExecutor (Local vs Remote), Validator implementations per StrategyValidator |
| Decorator/Delegation | SwitcherRemoteService wraps a SwitcherLocalService for fallback without inheriting from it |
| Template Method | Validator#execute() delegates to abstract process(...); SwitcherExecutorImpl shares snapshot init/version-check logic used differently by Local/Remote executors |
| Factory | SwitcherFactory builds consistent SwitcherResult instances (buildResultEnabled/Disabled/FromDefault) |
| Observer/Callback | SnapshotCallback (auto-update notifications), SnapshotEventHandler (file watcher events) |
| Singleton (per-context) | ContextBuilder holds one static builder instance per JVM context class |
| Proxy/Bypass | SwitcherBypass intercepts evaluation before it reaches the executor pipeline, used by @SwitcherTest |
The SDK is designed to compile under GraalVM Native Image:
@SwitcherKeyfield discovery uses reflection (Class#getFields()), so native image consumers must either rely on the SDK's reflection configuration or explicitly callregisterSwitcherKeys(...)/overrideconfigureClient()to avoid relying on reflective classpath scanning (see theMyNativeAppFeaturesexample in the README).- HTTP transport uses the JDK's built-in
java.net.http.HttpClientrather than a third-party client, reducing the reflection/proxy surface that would otherwise need native-image configuration. - Gson is used for JSON (de)serialization of DTOs and snapshot models; DTOs are plain POJOs with public getters/setters to keep Gson's reflective (de)serialization native-image-friendly.