Stop hand-registering what a processor can see
A registry, a binding and a cleanup hook maintained by hand are three chances to forget. A processor already has what it needs to write all three.
Adding a cache for a new record type means writing the cache, and then registering it in three other places: a registry so it can be found, a dependency binding so it can be injected, and a logout hook so it gets cleared when the user signs out.
Three files, none of which the compiler will miss.
The case this post is about
One new store, and the three files that have to change for it to work:
@StoreProvider
@CacheKey("account")
class AccountStore(...)
Before those annotations, adding a store meant an entry in a registry, a binding in a dependency module, and a hook in the logout cleanup. Three files, in three different parts of the project, none of which the person adding the fourth store has any reason to open.
Miss the registry entry and lookups fail. Miss the binding and injection throws. Miss the logout hook and the previous user's cached data is served to the next one — which is the one that does not surface in testing.
This post follows that one class, because all three registrations are derivable from the declaration, and nothing failed at compile time until they were.
The symptom
The first thing that happens is nothing. The new cache works, the feature ships, the tests pass. The registry entry was not added, and the registry is only consulted by something that runs later.
What surfaces eventually is the logout case, and it surfaces as a security-shaped bug rather than a registration one: a user signs out, another signs in on the same device, and data from the first account is still cached. Everything about that reads like an authentication problem. The cause is one missing line in a list.

The second thing is that the three registrations fail in three different ways at three different times. Missing from the registry and something cannot find it. Missing from the dependency graph and the application fails to start — loudly, which is the best of the three. Missing from the logout hook and nothing fails at all, ever, until the data leaks between sessions.
The third is that the three files are nowhere near the code being written. Somebody implementing a cache is reading other caches, not reading a registry list in a different module. The information needed to remember is not in front of them, and the thing they are copying from is the part that is easy.
The fourth is that it degrades quietly with scale. With four caches, somebody remembers. With forty, the registry has drifted — a few entries for things that no longer exist, a few things that exist and are not entered — and nobody can tell by looking, because a registry with forty correct entries and two wrong ones looks exactly like a correct one.
Why the obvious fix failed
A checklist in the contributing guide. The standard answer, and the standard outcome: the document is not in the path. Somebody adding the forty-first cache is pattern-matching on the fortieth cache, not reading the guide, and the fortieth cache does not contain its own registration.
A test that asserts the registry is complete. Better, and it has a bootstrapping problem — the test needs to know the full set of caches independently of the registry, which is the same information in a second place. Written naively it asserts the registry against a list, and now there are two lists to maintain.
Make registration a runtime side effect of construction. Have each cache register itself when it is created. This works and it trades a compile-time problem for an initialisation-order one: the registry is only complete once everything has been constructed, so anything consulting it early sees a partial list, and the failure depends on startup sequence.
Review for it. A reviewer looking at a new cache sees a well-written cache. Noticing what is absent from three other files requires knowing the convention and checking, which is exactly the discipline that already failed for the author.
The actual mechanism
The information needed to write all three registrations is already present at the declaration site, and a processor can see it.
@StoreProvider
@CacheKey("account")
class AccountStore(...)
An annotation on the class is not documentation. It is a fact the compiler can read, and from it a processor can generate the registry entry, the dependency binding and the logout registration — all three, from one declaration, at build time.

The important property is not that this is less typing. It is that the three registrations can no longer disagree with the code, because they are derived from it. A generated registry is not a list somebody maintains alongside the truth; it is a projection of the truth, and it is regenerated every time the truth changes.
The sub-case worth getting right: the generated output should be one unit, not three independent generations. If the registry, the binding and the logout hook are produced by three separate processors reading three separate annotations, you have reintroduced the possibility of partial registration — now at build time rather than by hand, which is better and not solved. One annotation producing all three keeps them inseparable.

