A team's @ConfigurationProperties keys aren't showing up in IDE auto-completion. Walk through the likely causes, including Kotlin and incremental-build pitfalls.
answer
- Kotlin needs kapt, not annotationProcessor
- Needs getter/setter or constructor binding
- @NestedConfigurationProperty for non-inner nested types
- Stale JSON -> clean rebuild + refresh IDE
- Per-module artifact; KDoc not extracted
basics
~10 sCheck that the configuration-processor is on the annotation-processor path (kapt for Kotlin), that properties have getters/setters or constructor binding, that nested non-inner types use @NestedConfigurationProperty, and that the project was rebuilt so spring-configuration-metadata.json regenerated.
solid answer
~50 sI'd triage in order. First, is the processor even wired? On Java it must be on the annotationProcessor path; on Kotlin it must run through kapt, not annotationProcessor, or nothing is generated. Second, is the property bindable in a way the processor sees — a getter/setter, a record component, or a @ConstructorBinding parameter? Bare fields with no accessor are invisible. Third, nested config objects that are not inner classes need @NestedConfigurationProperty to be walked. Fourth, is the metadata stale — the JSON only regenerates when the annotated sources recompile, so incremental builds or an unopened Gradle refresh can leave the IDE reading old metadata; a clean rebuild fixes it. Fifth, module boundaries: metadata is generated per module, so a properties class in one jar needs its own processor run. Finally, Kotlin KDoc isn't extracted as descriptions the way Javadoc is, so descriptions may be blank even when keys appear.
code
kotlin · 20 lines// build.gradle.kts
plugins {
kotlin("jvm")
kotlin("kapt") // REQUIRED for the processor to run on Kotlin
}
dependencies {
// Must be kapt, NOT annotationProcessor, or no metadata is generated
kapt("org.springframework.boot:spring-boot-configuration-processor")
}
// Properties class: constructor binding makes params discoverable
@ConfigurationProperties(prefix = "app.mail")
data class MailProperties(
val host: String = "localhost",
val port: Int = 25,
// non-inner nested type must be flagged
@param:NestedConfigurationProperty val pool: PoolProperties = PoolProperties()
)
data class PoolProperties(val maxSize: Int = 10)go deeper
Can name one or two causes (rebuild, missing getters).
Covers accessor/constructor binding and rebuild staleness.
Adds kapt for Kotlin, @NestedConfigurationProperty, and per-module generation.
Reasons from the model (static, per-module, compile-time), separates tooling from runtime binding, and covers Lombok ordering, module boundaries, and KDoc extraction limits systematically.
## A principal-level triage checklist When `@ConfigurationProperties` keys don't auto-complete, work through the layers from wiring outward. ### 1. Is the processor actually running? - **Java**: it must be on the `annotationProcessor` configuration (Gradle) or as an `optional` dependency with annotation processing enabled (Maven). If it is only a normal `implementation`/`compile` dependency, `javac` won't invoke it and **no JSON is generated**. - **Kotlin**: this is the classic trap. Kotlin classes are compiled by the Kotlin compiler, **not `javac`**, so the Java annotation processor must run through **kapt**: ```kotlin plugins { kotlin("kapt") } dependencies { kapt("org.springframework.boot:spring-boot-configuration-processor") } ``` Putting it on `annotationProcessor` for a Kotlin class silently produces nothing. ### 2. Is the property in a shape the processor can see? The processor discovers properties through the **bindable surface**: - JavaBean binding — a **getter** (and setter for mutability). - **Constructor binding** — `@ConstructorBinding` / a records' components / a single non-default constructor. A `private` field with **no getter and no constructor parameter** is invisible. In Kotlin, `val`/`var` in the primary constructor (constructor binding) or properties (which generate getters) are fine; a private field trick is not. ### 3. Nested configuration objects If a property is a **nested type that is not an inner class** of the `@ConfigurationProperties` class, the processor won't automatically recurse into it. Mark the field with **`@NestedConfigurationProperty`** so its inner keys are emitted. Inner classes and collections of known types are handled automatically. ### 4. Stale / incremental-build metadata The JSON is only (re)written when the **annotated sources recompile**. Symptoms of staleness: - You added a property but the IDE still doesn't suggest it → the module wasn't recompiled. - Incremental compilation skipped the properties class. **Fix**: a clean rebuild (`./gradlew clean compileJava` / `clean build`, or an IDE "Rebuild project") forces regeneration. Also ensure the IDE re-imports/refreshes the Gradle/Maven model so it knows metadata exists. ### 5. Module / packaging boundaries Metadata is generated **per compilation unit (module/jar)**. If `MailProperties` lives in library module A and you edit config in application module B, module A must have been built **with the processor** for its `spring-configuration-metadata.json` to exist on B's classpath. A shared library that omits the processor gives no completion downstream. This is the common cause in multi-module / Spring Modulith codebases. ### 6. Kotlin description extraction Even when keys appear, **KDoc is not extracted into `description`** the way Javadoc is for Java properties. So Kotlin config classes often show keys with no docs. If rich descriptions matter, supply them via `additional-spring-configuration-metadata.json`. ### 7. Other checks - **Typos / wrong file**: manual `additional-spring-configuration-metadata.json` in the wrong path or with invalid JSON is silently ignored. - **`@EnableConfigurationProperties` vs `@ConfigurationPropertiesScan`** affect runtime registration, not metadata generation — the processor keys off the `@ConfigurationProperties` annotation on the type regardless, so a missing registration annotation is a *runtime* binding bug, not a metadata one. Don't conflate the two. - **Lombok**: if getters are Lombok-generated, ensure Lombok's processor runs before/with the config processor (ordering on the annotationProcessor path) so the getters exist when it scans. ## Framing for a principal The underlying model: metadata is a **static, per-module, compile-time artifact** driven entirely by what the processor can see in source. Every failure mode reduces to one of: processor not invoked (wiring/kapt), property not visible (accessor/nesting), artifact stale (incremental build), or wrong module. Once you internalize that, diagnosis is systematic rather than guesswork. And crucially, none of this affects runtime — a missing key in auto-completion never means the app won't bind it; it means the tooling can't see it.
- Keys from a shared library module don't auto-complete in the app that depends on it. Why?Metadata is generated per module. The library must be compiled with the configuration-processor so its own spring-configuration-metadata.json ends up in its jar on the app's classpath. If the library omits the processor, downstream consumers get no completion even though binding still works.
- The keys appear but have no descriptions in a Kotlin project. What's happening?The processor extracts descriptions from Javadoc, and KDoc is not extracted the same way, so Kotlin properties often show blank descriptions. Supply them via additional-spring-configuration-metadata.json if documentation matters.
saying these in an interview costs you the question
- Putting the processor on annotationProcessor for a Kotlin project instead of kapt
- Assuming missing auto-completion means the property won't bind at runtime
- Forgetting @NestedConfigurationProperty for nested non-inner types
- Thinking @EnableConfigurationProperties/@ConfigurationPropertiesScan affects metadata generation
- Expecting incremental builds to always regenerate the JSON