|
| 1 | +# HOCON Configuration Defaults |
| 2 | + |
| 3 | +Rules governing where configuration defaults live and how `DefaultAppConfig` reads them. Every default value for the application must be declared exactly once — in `application.conf` (HOCON) — never duplicated as a Kotlin fallback literal. |
| 4 | + |
| 5 | +## Context |
| 6 | + |
| 7 | +*Applies to:* `src/main/resources/application.conf`, `AppConfig.kt`, `ConfigExtensions.kt`, and any code that reads Ktor `ApplicationConfig` |
| 8 | +*Level:* Tactical — direct impact on config correctness and maintainability |
| 9 | +*Audience:* Developers wiring configuration at the application boundary |
| 10 | + |
| 11 | +## Core Principles |
| 12 | + |
| 13 | +1. *Single Source of Truth:* A default belongs in exactly one place — `application.conf`. Two copies (HOCON + a Kotlin literal) inevitably drift and nothing keeps them in sync. |
| 14 | +2. *Fail Fast:* A missing config key is a deploy misconfiguration. Required reads should throw at startup so the problem surfaces immediately, rather than being masked by a silent Kotlin fallback. |
| 15 | +3. *No Dead Fallbacks:* `application.conf` always supplies its keys in production, so a Kotlin-side default never fires there. Such fallbacks are dead production code that only exist to be re-asserted by circular tests. |
| 16 | +4. *Helpers Earn Their Keep:* A config helper should do genuine work (type/`Option` conversion, parsing) — not merely hold a duplicated default. |
| 17 | + |
| 18 | +## Rules |
| 19 | + |
| 20 | +### Must Have (Critical) |
| 21 | + |
| 22 | +- *RULE-001:* Declare every config default exactly once, in `application.conf`. No Kotlin literal may duplicate a HOCON default value. |
| 23 | +- *RULE-002:* Read mandatory values as required properties — `config.property(path).getString()` (with `.toInt()` / `.toLong()` as needed) — matching the existing `jwtSecret` style. A required read throwing on a missing key is the desired behaviour. |
| 24 | +- *RULE-003:* Do not introduce `getXxxOrDefault`-style helpers (or inline `propertyOrNull(...) ?: default` equivalents) that carry a default. Defaulting is HOCON's job. |
| 25 | +- *RULE-004:* When adding a new config key, add it to `application.conf` with its default (and a `${?ENV_VAR}` override where its siblings have one) **before** reading it in `DefaultAppConfig`. |
| 26 | + |
| 27 | +### Should Have (Important) |
| 28 | + |
| 29 | +- *RULE-101:* Read genuinely optional values as `Option<A>` via `getOptionString` (using `propertyOrNull(...).toOption()`), never as a defaulted read or a nullable. |
| 30 | +- *RULE-102:* Keep non-defaulting helpers that do real work (e.g. `getOptionString`, `getCommaSeparatedSet`); name them for the work they do, not for a default they no longer hold. |
| 31 | +- *RULE-103:* Do not write unit tests whose only assertion re-states `application.conf` constants. Once defaults live solely in HOCON such a test is circular; rely on integration/acceptance specs that boot from real config. |
| 32 | + |
| 33 | +### Could Have (Preferred) |
| 34 | + |
| 35 | +- *RULE-201:* If repeated `.getString().toInt()` / `.toLong()` reads feel noisy, thin **non-defaulting** typed helpers (`getInt(path)`, `getLong(path)`) are acceptable — provided they carry no default. Pick one approach and apply it uniformly. |
| 36 | +- *RULE-202:* Mirror the `${?ENV_VAR}` override convention across a config block so siblings stay consistent (e.g. every `database.*` key has its override). |
| 37 | + |
| 38 | +## Patterns & Anti-Patterns |
| 39 | + |
| 40 | +### ✅ Do This |
| 41 | + |
| 42 | +```hocon |
| 43 | +# application.conf — the one place the default lives |
| 44 | +database { |
| 45 | + name = "sdkman" |
| 46 | + name = ${?DATABASE_NAME} |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +```kotlin |
| 51 | +// AppConfig.kt — read directly; HOCON supplies the default |
| 52 | +override val databaseName: String = config.property("database.name").getString() |
| 53 | +override val databasePort: Int = config.property("database.port").getString().toInt() |
| 54 | +override val databaseUsername: Option<String> = config.getOptionString("database.username") |
| 55 | +``` |
| 56 | + |
| 57 | +### ❌ Don't Do This |
| 58 | + |
| 59 | +```kotlin |
| 60 | +// Default duplicated in Kotlin — drifts from application.conf, never fires in prod |
| 61 | +override val databaseName: String = config.getStringOrDefault("database.name", "sdkman") // ❌ |
| 62 | +override val databasePort: Int = config.getIntOrDefault("database.port", 5432) // ❌ |
| 63 | + |
| 64 | +// Helper that exists only to hold a duplicated default |
| 65 | +fun ApplicationConfig.getIntOrDefault(path: String, default: Int): Int = // ❌ |
| 66 | + propertyOrNull(path)?.getString()?.toInt() ?: default |
| 67 | +``` |
| 68 | + |
| 69 | +## Decision Framework |
| 70 | + |
| 71 | +*When deciding how to read a value:* |
| 72 | +1. Mandatory in production → required read (RULE-002). |
| 73 | +2. Genuinely optional (may be absent by design) → `Option<A>` (RULE-101). |
| 74 | +3. Needs parsing/transformation → a non-defaulting helper (RULE-102). |
| 75 | + |
| 76 | +*When tempted to add a Kotlin default:* add it to `application.conf` instead. If you think a key might be absent at runtime, decide whether that is a misconfiguration (let it throw) or a real optional (use `Option`) — never paper over it with a literal. |
| 77 | + |
| 78 | +*Tests don't load `application.conf`:* test code builds `MapApplicationConfig` directly, so HOCON defaults never apply. Test config builders must supply every required key explicitly — fix the test map, do not reintroduce a Kotlin fallback to cover the gap. |
| 79 | + |
| 80 | +## Exceptions & Waivers |
| 81 | + |
| 82 | +*Valid reasons for exceptions:* |
| 83 | +- A value that is legitimately absent in some environments — use `Option<A>`, not a default. |
| 84 | +- Third-party config APIs that force a nullable boundary — convert to `Option` immediately, still without a default. |
| 85 | + |
| 86 | +*Process for exceptions:* document the rationale in a code comment and prefer `Option` over a literal default in every case. |
| 87 | + |
| 88 | +## Quality Gates |
| 89 | + |
| 90 | +- *Automated checks:* `grep -RIn "OrDefault" src/main` returns no hits; `./gradlew check` passes (detekt forbids nullables/suppressions). |
| 91 | +- *Code review focus:* every default appears once (HOCON only); no Kotlin literal mirrors a HOCON value; reads are required / `Option` / parsed as appropriate. |
| 92 | +- *Testing requirements:* no unit test re-asserts HOCON constants; required keys are supplied by the test config builder, not by a Kotlin fallback. |
| 93 | + |
| 94 | +## Related Rules |
| 95 | + |
| 96 | +- rules/kotlin.md — `Option` over nullables, expression bodies, immutability at the config boundary. |
| 97 | +- rules/hexagonal-architecture.md — configuration is wired at the application boundary, not inside the domain. |
| 98 | +- rules/kotest.md — boot-from-real-config acceptance tests prove every required key is present; avoid circular unit tests. |
| 99 | + |
| 100 | +## References |
| 101 | + |
| 102 | +- [HOCON specification](https://github.com/lightbend/config/blob/main/HOCON.md) — substitution and `${?ENV}` override syntax. |
| 103 | +- [Ktor configuration](https://ktor.io/docs/configuration-file.html) — `ApplicationConfig`, `property`, and `propertyOrNull`. |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## TL;DR |
| 108 | + |
| 109 | +*Key Principles:* |
| 110 | +- Every config default lives exactly once, in `application.conf`. |
| 111 | +- Required reads fail fast on a missing key; that is desirable, not a bug to mask. |
| 112 | +- A Kotlin fallback literal is dead production code that drifts and breeds circular tests. |
| 113 | + |
| 114 | +*Critical Rules:* |
| 115 | +- Must declare defaults only in HOCON; no Kotlin literal may duplicate one. |
| 116 | +- Must read mandatory values as required properties (`config.property(path).getString()`). |
| 117 | +- Must not add `getXxxOrDefault`-style helpers that carry a default; use `Option<A>` for genuinely optional values. |
| 118 | + |
| 119 | +*Quick Decision Guide:* |
| 120 | +When in doubt: put the default in `application.conf` and read it directly — if a key might be missing, that is either a misconfiguration (let it throw) or an `Option`, never a Kotlin literal. |
0 commit comments