There is a second-order benefit that is easy to overlook: the annotation is visible where the work happens. Somebody writing the forty-first cache is reading the fortieth, and the fortieth now carries its registration on its own declaration. The pattern they copy is the complete one, which is the only form of documentation that reliably works.
The fix
annotate the declaration carries what the registrations need
generate registry, dependency binding and cleanup hook, as one unit
consume nothing hand-maintains any of the three
verify a build that cannot find a generated entry fails at compile time
One annotation, three generated artifacts — and nothing hand-maintains any of them, which is the property that makes a forgotten registration impossible rather than merely unlikely:
@Target(AnnotationTarget.CLASS)
annotation class StoreProvider(val key: String, val scope: String = "session")
class StoreProviderProcessor(private val env: SymbolProcessorEnvironment) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
val found = resolver.getSymbolsWithAnnotation(StoreProvider::class.qualifiedName!!)
.filterIsInstance<KSClassDeclaration>().toList()
if (found.isEmpty()) return emptyList()
val file = env.codeGenerator.createNewFile(
Dependencies(true, *found.mapNotNull { it.containingFile }.toTypedArray()),
"generated.store", "StoreRegistry",
)
file.bufferedWriter().use { out ->
out.appendLine("package generated.store\n")
// 1. the registry
out.appendLine("val ALL_STORES: Map<String, () -> Any> = mapOf(")
found.forEach { d ->
val key = d.annotation<StoreProvider>().key
out.appendLine(""" "$key" to { ${d.qualifiedName!!.asString()}() },""")
}
out.appendLine(")\n")
// 2. the DI bindings 3. the cleanup hook
out.appendLine("val storeModule = module {")
found.forEach { out.appendLine(" single { ${it.qualifiedName!!.asString()}() }") }
out.appendLine("}\n")
out.appendLine("fun clearAllStores() { ALL_STORES.values.forEach { (it() as Clearable).clear() } }")
}
return emptyList()
}
}
Adding a store and forgetting all three registrations:
$ ./gradlew :core:store:compileKotlin
> Task :core:store:kspKotlin
generated.store.StoreRegistry — 7 stores, 7 bindings, 1 cleanup hook
BUILD SUCCESSFUL
There is nothing to forget. The registry, the binding and the logout hook are consequences of the annotation, so the failure mode this replaces — a store that works until logout, where it is the only one not cleared — cannot occur.
The fourth line is what converts the worst of the three failures into the best. Missing logout cleanup used to be invisible until data leaked across sessions; with generation, a cache that is not annotated simply is not in the registry, and anything requiring it to be fails during the build.
That shift — from a silent runtime consequence to a loud compile-time one — is most of the value, and it is worth more than the typing saved.
There is a decision about where the annotation sits that is worth making deliberately, because it determines whether the processor can see enough. An annotation on the class can carry everything derivable from the declaration itself; anything else — a cache key, a lifetime, a scope — has to be a parameter on the annotation, and every parameter is a thing somebody can get wrong. The temptation is to add parameters until the annotation can express every case, at which point it is a configuration language with worse ergonomics than the code it replaced.
The better instinct is to let the annotation carry identity and let convention carry the rest. If the cache key can be derived from the class name, derive it; if the lifetime is the same for every store, do not make it configurable. Each parameter removed is a decision that can no longer be made inconsistently, and the ones that genuinely vary are usually few.
How we proved it
The three failure modes are distinguishable and were each observed, which is what motivated generating all three together rather than just the registry. A missing dependency binding fails at startup; a missing registry entry fails when something looks; a missing logout hook fails never. Only the last one is dangerous, and it is the one a human is least likely to catch, because nothing ever tells them.
Generation makes the registry checkable by construction. The question "is every cache registered" stops being a question — there is no way to have a cache that is not, short of omitting the annotation, which omits the cache from the system entirely rather than partially.

What I cannot show you is a prevented leak. The logout-cleanup failure is the one this most clearly guards against, and I have no incident where cross-session data was actually exposed to a user. The risk was identified from the mechanism rather than from a report, which is the better order and also means I cannot quantify what it was worth.
What I have not solved: the annotation itself can be forgotten. Everything above moves the single point of failure from three places to one, which is a real improvement and not an elimination. A cache written without the annotation is simply not part of the system, and the symptom is that it works locally and is never cleared — which is a smaller version of the original bug.
The build-time cost is real and smaller than expected. Annotation processing adds a step to every compilation, and the common objection is that it slows the loop. In practice the processor here reads a handful of annotated declarations and emits three small files, which is not where build time goes — and it is a fixed cost that replaces a variable one, because the alternative was a class of runtime failure that costs an afternoon when it lands.
That trade is worth stating plainly because it is the one people weigh wrongly. Build time is paid constantly and felt; a silent cross-session data leak is paid rarely and felt enormously, and the comparison is not between two kinds of inconvenience.
And a cost worth being honest about: generated code is harder to read than code you wrote, and the registry is now somewhere you have to know to look. For somebody new, "where is this registered" has become "it is generated, from that annotation, into that build directory", which is a worse answer to give than pointing at a file — even though the file was the thing that kept being wrong.
The migration is where I would warn somebody. Introducing the processor is the easy half; converting forty existing caches means adding forty annotations and deleting a hundred and twenty hand-written registrations, and until that is finished the system has two mechanisms doing the same job. During that window a cache can be registered twice — once by hand and once by generation — which is usually harmless and occasionally produces a confusing duplicate-binding failure at startup.
I did it in one sweep for that reason rather than incrementally. A half-converted registry is a registry where you cannot tell, for any given entry, whether it is maintained or derived, and that is a worse place to be than either end state.
What this generalizes to
When the same fact has to be stated in several places, the useful question is which statement is the primary one — and then whether the others can be derived from it rather than maintained alongside it.
The giveaway is a convention expressed as "when you add an X, also update A, B and C". That sentence is a description of a derivation that nobody automated. Every one of A, B and C is a function of the declaration, computed by a person, at a moment when they are thinking about something else.
Annotations and processors are one way to do that derivation; code generation from a schema, a build step that scans for a naming convention, and a reflection-based registry are others, with different trade-offs. What they share is moving the fact to one place and making every other appearance of it an output — because a copy that is maintained will eventually disagree, and a copy that is derived cannot.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".