Firebase Remote Config is simple to use until your app gets big. Broken JSON parsing, having to watch quotas and QA having to manually toggle switches add a lot of friction to the development process. We rebuilt our Remote Config as a code-generated, testable and safe layer that catches mistakes at build time and gives developers and QA a much safer way to work with configuration.
We noticed that our Android app wasn’t using Firebase Remote Config’s real-time updates. Instead, it kept fetching config values, slowly creeping toward Firebase’s rate limits. Fixing that led us deeper into the architecture – and to a much bigger rewrite than we expected.
This is the story of what I built and why: a code generator that turns a plain Kotlin interface into a validated, reactive, QA-friendly Remote Config layer.
What was actually wrong
Firebase Remote Config’s raw Android API looks innocent enough:
val enabled = remoteConfig.getBoolean("cool_new_feature")
In a large multi-module app, even Remote Config stops being simple:
-
Keys are magic strings. A typo compiles fine and silently returns the default. Nobody would notice until the feature mysteriously never turns on.
-
There is no single source of truth. Keys live in the Firebase console, in an XML defaults file, and across call sites. Over time, these three drift apart. Keys get removed from the console but stay in the code. Others get added to the code but never get a default. Nothing warns you.
-
It is imperative, not reactive. Call sites poll
getBoolean()whenever they feel like it. There’s no first-class stream that re-emits when Firebase pushes an update, which is exactly how we ended up polling in the first place. -
Complex types are hand-rolled. JSON-encoded config values get deserialized ad hoc, with each call site handling errors differently.
The JSON parsing problems
Remote Config only knows strings. A JSON value in the Firebase console is just a string to Firebase, which means a broken JSON value is perfectly valid as far as Firebase is concerned. Someone edits a JSON config in the console, misses a bracket, creates malformed JSON, hits publish, and Firebase serves it to every device.
You might think: “Fine, parsing fails, so we fall back to the bundled default.” Except you can’t. The API only gives you the last fetched value. Once a fetched value exists, the in-app default is unreachable. There is no “give me the default instead” option. Your options are a crash, an ad hoc null, or whatever each call site improvised.
So any serious wrapper has to solve both problems: at runtime fall back to the default when parsing fails and at build time guarantee that the default itself is parseable so the fallback always works.
Why not just use an existing library?
Third-party libraries each covered part of what we wanted, but none covered everything, and none solved the JSON parsing problem. KFirebaseRemoteConfig, for example, is a great library, but its API still exposes getString(key) calls: weakly typed, non-reactive, and without compile-time validation.
We also considered a lower-tech route: extension functions, conventions, maybe some well-written instructions for AI-assisted development. But conventions don’t enforce anything. They move the burden onto code review and discipline, which is exactly where the original problems came from.
They also can’t give you what became one of the most useful additions in practice: a generated debug screen where developers and QA can see every config key the app actually uses, inspect raw remote values, and override any of them locally.
Previously, testing config-dependent scenarios meant changing values in the shared Firebase console. Testers were constantly stepping on each other: one person flips a flag for scenario A and someone else’s scenario B breaks mid-test. With local overrides, every tester can run their own scenarios in parallel on their own device while the console stays untouched.
That’s not something a convention gives you. So code gen it was.
The solution: declare an interface, generate the rest
The result is a small piece of infrastructure built on two tools: KSP (Kotlin Symbol Processing), which analyzes annotated code at compile time, and KotlinPoet, Square’s library for generating Kotlin source files. Instead of piecing generated code together from strings, KotlinPoet builds a proper model of Kotlin types, functions, and properties, keeping the generator readable and maintainable.
As a developer, this is all you write:
@RemoteConfigSource
internal interface FeatureRemoteConfig {
@RemoteConfigProperty("cool_new_feature")
val coolNewFeatureEnabled: StateFlow<Boolean>
@RemoteConfigProperty("feature_rules")
val featureRules: StateFlow<FeatureRules?>
}
Plus one entry per key in the standard Firebase XML defaults file, the same file Firebase loads at runtime anyway:
<entry><key>cool_new_feature</key><value>false</value></entry>
<entry><key>feature_rules</key><value>{"rules":[]}</value></entry>
Everything else is generated at compile time: the implementation class, metadata for the debug panel, and validation glue. Adding a new config value takes one interface property and one XML entry. Quick and safe.
The build fails before you can ship a broken config
The XML defaults file plays a double role. Firebase loads it at runtime as the defaults, and the KSP processor reads it at compile time as the ground truth for validation. Every declared key must exist in the XML, and every default value must be parseable to the declared type.
Typo in a key? Build fails. A Boolean default that isn’t exactly true or false? Build fails. Invalid default JSON? Build fails. Duplicate keys in the XML? Build fails. A whole class of runtime issues becomes compile errors, with messages that tell you exactly which key is wrong and why.
This also fixes the JSON parsing problem. Because the default is proven parseable at build time, runtime can always fall back to it. When Firebase serves broken JSON, the generated code logs the failure and the flow keeps emitting a value the app can handle — without crashes or improvised null handling at call sites.
For custom @Serializable types, one rule is deliberate: they must be nullable. And it isn’t a convention you have to remember. The processor enforces it. Declare StateFlow<FeatureRules> instead of StateFlow<FeatureRules?> and the build fails, explaining that the type must be nullable because deserialization can fail at runtime.
The default is validated at build time, but a remote value can still drift from the expected schema. If that happens, the property resolves to null instead of crashing. Every consumer has to handle null, but that’s a failure mode the codebase already knows how to deal with.
Reactive by construction – and goodbye, rate limits
Every generated property is a StateFlow. It emits the current value immediately and re-emits when Firebase pushes an update or when a local override changes. Consumers simply collect the flow. No polling anywhere.
Under the hood, there’s exactly one Firebase real-time update listener for the entire app. It feeds a shared invalidation bus used by all derived flows, so a single pushed update re-resolves every affected value, everywhere, in sync.
That also fixes the original problem. The repeated fetching disappeared, along with the rate-limit risk. One live connection, many flows, no polling.
Local overrides without production side effects
The debug override panel is generated from the same metadata as the rest of the layer. It lists every source and key, along with its type, default, and current remote value. You can set a local override using the same parsing rules as the real config, so invalid overrides fall back gracefully instead of breaking the app.
The panel exists only in debug builds. The build type selects between two override-store implementations: a DataStore-backed one for debug and a no-op for release. The resolution engine and generated code are otherwise identical across build types; in release, overrides simply never exist. No debug behavior leaks into production.
The hard part: testing generated code
Building the generator itself was the easy part. Testing it properly was hard.
The feature doesn’t live in one place. It spans several stages of the build and test pipeline. The KSP processor runs at compile time on the JVM, before the app exists. The generated code runs inside the app. Default validation runs in unit tests.
And each stage only has access to part of the system: the processor can’t see the running app, unit tests can’t re-run the processor, and the runtime engine knows nothing about the XML file.
Each stage therefore needed its own testing strategy: unit tests for processor parsing, type resolution, and code generation; tests for runtime resolution and listener behavior, including an assertion that there is only one Firebase listener no matter how many flows exist; and a default-validation test that runs against the actual generated output.
Finally, it’s self-maintaining. The processor emits a ServiceLoader manifest alongside the generated code, and a single test uses it to discover every config source and validate all defaults. Adding a new source anywhere in the app requires zero test edits: it is discovered and validated automatically. Tests that maintain themselves are much better than tests that need to be maintained.
What actually changed
The measurable part is simple: adding a config value used to mean touching scattered call sites, hand-rolled parsing, and hoping. Now it means one interface property and one XML entry, with the compiler checking your work. Polling and its rate-limit risk are gone, replaced by a single real-time listener.
The less measurable part turned out to matter even more. QA now has visibility: the override panel shows exactly which keys the app actually uses, live. If a flag isn’t implemented yet or has been removed, testers see it without asking a developer. And because overrides are local, multiple testers can run different config scenarios in parallel without fighting over the shared Firebase console.
What we appreciate most, months later, is that the safety isn’t something we have to remember. It’s built in. Typos in remote config don’t result in features breaking, QA can test independently without slowing down, and the app no longer polls for updates. That’s the real win: fewer rules for people to remember, more guarantees enforced by the system.
If you’re maintaining a large Remote Config setup with the raw Firebase API, especially if JSON or other data structures are involved, that’s the direction we’d recommend: move correctness out of conventions and into the build and tests.