82 KiB
Phase 1: Project Infrastructure & Module Wiring - Research
Researched: 2026-04-24 Domain: Gradle build infrastructure for KMP + CMP + Ktor (iOS-primary, self-hosted server) Confidence: HIGH
Summary
Phase 1 is build-plumbing only — no feature logic, no auth, no domain tables. The risk is not "can we pick the right library?" (the stack is locked) but "will the plumbing we lay now be painful to unwind?" Four things have to be right on day 1: (a) iOS native binary flags (objcDisposeOnMain, gc=cms), (b) a clean split between the 5 convention plugins so shared/ never pulls Compose and the server never pulls Android, (c) a version-catalog-only dependency surface so grep-for-version-literals returns nothing, and (d) Ktor booting against Postgres with Flyway already scheduled to run in Phase 3 without refactor.
The single highest-leverage construct is the precompiled-script-plugin pattern in build-logic/ — fine-grained (per D-06) so each module's plugins { } block reads as a role declaration. Accessing the version catalog from inside a precompiled plugin requires a deliberate extension trick (extensions.getByType<VersionCatalogsExtension>().named("libs")) because the libs accessor is only auto-generated in module build scripts, not inside build-logic/. Everything else (Koin bootstrap, Kermit setup, /health route, docker-compose) is pattern-matching against well-documented idioms.
Primary recommendation: Build recipe.quality first (smallest, testable in isolation), then recipe.kotlin.multiplatform (the dependency root), then the three leaf plugins (compose.multiplatform, android.application, jvm.server) in parallel. Wire each module's build.gradle.kts only after its convention plugins are green. Gate Phase 1 completion on ./gradlew build producing an Android APK + iOS framework + server fat JAR with zero version literals outside the catalog.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Target matrix
- D-01: Drop the
jstarget fromcomposeAppandshared. KeepwasmJsas the strategic future-web bet. - D-02: Skip
iosX64. User is on Apple Silicon; no Intel-Mac contributors anticipated. - D-03: Keep
jvmtarget incomposeAppfor Desktop — as a dev tool only (hot-reload). No Compose Desktop packaging; not a release surface. - D-04:
shared/ships the same target set ascomposeApp:androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs. Plusjvmcovers the server dependency. - D-05: Final target matrix repo-wide:
androidTarget, iosArm64, iosSimulatorArm64, jvm (Desktop + Server), wasmJs.
Convention plugins (build-logic/)
- D-06: Fine-grained plugin split (5 plugins):
recipe.kotlin.multiplatform,recipe.compose.multiplatform,recipe.android.application,recipe.jvm.server,recipe.quality. - D-07:
recipe.kotlin.multiplatformlocks in the D-05 target set, JVM toolchain, framework basename convention (ComposeApp/Shared), andkotlin-testas a common-test dep. - D-08: JVM toolchain is JVM 21 for server, desktop, and
shared/jvm. Android bytecode target stays JVM 11 (Android 7 minSdk constraint per template). Document this split in convention plugin comments. - D-09: All library versions live in
gradle/libs.versions.toml. Hard rule: grep for a non-test version literal inside anybuild.gradle.ktsreturns zero matches. Plugin versions also routed through the catalog.
Code-quality toolchain
- D-10: Minimal baseline — ktlint via Spotless only. Spotless handles Kotlin + Gradle files + markdown. Commands:
./gradlew spotlessCheck,./gradlew spotlessApply. No Detekt, no Konsist in Phase 1. - D-11:
allWarningsAsErrors = trueeverywhere (configured inrecipe.kotlin.multiplatform). - D-12:
explicitApi()strict onshared/only. Configured inshared/build.gradle.ktsdirectly, not in the KMP plugin. - D-13: No git hooks.
./gradlew checkis the local gate; CI gate deferred to Phase 11.
Phase 1 scope beyond the template
- D-14: Koin bootstrap. Add Koin deps (
koin-core,koin-compose,koin-compose-viewmodel) viarecipe.kotlin.multiplatform. Ship an emptyappModuleincomposeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt. - D-15: Kermit logger bootstrap. Set a single top-level tag (
"recipe") during app init. - D-16: Server:
/healthendpoint + Flyway scaffold + Postgres conn config.GET /healthreturns 200 with trivial JSON body. Flyway Gradle plugin + runtime dep wired;src/main/resources/db/migration/created empty.application.confreadsDATABASE_URL,DATABASE_USER,DATABASE_PASSWORDfrom env with localhost defaults. Server fails loudly if Postgres unreachable. - D-17:
docker-compose.ymlat repo root definespostgres:16service with named volume. README gets a "Local development" section.
Infrastructure hygiene
- D-18: iOS binary flags:
kotlin.native.binary.objcDisposeOnMain=falseandkotlin.native.binary.gc=cmsingradle.properties. - D-19:
shared/commonMainstays pure: domain models +@SerializableDTOs only; no Ktor, Compose, or SQLDelight imports. Phase 1 ships an empty package scaffold underdev.ulfrx.recipe.shared. - D-20: Namespace
dev.ulfrx.recipe. Framework basenameComposeAppfor iOS. No feature modules in v1.
Claude's Discretion
- Exact ordering of plugin application inside each
build.gradle.kts. - Specific
spotless { kotlin { ktlint(...) } }ruleset version (pick latest stable from catalog). - Whether
application.conforApplicationConfig.ktcode owns env-var parsing. - Flyway
cleanDisabledandbaselineOnMigrateflag choices (use sane defaults for dev). - Whether Koin bootstrap in
MainViewControllerusesKoinApplicationvsstartKoin(iOS-specific idiom). - Whether
docker-compose.ymluses a.envfile or inlines localhost defaults. - The exact sentinel JSON body for
/health(empty object is fine).
Deferred Ideas (OUT OF SCOPE)
- Detekt — add only if code review starts missing bugs that Detekt would catch.
- Konsist — revisit ~Phase 4.
- CI pipeline — Phase 11.
- Git hooks — explicitly rejected.
explicitApifor composeApp/server — rejected (app code, not library).iosX64target — rejected.jstarget — rejected.- Compose Desktop packaging (dmg/msi/exe) — out of scope entirely. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| INFRA-01 | Gradle version catalog (gradle/libs.versions.toml) is the single source of truth for library versions |
§Standard Stack table + version-lookup patterns for libs.versions.X.get().toInt() and libs.plugins.X.get().pluginId |
| INFRA-02 | build-logic/ convention plugins centralize Kotlin/Compose/test configuration across modules |
§Convention Plugin Mechanics + §Architecture Patterns with full skeleton for all 5 plugins |
| INFRA-03 | iOS Kotlin/Native binary options set from day 1: kotlin.native.binary.objcDisposeOnMain=false, gc=cms |
§iOS Binary Flags with exact property keys, rationale, and verification procedure |
| INFRA-06 | shared/commonMain contains only domain models + API DTOs — no UI, no HTTP, no DB code |
§Pattern: Shared module as a pure-Kotlin library + Anti-Patterns + verification grep |
| </phase_requirements> |
Project Constraints (from CLAUDE.md)
The following CLAUDE.md conventions are load-bearing for Phase 1 and must be honored by all plans:
- #5 Exposed DSL only, never DAO. Phase 1 doesn't wire Exposed at all, but the
recipe.jvm.serverplugin must not accidentally pullexposed-daotransitively (it won't if Exposed isn't added in Phase 1 — verified below). - #6
newSuspendedTransactionfor every coroutine-touching handler. Not applicable in Phase 1 (no DB operations yet) but the server plugin must not preclude it. - #7 iOS binary flags on day 1. Directly addressed by D-18 / §iOS Binary Flags.
- #8
shared/commonMainstays light. Directly addressed by D-19 / INFRA-06. - #9 Strings externalized from day 1. Phase 1 only needs the
composeAppmodule to have Compose Resources wired (already present from template —compose.components.resourcesincommonMaindeps). Real copy lands in Phase 11.
Architectural Responsibility Map
Phase 1 ships infrastructure — not features — so the "tier ownership" exercise is about where each concern's configuration lives, not where business logic runs:
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Target matrix (KMP) | recipe.kotlin.multiplatform plugin |
module build.gradle.kts |
Plugin defines targets; module applies plugin; target-specific source sets live in the module. |
| Compose runtime wiring | recipe.compose.multiplatform plugin |
composeApp/build.gradle.kts only |
Server + shared must NOT get Compose; this plugin applies only to composeApp. |
| Android app shell | recipe.android.application plugin |
composeApp/build.gradle.kts only |
Namespace, SDK versions, application ID; shared is a KMP library (no android-application). |
| Server JVM + Ktor + Flyway | recipe.jvm.server plugin |
server/build.gradle.kts only |
Applies kotlin("jvm"), Ktor plugin, Flyway plugin; pulls only server-side deps. |
| Code quality (ktlint, warnings-as-errors) | recipe.quality plugin |
every module | Cross-cutting; must be reusable across KMP/Android/JVM modules without breakage. |
| iOS native runtime flags | gradle.properties (project root) |
— | Global K/N compiler options; cannot be set per-module. |
| Version constraints | gradle/libs.versions.toml |
— | Single source of truth (D-09). Modules and convention plugins read from here. |
| Koin DI container | composeApp (di/AppModule.kt, App(), MainViewController) |
— | Client-side DI only in Phase 1; server DI added in Phase 2+. |
| Kermit logger | composeApp init path |
shared/ (usage later) |
Client-side logger; server uses Logback (already wired). |
Ktor server module + /health |
server/src/main/kotlin/.../Application.kt |
application.conf |
Routing lives in Kotlin; boot config in HOCON. |
| Database connection config | application.conf (HOCON env vars) |
ApplicationConfig.kt reader |
Env wins; HOCON provides localhost defaults matching docker-compose. |
| Local Postgres | docker-compose.yml (repo root) |
.env file optional |
Dev ergonomics only; homelab deploy is Phase 11. |
Standard Stack
Core (already in catalog — versions below are what's pinned in libs.versions.toml)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Kotlin | 2.3.20 | Compiler / stdlib | [CITED: libs.versions.toml] — locked per PROJECT.md |
| AGP | 8.11.2 | Android build | [CITED: libs.versions.toml] — template default |
| Compose Multiplatform | 1.10.3 | UI framework | [CITED: libs.versions.toml] |
| Compose Hot Reload | 1.0.0 | Desktop dev iteration | [CITED: libs.versions.toml] — preserve existing wiring (commit c50d747) |
| Ktor | 3.4.1 | Server + Client | [CITED: libs.versions.toml] — locked per PROJECT.md |
| Kotlinx Coroutines | 1.10.2 | Async primitives | [CITED: libs.versions.toml] |
| Logback | 1.5.32 | Server logging | [CITED: libs.versions.toml] — already wired |
New additions for Phase 1 (to be added to catalog)
| Library | Version | Purpose | Source |
|---|---|---|---|
| Koin (BOM) | 4.2.1 | DI container | [VERIFIED: central.sonatype.com] — latest stable Apr 2026, supports Kotlin 2.3.x |
| Koin Compose Viewmodel | (via BOM) | CMP ViewModel integration | [CITED: Koin KMP setup docs] — required for koinViewModel() with Jetpack Nav CMP |
| Kermit | 2.1.0 | KMP logger | [VERIFIED: github.com/touchlab/Kermit/releases] — latest stable Mar 2025 |
| Spotless plugin | 8.4.0 | Formatter harness | [VERIFIED: web search 2026-03] — requires JRE 17+ (we use 21, fine) |
| Flyway plugin | 12.4.0 | DB migration runner | [VERIFIED: plugins.gradle.org 2026-04-14] — standard ID org.flywaydb.flyway |
| Flyway core | 12.4.0 | Runtime migrator | [VERIFIED: central.sonatype.com] — match plugin version |
| Flyway postgresql | 12.4.0 | Postgres dialect | [VERIFIED: Flyway docs] — required for Postgres 15+ support |
| PostgreSQL JDBC | 42.7.10 | JDBC driver (runtime) | [VERIFIED: mvnrepository.com] — current stable; Flyway pulls indirectly but we pin explicitly |
| kotlinx-serialization-json | 1.7.3+ | Ktor JSON content-negotiation | [VERIFIED: via ktor-serialization-kotlinx-json coords] — version bundled with Ktor 3.4.1 |
| ktor-server-content-negotiation | 3.4.1 | Content negotiation plugin | [VERIFIED: same as ktor version] — required for /health JSON |
| ktor-serialization-kotlinx-json | 3.4.1 | JSON serializer for Ktor | [VERIFIED: same as ktor version] |
| Postgres JDBC test driver | 42.7.10 | Server integration tests (Phase 3+) | Deferred — not required in Phase 1 |
Alternatives considered:
| Instead of | Could Use | Why not for v1 |
|---|---|---|
| Spotless + ktlint | ktlint standalone plugin (jlleitschuh.gradle.ktlint) |
Spotless covers Kotlin + Gradle KTS + markdown in one plugin; simpler config surface |
Koin KoinApplication composable |
startKoin { } at app entry |
See §Koin Bootstrap below; both work but startKoin via top-level initKoin() is the Koin-docs-canonical pattern for KMP |
| Flyway via programmatic API only (no Gradle plugin) | Kept the Gradle plugin anyway | Plugin gives ./gradlew flywayInfo / flywayMigrate for ops ergonomics; runtime Flyway.configure().load().migrate() still drives boot-time migration |
ktor-server-auto-head / ktor-server-call-logging |
skip in Phase 1 | Not needed for a single /health route; added incrementally in later phases |
Installation (new catalog entries — illustrative TOML fragments):
[versions]
koin = "4.2.1"
kermit = "2.1.0"
spotless = "8.4.0"
flyway = "12.4.0"
postgresql = "42.7.10"
kotlinx-serialization = "1.7.3"
[libraries]
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-composeViewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" }
ktor-serializationKotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" }
ktor-serverConfigYaml = { module = "io.ktor:ktor-server-config-yaml-jvm", version.ref = "ktor" } # only if HOCON→YAML is preferred; see §Ktor config
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
[plugins]
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }
Version verification commands (Wave 0 — before locking):
# Verify each added version against current registry
curl -s 'https://repo1.maven.org/maven2/io/insert-koin/koin-bom/' | grep 'href="4\.'
curl -s 'https://repo1.maven.org/maven2/co/touchlab/kermit/' | grep 'href="2\.'
curl -s 'https://plugins.gradle.org/m2/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/' | grep 'href'
curl -s 'https://plugins.gradle.org/m2/org/flywaydb/flyway/org.flywaydb.flyway.gradle.plugin/' | grep 'href'
If any version is newer than what's listed above, use the newer stable and update this table.
Architecture Patterns
System Architecture Diagram
┌──────────────────────────────────────────────────────────────────┐
│ gradle/libs.versions.toml (single source of truth, D-09) │
└──────────────────────────────────────────────────────────────────┘
│
▼ read by
┌──────────────────────────────────────────────────────────────────┐
│ build-logic/ (included build: includeBuild("build-logic")) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ recipe.quality.gradle.kts (Spotless + ktlint, │ │
│ │ allWarningsAsErrors │ │
│ │ — reusable everywhere) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ recipe.kotlin.multiplatform.gradle.kts │ │
│ │ Targets: androidTarget, iosArm64, iosSimulatorArm64, │ │
│ │ jvm, wasmJs. JVM toolchain 21. │ │
│ │ Deps: Koin BOM + koin-core (commonMain) │ │
│ │ Kermit (commonMain) │ │
│ │ kotlin-test (commonTest) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────┐ ┌───────────────────────────┐ │
│ │ recipe. │ │ recipe.compose. │ │
│ │ android. │ │ multiplatform.gradle.kts │ │
│ │ app │ │ (Compose plugin + │ │
│ └────────────┘ │ hot-reload + │ │
│ │ compose deps commonMain) │ │
│ └───────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ recipe.jvm.server.gradle.kts │ │
│ │ kotlin("jvm") + Ktor plugin + Flyway plugin │ │
│ │ Ktor server-core / netty / content-negotiation / json │ │
│ │ Flyway core / postgresql + PostgreSQL JDBC driver │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ applied by
▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ composeApp/ │ │ shared/ │ │ server/ │
│ plugins { │ │ plugins { │ │ plugins { │
│ id("recipe. │ │ id("recipe. │ │ id("recipe. │
│ kotlin.mp") │ │ kotlin.mp") │ │ jvm.server")│
│ id("recipe. │ │ id("recipe. │ │ id("recipe. │
│ compose.mp")│ │ quality") │ │ quality") │
│ id("recipe. │ │ } │ │ } │
│ android.app")│ │ kotlin{ │ │ (no Compose, │
│ id("recipe. │ │ explicitApi() │ │ no Android) │
│ quality") │ │ } │ │ │
│ } │ └────────────────┘ └────────────────┘
└────────────────┘
│
▼ boots
┌──────────────────────┐
│ ApplicationKt.main() │
│ → Flyway.migrate() │ ◄── fails loudly
│ → /health (JSON) │ if Postgres
│ → Netty :8080 │ unreachable
└──────────────────────┘
│
▼
┌──────────────────────┐
│ docker-compose.yml │
│ postgres:16 │
│ volume: pgdata │
│ :5432 │
└──────────────────────┘
Recommended Project Structure (Phase 1 adds the bolded items)
recipe/
├── composeApp/
│ └── src/commonMain/kotlin/dev/ulfrx/recipe/
│ ├── App.kt # (exists) — add startKoin call
│ ├── di/
│ │ └── AppModule.kt # NEW — empty module placeholder
│ └── logging/
│ └── Logging.kt # NEW — `Logger.setTag("recipe")`
├── iosApp/iosApp/
│ ├── iOSApp.swift # MODIFY — call KoinKt.doInitKoin()
│ └── ContentView.swift # (exists) — no change
├── server/
│ ├── build.gradle.kts # REWRITE — applies recipe.jvm.server + quality
│ └── src/main/
│ ├── kotlin/dev/ulfrx/recipe/
│ │ ├── Application.kt # MODIFY — install ContentNegotiation, /health, Flyway boot
│ │ └── Database.kt # NEW — reads ApplicationConfig, runs Flyway.migrate()
│ └── resources/
│ ├── application.conf # NEW — HOCON with ${?DATABASE_URL} env overrides
│ ├── logback.xml # (exists)
│ └── db/migration/ # NEW — empty dir, .gitkeep
├── shared/
│ ├── build.gradle.kts # REWRITE — recipe.kotlin.mp + explicitApi() + quality
│ └── src/commonMain/kotlin/dev/ulfrx/recipe/shared/ # NEW pkg (empty)
├── build-logic/ # NEW — included build
│ ├── settings.gradle.kts # NEW
│ ├── build.gradle.kts # NEW — kotlin-dsl, kotlinGradlePlugin, agp, spotless-plugin as compileOnly
│ └── src/main/kotlin/
│ ├── recipe.quality.gradle.kts # NEW
│ ├── recipe.kotlin.multiplatform.gradle.kts # NEW
│ ├── recipe.compose.multiplatform.gradle.kts # NEW
│ ├── recipe.android.application.gradle.kts # NEW
│ └── recipe.jvm.server.gradle.kts # NEW
├── gradle/
│ └── libs.versions.toml # EXTEND — add Koin/Kermit/Spotless/Flyway/Postgres
├── gradle.properties # EXTEND — append 2 iOS binary flags
├── settings.gradle.kts # EXTEND — add includeBuild("build-logic")
├── docker-compose.yml # NEW — postgres:16 service
├── .env.example # NEW (if D-17 .env route chosen)
└── README.md # EXTEND — "Local development" section
Pattern 1: build-logic/ as an included build (not a subproject)
What: build-logic/ is its own Gradle build, composed into the main build via includeBuild("build-logic") in settings.gradle.kts. Plugins are written as precompiled script plugins: a Kotlin file named foo.bar.gradle.kts under build-logic/src/main/kotlin/ automatically becomes a plugin with ID foo.bar.
When to use: Always for multi-module Kotlin/Android/KMP projects. Prefer precompiled .gradle.kts plugins over full Plugin<Project> classes unless you need parameterization — the DSL is identical to a build.gradle.kts, so your mental model transfers.
Example — build-logic/settings.gradle.kts [CITED: gradle best practices, VersionCatalogSample]:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
Example — build-logic/build.gradle.kts:
plugins {
`kotlin-dsl`
}
dependencies {
// These must be on the buildscript classpath so precompiled plugins
// can use `plugins { id("...") }` for alias-based IDs.
compileOnly(libs.plugins.kotlinMultiplatform.asDependency())
compileOnly(libs.plugins.androidApplication.asDependency())
compileOnly(libs.plugins.androidLibrary.asDependency())
compileOnly(libs.plugins.composeMultiplatform.asDependency())
compileOnly(libs.plugins.composeCompiler.asDependency())
compileOnly(libs.plugins.composeHotReload.asDependency())
compileOnly(libs.plugins.kotlinJvm.asDependency())
compileOnly(libs.plugins.ktor.asDependency())
compileOnly(libs.plugins.spotless.asDependency())
compileOnly(libs.plugins.flywayPlugin.asDependency())
}
// Helper extension because plugin catalog entries have .pluginId but not .asDependency() directly
fun Provider<PluginDependency>.asDependency(): Provider<String> =
map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" }
Note: the asDependency() trick maps a catalog plugin entry to its marker artifact coordinates so compileOnly() can resolve it on the buildscript classpath. This is how precompiled plugins can write plugins { id("org.jetbrains.kotlin.multiplatform") } without an explicit version.
Pattern 2: Accessing the version catalog from inside a precompiled plugin
What: The libs.xxx.yyy accessor is auto-generated only in module build scripts, not in precompiled plugins under build-logic/. Inside a precompiled plugin you must look up the catalog explicitly. [CITED: Gradle docs — Using a catalog in buildSrc]
Example — top of every precompiled plugin that reads versions:
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
// Now read versions/libraries/plugins:
val kotlinVersion = libs.findVersion("kotlin").get().toString()
val koinBom = libs.findLibrary("koin-bom").get()
val minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
Anti-pattern: Writing implementation(libs.koin.core) inside a precompiled plugin — does not compile. Use implementation(libs.findLibrary("koin-core").get()) instead.
Pattern 3: Applying another plugin by ID from inside a precompiled plugin
What: Inside a precompiled .gradle.kts you can write a normal plugins { id("...") } block, but only for plugin IDs whose markers are on the buildscript classpath via build-logic/build.gradle.kts (Pattern 1). Use bare string IDs — catalog accessors (libs.plugins.X) are NOT available in precompiled plugins, same constraint as Pattern 2.
Example — recipe.kotlin.multiplatform.gradle.kts (excerpt):
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) // Android bytecode only (D-08)
}
}
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp" // D-20; shared overrides to "Shared"
isStatic = true
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) // Server + Desktop (D-08)
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() }
// D-11: warnings as errors at extension level
compilerOptions {
allWarningsAsErrors.set(true)
}
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get()))
implementation(libs.findLibrary("koin-core").get())
implementation(libs.findLibrary("kermit").get())
}
commonTest.dependencies {
implementation(libs.findLibrary("kotlin-test").get())
}
}
}
Pattern 4: Layering compose on top of kotlin-multiplatform
What: recipe.compose.multiplatform applies itself on top of recipe.kotlin.multiplatform — it does NOT re-declare the KMP plugin, because a precompiled plugin applying another precompiled plugin is supported, but double-applying causes errors. shared/ does not apply recipe.compose.multiplatform, so it never pulls Compose.
Example — recipe.compose.multiplatform.gradle.kts:
plugins {
id("recipe.kotlin.multiplatform") // assumes kotlin-mp is already configured
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose.hot-reload") // preserve commit c50d747 wiring
}
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.findLibrary("compose-runtime").get())
implementation(libs.findLibrary("compose-foundation").get())
implementation(libs.findLibrary("compose-material3").get())
implementation(libs.findLibrary("compose-ui").get())
implementation(libs.findLibrary("compose-components-resources").get())
implementation(libs.findLibrary("androidx-lifecycle-viewmodelCompose").get())
implementation(libs.findLibrary("androidx-lifecycle-runtimeCompose").get())
implementation(libs.findLibrary("koin-compose").get())
implementation(libs.findLibrary("koin-composeViewmodel").get())
}
}
}
Why separate: If Compose deps were inside recipe.kotlin.multiplatform, shared/ would pull Compose — violating INFRA-06 / D-19.
Pattern 5: recipe.quality as the cross-cutting plugin
plugins {
id("com.diffplug.spotless")
}
spotless {
kotlin {
target("src/**/*.kt")
targetExclude("**/build/**", "**/generated/**")
ktlint() // pick up default version; bump via `.ktlint("1.x.y")` if needed
}
kotlinGradle {
target("*.gradle.kts")
ktlint()
}
format("markdown") {
target("*.md", "docs/**/*.md")
endWithNewline()
trimTrailingWhitespace()
}
}
// D-11 redundancy guard: if a module applies recipe.quality WITHOUT recipe.kotlin.multiplatform
// (e.g. a future pure-JVM utility), ensure allWarningsAsErrors still applies:
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
}
}
Pattern 6: recipe.android.application — applied ONLY to composeApp
plugins {
id("com.android.application")
}
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
android {
namespace = "dev.ulfrx.recipe"
compileSdk = libs.findVersion("android-compileSdk").get().toString().toInt()
defaultConfig {
applicationId = "dev.ulfrx.recipe"
minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
targetSdk = libs.findVersion("android-targetSdk").get().toString().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
Anti-pattern: Applying this to shared/. shared/ is a KMP library — if it ever needs Android, it should apply com.android.library (and we'd build a recipe.android.library plugin). Phase 1 does not require this; shared/ builds android-less through KMP's androidTarget only when a consumer (composeApp) applies the android-application plugin. (Note: the current template's shared/build.gradle.kts DOES apply com.android.library directly. Verify whether this is still needed after the refactor; if the shared Android target compiles via KMP + androidTarget alone, we can drop the android-library plugin. See §Open Questions.)
Pattern 7: recipe.jvm.server — Ktor + Flyway, no Compose/Android
plugins {
id("org.jetbrains.kotlin.jvm")
id("io.ktor.plugin")
id("org.flywaydb.flyway")
application
}
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21)
compilerOptions {
allWarningsAsErrors.set(true)
}
}
dependencies {
"implementation"(libs.findLibrary("ktor-serverCore").get())
"implementation"(libs.findLibrary("ktor-serverNetty").get())
"implementation"(libs.findLibrary("ktor-serverContentNegotiation").get())
"implementation"(libs.findLibrary("ktor-serializationKotlinxJson").get())
"implementation"(libs.findLibrary("logback").get())
"implementation"(libs.findLibrary("flyway-core").get())
"implementation"(libs.findLibrary("flyway-database-postgresql").get())
"implementation"(libs.findLibrary("postgresql").get())
"testImplementation"(libs.findLibrary("ktor-serverTestHost").get())
"testImplementation"(libs.findLibrary("kotlin-testJunit").get())
}
// Flyway plugin config — dev ergonomics only; runtime uses Flyway Java API in Application.kt
flyway {
url = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/recipe"
user = System.getenv("DATABASE_USER") ?: "recipe"
password = System.getenv("DATABASE_PASSWORD") ?: "recipe"
locations = arrayOf("classpath:db/migration")
cleanDisabled = true // safety: no accidental `flywayClean` in dev
baselineOnMigrate = true // tolerate an existing DB with no Flyway history
validateOnMigrate = true
}
Note on dependencies { "implementation"(...) }: inside precompiled plugins the named configurations aren't statically typed, so you must quote them. (In module build.gradle.kts files, implementation(...) is a typed method from the plugin's extension.)
Quoted configurations are a common footgun — plan-checkers should verify these compile before Phase 1 sign-off.
Anti-Patterns to Avoid
- Putting Compose plugin ID inside
recipe.kotlin.multiplatform: breaksshared/(no Compose). Instead, layerrecipe.compose.multiplatformon top (Pattern 4). - Applying
recipe.android.applicationtoshared/:shared/is a library, not an app. The current template appliescom.android.librarydirectly inshared/build.gradle.kts— we may or may not need to keep that after refactor (see Open Questions). - Calling
startKoininside a@Composable: composition-timed side effects race with UI rendering and panic on recomposition. Call at app entry, before composition starts (Android:Application.onCreate(); iOS:iOSApp.init(); Desktop: top ofmain()beforeapplication { }). - Adding Ktor Client, Compose, or SQLDelight deps to
shared/commonMain: violates D-19 / INFRA-06. Onlykotlinx-serialization+kotlinx-datetimeare allowed non-stdlib deps inshared/. (Phase 1 adds neither yet —shared/commonMainis truly empty beyond the placeholder package.) - Configuring
allWarningsAsErrorsviakotlinOptions {}: deprecated in Kotlin 2.2+ (removed in 2.3). UsecompilerOptions { allWarningsAsErrors.set(true) }at thekotlin { }extension level. [CITED: kotlinlang.org/docs/gradle-compiler-options.html] - Using deprecated
js()target: D-01 explicitly drops it; currentcomposeAppandsharedstill reference it and must be removed. (Current files confirmjs { browser() }blocks exist.) - Referencing
iosX64(): D-02 skips it; the current template doesn't reference it (verified incomposeApp/build.gradle.ktsandshared/build.gradle.kts), so this is a "don't add" guideline. - Calling
startKointwice on iOS: ifiOSApp.init()callsdoInitKoin()ANDMainViewControlleralso callsstartKoin, the second throwsKoinApplicationAlreadyStartedException. Pick one call site (recommendation:iOSApp.init()— see §Koin Bootstrap). - Using the Flyway Gradle plugin for runtime migration at server boot: the plugin is for ops ergonomics (CLI); runtime migration uses
Flyway.configure().dataSource(...).load().migrate()inDatabase.kt. Mixing the two leads to "why didn't my migration run on boot?" debugging. - Using
transaction {}in a coroutine / suspend context: PITFALLS.md #5. Phase 1 doesn't touch DB yet, but therecipe.jvm.serverplugin must not preclude usingnewSuspendedTransactionlater — verify by NOT addingexposed-daodeps in Phase 1 (we don't; Exposed isn't added at all until Phase 3).
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Multi-module Gradle conventions | Scripted copy-paste of kotlin { } blocks |
build-logic/ + precompiled .gradle.kts plugins |
One file per module, DSL identical to build.gradle.kts, full IDE support |
| Version management | Hardcoded versions in each module | gradle/libs.versions.toml + catalog accessors |
Single source of truth (INFRA-01 hard rule) |
| Kotlin formatter/linter harness | Script ktlint as a Exec task |
Spotless plugin | Handles multi-file, editor-config, caching, spotlessApply fix command |
| DB migration runner | Hand-rolled version table + SQL runner | Flyway (core + postgres dialect + plugin) | Industry standard; auto-apply on boot; repair command for corruption |
| KMP DI container | Service-locator singletons | Koin | Explicit graph, koinViewModel() integration with Jetpack Nav CMP, no codegen |
| KMP logger | println + NSLog via expect/actual |
Kermit | Platform-correct defaults; withTag; format control; same API everywhere |
| Plugin classpath for build-logic | Manually adding kgp/agp as buildscript classpath |
compileOnly(libs.plugins.X.asDependency()) in build-logic/build.gradle.kts |
Plugin markers resolve the real JARs; version stays in catalog |
| Postgres local dev | "install Postgres via brew" instructions | docker-compose up -d |
Works identically on macOS/Linux; clean teardown; version-pinned |
| iOS K/N GC tuning | Custom finalizer plumbing in Koin modules | Set objcDisposeOnMain=false, gc=cms in gradle.properties |
Addresses PITFALL #1 once, for all iOS code in the repo |
Key insight: every item above is "the plumbing everyone eventually rebuilds badly; starting with the library means you get the edge-case handling for free."
Runtime State Inventory
Phase 1 scaffolds infrastructure; it does not rename or migrate. Still, because it modifies the template, I audit what persists:
| Category | Items Found | Action Required |
|---|---|---|
| Stored data | None — no DB tables exist yet; Postgres volume (pgdata) is created by docker-compose on first boot but carries no meaningful state until Phase 3. |
None. Developers can docker compose down -v freely during Phase 1. |
| Live service config | None — the repo has no deployed services yet. Authentik is on user's homelab but untouched by Phase 1. | None. |
| OS-registered state | None — no Windows Task Scheduler / launchd / pm2 involvement. Hot-reload is a ./gradlew :composeApp:jvmRun -DmainClass=... --no-daemon invocation, nothing persistent. |
None. |
| Secrets/env vars | DATABASE_URL, DATABASE_USER, DATABASE_PASSWORD — introduced in Phase 1 via application.conf. Not secret in dev (localhost Postgres via docker-compose). No homelab secrets in Phase 1. |
Document defaults in README "Local development". Real secrets Phase 11 (homelab deploy). |
| Build artifacts / installed packages | ~/.gradle/caches pulls new artifacts (Koin, Kermit, Spotless, Flyway, Postgres JDBC). First ./gradlew build after Phase 1 will download ~80 MB. Existing build/ and composeApp/build/Kotlin/ caches from the template may contain stale js target outputs (D-01 removes this target). |
Developers should ./gradlew clean once after Phase 1 to flush stale js/ target outputs. Document this in the migration note. |
Canonical question — After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?
Answer for Phase 1: Build caches only. A single ./gradlew clean resolves it. No external systems are affected.
Common Pitfalls
Pitfall 1: precompiled plugin can't see libs.xxx accessor
What goes wrong: You write implementation(libs.koin.core) inside a build-logic/src/main/kotlin/recipe.foo.gradle.kts file — compile error: "Unresolved reference: libs".
Why it happens: Type-safe catalog accessors are generated only for module build scripts, not for precompiled plugins. [CITED: Gradle docs — Using a catalog in buildSrc]
How to avoid: Use the explicit lookup pattern (extensions.getByType<VersionCatalogsExtension>().named("libs").findLibrary("koin-core").get()).
Warning signs: First red squiggle when you start writing a convention plugin.
Pitfall 2: double-applying KMP plugin
What goes wrong: recipe.compose.multiplatform.gradle.kts applies both id("org.jetbrains.kotlin.multiplatform") and id("recipe.kotlin.multiplatform") — Gradle error "Plugin X already applied".
Why it happens: Forgetting that recipe.kotlin.multiplatform already applies KMP.
How to avoid: Compose plugin applies only id("recipe.kotlin.multiplatform") (which internally applies KMP) plus the Compose-specific plugins. See Pattern 4.
Warning signs: Clear error message; easy to fix once seen.
Pitfall 3: forgot allWarningsAsErrors on a specific compilation
What goes wrong: Setting compilerOptions { allWarningsAsErrors = true } at the kotlin { } extension level covers common, but a per-target override (e.g. androidTarget { compilerOptions { ... } }) can silently mask it.
Why it happens: Kotlin 2.x DSL inherits options top-down but any child compilerOptions { } block creates its own scope.
How to avoid: Set allWarningsAsErrors.set(true) at the kotlin { compilerOptions { } } extension level only; don't re-open per-target compilerOptions { } blocks unless setting target-specific things (like jvmTarget). The recipe.quality plugin also has a tasks.withType<KotlinCompilationTask<*>>().configureEach { ... } safety net.
Warning signs: Build passes despite a deprecation warning in some sourceSet.
Pitfall 4: Koin startKoin called twice on iOS
What goes wrong: iOSApp.swift calls KoinKt.doInitKoin(), THEN MainViewController() calls startKoin { modules(appModule) } again → KoinApplicationAlreadyStartedException on second app launch (or on cold iOS re-entry).
Why it happens: Both Android and iOS samples in Koin docs show different entry points; it's easy to copy both.
How to avoid: Pick a single initKoin() helper in commonMain, called from ONE place per platform. Canonical pattern:
// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
config?.invoke(this)
modules(appModule)
}
Then Android calls initKoin { androidContext(applicationContext) } from Application.onCreate() or MainActivity.onCreate(); iOS calls initKoin() from KoinKt.doInitKoin() in iosMain, invoked once in iOSApp.init(). MainViewController() does NOT start Koin — it assumes Koin is already started. [CITED: insert-koin.io KMP setup docs]
Warning signs: App crashes on second launch on iOS; works fine the first time after a clean install.
Pitfall 5: Ktor HOCON env-var syntax confusion
What goes wrong: port = ${?PORT} behaves differently from port = ${PORT:8080}. The first makes the whole assignment disappear if PORT is unset (not what you want for defaults); the second fails at parse time if PORT is unset.
Why it happens: HOCON substitution semantics are non-obvious. [VERIFIED: Ktor docs — heroku.md, sevalla.md, dokku.md]
How to avoid: Use the two-line pattern for a default that can be overridden by env:
ktor {
deployment {
port = 8080
port = ${?PORT} # if PORT is set, overrides; otherwise this line is a no-op
}
}
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
Read via application.environment.config.propertyOrNull("database.url")?.getString() in Database.kt.
Warning signs: Server crashes on startup with "Could not resolve substitution to a value" (fix: use ${?X} form); or env override silently ignored (fix: the second line overrides the first only if env is set).
Pitfall 6: Flyway connects to Postgres at plugin-task time, not server-boot time
What goes wrong: You run ./gradlew build without Postgres running, and the build fails with a JDBC error — even though you didn't invoke flywayMigrate.
Why it happens: Some Flyway plugin versions evaluate the flyway { ... } block eagerly at configuration time.
How to avoid: Keep the Flyway plugin config simple and don't depend Flyway tasks on classes/build. Use the plugin only for CLI tasks (./gradlew flywayInfo, ./gradlew flywayMigrate). The runtime migration path is through Flyway.configure().dataSource(...).load().migrate() in Database.kt. D-17's docker-compose documents starting Postgres before running the server; CI in Phase 11 will bring its own Postgres.
Warning signs: ./gradlew build fails with JDBC connection refused despite not targeting Flyway.
Pitfall 7: Kotlin 2.x compilerOptions vs kotlinOptions
What goes wrong: Copy-pasting kotlinOptions { jvmTarget = "21" } from an older tutorial — deprecation warning under Kotlin 2.x, fails the build under D-11 (allWarningsAsErrors).
Why it happens: Legacy DSL still "works" but is deprecated.
How to avoid: Always write compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } (note .set() — it's a Provider property). [CITED: kotlinlang.org/docs/gradle-compiler-options.html]
Warning signs: Deprecation message: "The 'kotlinOptions' DSL is deprecated."
Pitfall 8: Wasm + Koin init order
What goes wrong: wasmJsMain doesn't have an iOSApp-style init hook; ComposeViewport { App() } runs composition immediately. If Koin isn't started before App() composes, koinViewModel<X>() throws.
Why it happens: The template's webMain/main.kt enters composition directly.
How to avoid: Call initKoin() at the top of the Wasm main():
fun main() {
initKoin()
ComposeViewport { App() }
}
Phase 1 doesn't ship any ViewModels yet, so this is a future-proofing note — but since D-01 keeps wasmJs and composeApp/src/webMain/main.kt exists, we should add it now to avoid "it broke silently in Phase 5" discoveries.
Warning signs: First Koin usage in wasmJs throws NoDefinitionFoundException despite appModule being correct.
Pitfall 9: includeBuild order in settings.gradle.kts
What goes wrong: includeBuild("build-logic") is placed inside dependencyResolutionManagement { } instead of pluginManagement { }; the main project can't find recipe.* plugin IDs.
Why it happens: includeBuild inside pluginManagement makes the included build's plugins available to the root project's plugins { } blocks. Inside dependencyResolutionManagement it affects dependency resolution instead.
How to avoid: Place includeBuild("build-logic") inside pluginManagement { } at the top of settings.gradle.kts:
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
Warning signs: "Plugin [id: 'recipe.kotlin.multiplatform'] was not found" — almost always this.
Pitfall 10: iOS framework basename mismatch
What goes wrong: baseName = "ComposeApp" in composeApp (D-20) but baseName = "shared" (template default) in shared/. Xcode imports both as import ComposeApp and import shared — works but inconsistent; if shared later gains its own framework publication, name casing will trip developers.
Why it happens: The template defaults to lowercase "shared".
How to avoid: Since shared/ in this project is NOT published as its own iOS framework (it's a dependency of composeApp's framework, compiled in), its baseName is irrelevant — but set it to "Shared" anyway for consistency, per D-07. The composeApp framework ComposeApp re-exports shared symbols automatically.
Warning signs: Xcode import statements inconsistent casing.
Code Examples
Convention plugin: recipe.kotlin.multiplatform.gradle.kts (full)
// build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
// Establishes the D-05 target matrix + JVM toolchain + common deps.
// Android bytecode is JVM 11; everything else is JVM 21 (D-08).
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp" // modules override (shared → "Shared")
isStatic = true
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() }
compilerOptions {
allWarningsAsErrors.set(true) // D-11
}
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get()))
implementation(libs.findLibrary("koin-core").get())
implementation(libs.findLibrary("kermit").get())
}
commonTest.dependencies {
implementation(libs.findLibrary("kotlin-test").get())
}
}
}
Koin bootstrap: initKoin() in commonMain, called once per platform
// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
package dev.ulfrx.recipe.di
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin {
config?.invoke(this)
modules(appModule)
}
// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
package dev.ulfrx.recipe.di
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule = module {
// intentionally empty in Phase 1
}
iOS-side wrapper [CITED: insert-koin.io KMP setup docs]:
// composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt
package dev.ulfrx.recipe.di
fun doInitKoin() { initKoin() }
Swift call site [CITED: insert-koin.io cmp.md]:
// iosApp/iosApp/iOSApp.swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinIosKt.doInitKoin() // Kotlin fun `doInitKoin` → generated Swift symbol KoinIosKt.doInitKoin
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Android call site:
// composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
package dev.ulfrx.recipe
import android.app.Application
import dev.ulfrx.recipe.di.initKoin
import org.koin.android.ext.koin.androidContext
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin {
androidContext(this@MainApplication)
}
}
}
Then AndroidManifest.xml gains android:name=".MainApplication" on the <application> tag.
Desktop + Wasm call sites (top of main() before composition):
// composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
fun main() {
initKoin()
application {
Window(onCloseRequest = ::exitApplication, title = "recipe") { App() }
}
}
// composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
fun main() {
initKoin()
ComposeViewport { App() }
}
Kermit bootstrap: set tag once, BEFORE Koin
// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt
package dev.ulfrx.recipe.logging
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform-specific log writers (OSLog on iOS, LogCat on Android, System.out on JVM)
// are installed automatically by Kermit's default Logger setup.
}
Call configureLogging() at the very top of initKoin() (or each platform main()/Application.onCreate()/iOSApp.init()) so logging is available inside Koin module loading itself. Order: configureLogging() → initKoin() → composition.
Ktor /health + ContentNegotiation + JSON
// server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
package dev.ulfrx.recipe
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
fun main() {
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
@Serializable
private data class Health(val status: String)
fun Application.module() {
install(ContentNegotiation) {
json()
}
Database.migrate(this) // fails loudly if Postgres unreachable (D-16)
routing {
get("/health") {
call.respond(Health(status = "ok"))
}
}
}
Database.kt — fail-loud Postgres + Flyway boot
// server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
package dev.ulfrx.recipe
import co.touchlab.kermit.Logger
import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
object Database {
private val log = Logger.withTag("Database")
fun migrate(app: Application) {
val url = app.environment.config.property("database.url").getString()
val user = app.environment.config.property("database.user").getString()
val password = app.environment.config.property("database.password").getString()
log.i { "Connecting to $url as $user and running Flyway migrations" }
runCatching {
Flyway.configure()
.dataSource(url, user, password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.validateOnMigrate(true)
.cleanDisabled(true)
.load()
.migrate()
}.onFailure { ex ->
log.e(ex) { "Flyway migration failed — cannot start server" }
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
}
Note: server uses Logback (already wired) not Kermit — Kermit is the client-side logger. Kept for consistency at the API level but server logs go through SLF4J/Logback. For the Database object, if Kermit isn't set up on the server side, substitute org.slf4j.LoggerFactory.getLogger(...) — recommend using SLF4J on the server throughout, keeping Kermit for composeApp/shared.
Decision recommendation: Server uses SLF4J+Logback; client uses Kermit. Kermit's server-side JVM log writer exists but adds no value over the Logback stack already present.
application.conf — HOCON with env overrides
// server/src/main/resources/application.conf
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ dev.ulfrx.recipe.ApplicationKt.module ]
}
}
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
docker-compose.yml
# repo root docker-compose.yml
services:
postgres:
image: postgres:16
container_name: recipe-postgres
environment:
POSTGRES_DB: recipe
POSTGRES_USER: recipe
POSTGRES_PASSWORD: recipe
ports:
- "5432:5432"
volumes:
- recipe-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U recipe -d recipe"]
interval: 5s
timeout: 5s
retries: 5
volumes:
recipe-pgdata:
Recommendation on .env vs inline: inline is fine for single-dev + matching application.conf defaults. .env adds a file to .gitignore and an .env.example; more surface area without much benefit for a 2-person project. Deferring .env is safe; revisit when a second environment (staging) appears.
gradle.properties — iOS binary flags (D-18)
# Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
# Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true
# Android
android.nonTransitiveRClass=true
android.useAndroidX=true
# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1
# CMS GC: reduces pause spikes on UI-heavy iOS apps (Compose Multiplatform)
kotlin.native.binary.gc=cms
# Prevents Obj-C deinit from blocking the main thread — ships deinit to a special GC thread
kotlin.native.binary.objcDisposeOnMain=false
Verification of the two flags [CITED: kotlinlang.org/docs/native-binary-options.html]:
- Run
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --infoand grep the output forobjcDisposeOnMainandgc=cms— Kotlin's K/N link step echoes the binary options it's compiling with. - On simulator launch, absence of warnings like "legacy memory manager" or "freeze()" deprecations confirms the new MM is active.
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
kotlinOptions { jvmTarget = "11" } |
compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } |
Kotlin 2.2 (deprecated), 2.3 (still works but warns) | D-11 treats warnings as errors, so must migrate |
freeze() / @SharedImmutable / kotlin.native.concurrent.AtomicReference |
new K/N memory manager (default since 1.9); StateFlow is thread-safe | Kotlin 1.9 | PITFALLS.md #2 — old tutorials still show this, reject them |
kotlin.native.binary.memoryModel=experimental |
No flag needed; new MM is default | Kotlin 1.9 | Don't re-add legacy flag |
js { browser() } target for web |
wasmJs { browser() } |
CMP 1.6+ | D-01 drops js entirely |
build-src/ directory |
build-logic/ as included build via includeBuild |
Gradle 7+ | Cleaner build isolation, faster rebuilds |
Flyway 9.x flyway-core only |
Flyway 12.x splits Postgres dialect into flyway-database-postgresql artifact |
Flyway 10 | Must add flyway-database-postgresql for Postgres 15+ |
io.ktor:ktor-serialization:... |
io.ktor:ktor-serialization-kotlinx-json-jvm:... |
Ktor 3.x | Separate content-negotiation plugin + serializer plugin |
| Compose hot reload via third-party plugin | org.jetbrains.compose.hot-reload (JetBrains) |
CMP 1.9+ | Already in catalog (template commit c50d747) |
Deprecated/outdated:
kotlinOptions { }block — replaced bycompilerOptions { }with Provider propertiesiosX64()target — D-02 rejects; Apple Silicon onlyjs()target — D-01 rejects; wasmJs covers the nichefreeze()/@SharedImmutable/ old K/N memory-management APIs — all no-ops or deprecated under Kotlin 2.x
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Koin 4.2.1 (BOM) is the current stable and works under Kotlin 2.3.20 + CMP 1.10.3 | Standard Stack | If 4.2.x has a KMP regression with CMP 1.10.3, need to pin to last known-good 4.1.x until resolved. Verify by ./gradlew build after bumping; mitigation is easy (single version.ref change). |
| A2 | Flyway 12.4.0's programmatic API signature (Flyway.configure().dataSource(...).load().migrate()) is stable and compatible with Postgres 16 |
Code Examples: Database.kt | Low — API hasn't changed in years. Doc [CITED: Flyway API (Java).md]. |
| A3 | baselineOnMigrate = true is the right default for a dev environment with a fresh DB (no-op) and a homelab prod with pre-existing tables in Phase 11 |
Pattern 7; Database.kt | Low — this is the recommended setting for projects adopting Flyway partway; we're adopting from day 1, so it's technically unnecessary but defensive. |
| A4 | The current template's shared/build.gradle.kts applies com.android.library because Android cross-compilation needs it; we may drop it if androidTarget { } inside KMP is sufficient |
Anti-Patterns (android.application to shared) | Medium — dropping android-library might break :shared:androidDebugLibrary consumption. Keep applying it through a future recipe.android.library plugin (deferred beyond Phase 1) OR keep the existing direct application in shared/build.gradle.kts for now. Research inconclusive; recommend keeping direct application until tested. |
| A5 | The "Kermit for client, SLF4J/Logback for server" split is preferable to Kermit on both | Pattern 6; Database.kt | Low — Kermit-JVM works on the server, just adds a dep. The split is a style call, not a correctness issue. |
| A6 | Hot-reload wiring (commit c50d747) continues to work after refactor into recipe.compose.multiplatform |
Pattern 4 | Medium — if the plugin's id("org.jetbrains.compose.hot-reload") must be applied AFTER id("org.jetbrains.compose"), the order inside the precompiled plugin matters. Verify by ./gradlew :composeApp:jvmRun producing a hot-reloadable Desktop window after Phase 1. |
| A7 | Postgres JDBC 42.7.10 works under JVM 21 with Postgres 16 | Standard Stack | Low — well-documented. |
| A8 | explicitApi() in shared/build.gradle.kts will not force the current empty Greeting/Platform classes to add visibility modifiers — they're already class (public by default in Kotlin, so no changes) |
Pattern 4 / shared configuration | Low — current shared/src/commonMain/.../Greeting.kt uses default-public classes, which explicitApi() accepts. Verify by running ./gradlew :shared:build after enabling. |
If this table is non-empty: Planner and /gsd-discuss-phase already ran; these assumptions are residual and should be verified during implementation, not re-surfaced. Items A4 and A6 are the ones most likely to surprise — include in Wave 0 smoke checks.
Open Questions (RESOLVED)
-
Should
shared/keepcom.android.librarydirectly applied, or rely onandroidTargetin therecipe.kotlin.multiplatformplugin alone?- What we know: Current template applies
com.android.librarydirectly. KMP'sandroidTarget { }declares the Android target but doesn't strictly require the android-library plugin for every module — sometimes it does. - What's unclear: Whether dropping
com.android.libraryfromshared/breaks the composeApp Android consumer. - RESOLVED: Keep
com.android.libraryapplied inshared/build.gradle.ktsdirectly in Phase 1. Build arecipe.android.libraryconvention plugin in a future phase if the direct application becomes a pattern. Don't block Phase 1 on this refactor.
- What we know: Current template applies
-
Does
./gradlew buildinvokeflywayMigrate? Should it?- What we know: Flyway plugin exposes
flywayMigrate,flywayInfo, etc. as tasks; it does NOT hook them intobuildby default. - What's unclear: Nothing — this is a choice.
- RESOLVED: Do NOT wire Flyway tasks into
buildin Phase 1. Migration is a server-boot concern; the plugin is for CLI ops (developer runs./gradlew flywayInfomanually to inspect state). CI integration lands in Phase 11.
- What we know: Flyway plugin exposes
-
Should we add
ktor-server-config-yamlfor aapplication.yamlalternative to HOCON?- What we know: Ktor 3.x supports YAML config via the
ktor-server-config-yamlartifact; HOCON remains the default. - What's unclear: Team preference.
- RESOLVED: Stick with HOCON. Our server dev is Kotlin/Ktor background (user profile) and HOCON is the historically canonical Ktor config. YAML is a nice-to-have, not worth the added dep.
- What we know: Ktor 3.x supports YAML config via the
-
How to verify iOS binary flags take effect without shipping a build to hardware?
- What we know: Simulator launch eliminates most of the visible symptoms of PITFALL #1; Instruments on a real device would be the gold standard.
- What's unclear: Whether simulator-level verification is sufficient for Phase 1 sign-off.
- RESOLVED: Verify at two levels: (a) grep
gradle.propertiesfor the two flags (trivial but catches omission); (b) build the iOS framework and capture the Kotlin/Native link log for a line showing the GC + objcDisposeOnMain options. Real-device verification under Instruments is deferred to Phase 10 (UI chrome) when there's meaningful UI work to stress-test.
-
Does
recipe.qualityneed atargetExcludefor generated Compose Resources code?- What we know: Compose Multiplatform generates
Res.ktunderbuild/generated/compose/resourceGenerator/.... - What's unclear: Whether Spotless/ktlint visit
build/by default (they shouldn't, but worth confirming). - RESOLVED: Add
targetExclude("**/build/**", "**/generated/**")explicitly in the Spotless config (already in Pattern 5 example) to future-proof against any.ktfile landing in those paths.
- What we know: Compose Multiplatform generates
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Java / JDK | All Gradle builds | ✓ | OpenJDK 25.0.2 | — (JDK 21 toolchain resolved via Foojay if project JDK differs) |
| Docker | D-17 docker-compose / Postgres local | ✓ | 27.3.1 | — |
| Docker Compose | D-17 | ✓ | v2.40.0-desktop.1 | docker compose (v2 as subcommand) works identically |
| Xcode + iOS SDK | iOS framework build, simulator verification | ❓ (not probed — user on macOS; assume present given iOS-primary target) | — | If missing, developer installs from App Store before Phase 1 Wave 2 (iOS tasks) |
| Gradle daemon | All builds | Implicit (bundled with wrapper) | 8.x (from wrapper) | — |
| npx (for ctx7 docs lookups) | Research only, not build | ✓ (npm present) | — | — |
| Node / npm | Not required at build time | — | — | Project is pure Kotlin; no Node |
| Homebrew | Not required | — | — | — |
Missing dependencies with no fallback: None detected. Xcode is assumed but not probed; if missing, the iOS framework build will fail with a clear error at Phase 1 Wave 2 (iOS task execution).
Missing dependencies with fallback: None.
Validation Architecture
Phase 1 success is defined by 5 success criteria in ROADMAP.md (SC1–SC5) and 4 phase requirements (INFRA-01/02/03/06). Each maps to a specific verification command.
Test Framework
| Property | Value |
|---|---|
| Framework | kotlin.test (commonTest) + JUnit 4 (server test via ktor-server-test-host) + existing template tests |
| Config file | composeApp/src/commonTest/kotlin/ComposeAppCommonTest.kt, shared/src/commonTest/kotlin/SharedCommonTest.kt, server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (all present) |
| Quick run command | ./gradlew :server:test :composeApp:jvmTest :shared:jvmTest (JVM-only subset — <30s) |
| Full suite command | ./gradlew check (runs spotlessCheck + all test tasks across all targets) |
Phase 1 primarily adds build-level verification (Gradle tasks succeed, file structure correct, version literals absent) rather than unit tests. The existing ApplicationTest.kt is updated to cover the /health endpoint.
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| INFRA-01 | No version literals in any build.gradle.kts |
shell grep | ! grep -rE '(version[[:space:]]*=[[:space:]]*"[0-9]|"[0-9]+\.[0-9])' composeApp/build.gradle.kts server/build.gradle.kts shared/build.gradle.kts build-logic/src/main/kotlin/ |
❌ Wave 0 (script tools/verify-no-version-literals.sh) |
| INFRA-01 | gradle/libs.versions.toml is single source of truth |
manual visual + grep | grep -rE "libs\\.(versions|plugins|bundles)" build-logic/src/main/kotlin/ returns all version lookups |
✅ existing catalog |
| INFRA-02 | Convention plugins apply without duplication or errors | Gradle build | ./gradlew :composeApp:help :server:help :shared:help (each emits applied-plugins section including recipe.*) |
❌ Wave 0 (plugins don't exist yet) |
| INFRA-02 | Adding a new KMP module only needs id("recipe.kotlin.multiplatform") |
visual review of plugin | Demonstrated by the refactored shared/build.gradle.kts being ≤15 lines after refactor |
Target for Wave 2 |
| INFRA-03 | gradle.properties contains the two iOS flags |
grep | grep -E '^kotlin\\.native\\.binary\\.(gc=cms|objcDisposeOnMain=false)$' gradle.properties | wc -l returns 2 |
❌ Wave 0 |
| INFRA-03 | iOS simulator build boots without legacy memory-manager warnings | build log inspection | ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info 2>&1 | grep -i 'legacy|freeze' | grep -v '^$' returns empty |
Target for Wave 2 (iOS tasks) |
| INFRA-06 | shared/commonMain has no Ktor/Compose/SQLDelight imports |
grep | ! grep -rE '^import (io\\.ktor|androidx\\.compose|org\\.jetbrains\\.compose|app\\.cash\\.sqldelight)' shared/src/commonMain/kotlin/ |
❌ Wave 0 (script tools/verify-shared-pure.sh) |
| INFRA-06 | shared/ package scaffold exists |
file existence | test -d shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared |
Target for Wave 2 |
Success Criteria → Test Map (SC1–SC5 from ROADMAP.md)
| SC | Success Statement | Automated Command | Pass Criteria |
|---|---|---|---|
| SC1 | ./gradlew build succeeds across composeApp, server, shared; produces iOS framework and Android APK |
./gradlew build then test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk && test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework |
exit 0 + both files/dirs present |
| SC2 | No version literals in any build.gradle.kts | tools/verify-no-version-literals.sh |
exit 0 |
| SC3 | iOS gradle.properties carries the two flags; simulator debug launch has no legacy-MM warnings |
tools/verify-ios-flags.sh (grep gradle.properties) + optional simulator boot |
both flags present; simulator warning grep empty |
| SC4 | build-logic convention plugins apply to every module | ./gradlew :composeApp:help -q | grep 'recipe.kotlin.multiplatform' etc. |
each plugin shows in its applicable modules' help output |
| SC5 | shared/commonMain contains only domain models + DTOs |
tools/verify-shared-pure.sh |
exit 0 |
Additional acceptance beyond ROADMAP SC list:
| Check | Automated Command | Pass Criteria |
|---|---|---|
Server /health returns 200 JSON {"status":"ok"} |
docker compose up -d postgres && ./gradlew :server:run &; sleep 5; curl -s http://localhost:8080/health | grep -o '"status":"ok"'; kill %1 |
curl returns expected substring |
| Server fails loudly if Postgres missing | docker compose down; ./gradlew :server:run |
server exits non-zero within ~10s with "Database unreachable" in logs |
| Spotless formatting clean | ./gradlew spotlessCheck |
exit 0 |
./gradlew check runs full suite |
./gradlew check |
exit 0 |
| Koin starts without error in JVM target | ./gradlew :composeApp:jvmTest (existing template test runs composition path) |
exit 0; no KoinApplicationAlreadyStartedException |
Sampling Rate
- Per task commit:
./gradlew spotlessCheck :server:test :shared:jvmTest(fast subset, <30s) - Per wave merge:
./gradlew build(full build including iOS framework link and Android APK) - Phase gate:
./gradlew check+ manual server/healthcurl + iOS simulator boot verification
Wave 0 Gaps
tools/verify-no-version-literals.sh— shell script grepping for version literals outside catalogtools/verify-shared-pure.sh— shell script grepping for forbidden imports in shared/commonMaintools/verify-ios-flags.sh— shell script grepping gradle.properties for the two K/N flagsbuild-logic/directory scaffold with 5 empty placeholder.gradle.ktsfilesserver/src/main/resources/application.conf(does not exist yet)server/src/main/resources/db/migration/.gitkeep(directory placeholder)docker-compose.ymlat repo root- Extended
ApplicationTest.ktcovering/healthendpoint composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt+AppModule.ktcomposeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.ktcomposeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt+AndroidManifest.xmlregistrationcomposeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.ktiosApp/iosApp/iOSApp.swift— modify to callKoinIosKt.doInitKoin()
Security Domain
Phase 1 is infrastructure-only — no authentication, no user data, no network-facing multi-tenant endpoints. The /health route is unauthenticated by design (observability); it reveals only server liveness, not implementation detail.
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no (Phase 2) | ktor-server-auth-jwt (Phase 2) |
| V3 Session Management | no (Phase 2) | — |
| V4 Access Control | no (Phase 3) | household scoping (Phase 3) |
| V5 Input Validation | no (no request bodies yet) | kotlinx.serialization validation (future) |
| V6 Cryptography | no | — |
| V7 Error Handling | partial | Database fails loudly with opaque message (no stack trace in HTTP response) |
| V8 Data Protection | partial | .env / application.conf defaults use non-secret localhost creds; never check real secrets into git |
| V12 API Security | n/a | /health is the only endpoint, intentionally unauthenticated |
| V14 Configuration | yes | HOCON env-var overrides (${?DATABASE_URL}) ensure production creds come from environment, not from git |
Known Threat Patterns for this stack (Phase 1 subset)
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
Secret in application.conf committed to git |
Information Disclosure | Defaults must be non-secret (recipe/recipe/recipe localhost only). Real secrets arrive via env vars in Phase 11. Add *.env to .gitignore if .env route chosen. |
| Flyway clean wiping prod data | Destruction / Tampering | cleanDisabled = true in both plugin config and Database.kt programmatic call (Pattern 7 + Code Examples). |
Unauthenticated /health leaking runtime details |
Information Disclosure | Body is {"status":"ok"} only — no version, no commit hash, no uptime. (Leave build identifiers out until Phase 11.) |
| Postgres port 5432 exposed on 0.0.0.0 | Exposure | docker-compose.yml binds 5432:5432 on host; document in README that this is dev-local only, firewall required for any accidental multi-host use. |
Other security threats (auth bypass, SQL injection, CSRF, XSS) have no surface area in Phase 1 and are deferred to the phases that introduce them.
Sources
Primary (HIGH confidence)
- Koin KMP setup docs (Context7 /insertkoinio/koin) — Koin BOM coords,
initKoin()pattern, iOS SwiftUI entry point - Koin CMP quickstart —
KoinKt.doInitKoin()Swift wrapper - Kermit docs (Context7 /touchlab/kermit) —
Logger.setTag("...")global config - Ktor Content Negotiation + kotlinx.serialization — exact
install(ContentNegotiation) { json() }pattern - Ktor HOCON env-var config (Context7) —
port = 8080; port = ${?PORT}pattern for fallback defaults - Gradle version catalogs from buildSrc / build-logic —
VersionCatalogsExtensionaccess pattern - Gradle precompiled script plugins —
.gradle.ktsplugin authoring - Flyway programmatic Java API —
Flyway.configure().load().migrate()signature - Flyway Gradle plugin config —
org.flywaydb.flywayplugin ID and DSL - Spotless Gradle + ktlint config —
spotless { kotlin { ktlint() } } - Kotlin/Native binary options (kotlinlang.org) —
kotlin.native.binary.gc=cmsandobjcDisposeOnMain=false - Kotlin/Native memory manager — new MM default since Kotlin 1.9; PITFALLS.md #1/#2 context
- Kotlin compiler options (kotlinlang.org) —
compilerOptions { allWarningsAsErrors.set(true) }for Kotlin 2.x
Secondary (MEDIUM confidence — cross-verified with primary sources)
- Maven Central
io.insert-koin/koin-bom— confirmed 4.2.1 latest as of 2026-04 - MVNRepository
co.touchlab/kermit— confirmed 2.1.0 latest - Gradle plugin portal:
com.diffplug.spotless— confirmed 8.4.0 latest - Gradle plugin portal:
org.flywaydb.flyway— confirmed 12.4.0 latest - VersionCatalogSample GitHub reference — convention-plugin-access-to-catalog pattern
- droidcon: Gradle Kotlin convention plugins — multi-module convention plugin structure
Tertiary (training + synthesis)
- Kotlin 2.3.20 DSL behavior and
compilerOptionsprovider properties — training + kotlinlang.org cross-checks kotlin.native.binary.*defaults under Kotlin 2.x — kotlinlang.org + PITFALLS.md #1 synthesis
Metadata
Confidence breakdown:
- Standard stack: HIGH — all versions verified against Maven Central / Gradle plugin portal during this session
- Convention plugin mechanics: HIGH — directly sourced from Gradle docs, including the critical
VersionCatalogsExtensionaccess pattern - Koin KMP bootstrap: HIGH — Koin 4.x docs explicitly show the
initKoin()/doInitKoin()pattern; settles D-14 Claude's-discretion item - Kermit setup: HIGH — single-line tag + default platform writers is the documented canonical path
- Ktor
/health+ HOCON: HIGH — exact code + exact HOCON syntax both verified - Flyway programmatic API + Gradle plugin: HIGH — both documented, both pinned to 12.4.0
- iOS binary flags: HIGH — PITFALLS.md #1 + kotlinlang.org binary options reference
- Docker Compose service shape: HIGH — trivial postgres:16 pattern
- "What NOT to do" pitfalls: MEDIUM-HIGH — most are verified from docs; some (A4 android-library plugin on shared/, A6 hot-reload order) are conservative-assumption recommendations awaiting Wave 2 verification
Research date: 2026-04-24 Valid until: 2026-05-24 (4 weeks). Stack is stable; only risk is a Kotlin/CMP minor bump or a Koin 4.2.x regression. Re-verify before Phase 2 if this phase stretches beyond the valid-until window.