Skip to content

Commit 2db7938

Browse files
committed
docs: add hocon rule
1 parent d2a9556 commit 2db7938

1 file changed

Lines changed: 120 additions & 0 deletions

File tree

.claude/rules/hocon.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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

Comments
 (0)