55 KiB
Phase 1: Project Infrastructure & Module Wiring — Pattern Map
Mapped: 2026-04-24
Files analyzed: 35 (new + modified across build, client, server, iOS bootstrap, dev ergonomics)
Analogs found: 28 / 35 (in-repo template files or RESEARCH.md canonical excerpts)
Analog provenance: All analogs are either (a) an existing JetBrains KMP template file in this repo, or (b) a canonical excerpt in 01-RESEARCH.md § Code Examples / § Architecture Patterns. No external code was consulted — the upstream-template idioms already shipped in-repo are the highest-fidelity reference.
Orientation for the executor
Phase 1 is greenfield-infrastructure with refactor of the JetBrains template. Three shapes of file exist:
| Shape | Count | Where the pattern lives |
|---|---|---|
MODIFIED-IN-PLACE — existing template file, narrow edits (add flags, drop js, etc.) |
11 | The file itself is the analog; PATTERNS.md shows the exact delta |
| NEW-FROM-TEMPLATE-ANALOG — new file whose shape mirrors a sibling template file | 9 | The sibling template file is the analog (e.g. MainActivity.kt → MainApplication.kt) |
| NEW-FROM-RESEARCH — net-new files with no in-repo analog; canonical example in RESEARCH.md § Code Examples | 15 | Copy directly from 01-RESEARCH.md § Code Examples excerpts (lines 777–1107) |
Executor rule of thumb: if a file exists today, use it as the analog and apply minimal edits. If it does not, RESEARCH.md lines 777–1107 contain a near-final canonical excerpt — treat those excerpts as the implementation starting point, adjust only for the D-# decisions explicitly referenced here.
File Classification
A. Build infrastructure (new build-logic/ + catalog + properties)
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
build-logic/settings.gradle.kts |
NEW | included-build settings | config | RESEARCH.md § Pattern 1 (lines 314–331) | canonical |
build-logic/build.gradle.kts |
NEW | plugin buildscript | config | RESEARCH.md § Pattern 1 (lines 333–358) | canonical |
build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts |
NEW | precompiled script plugin | config | RESEARCH.md § Code Examples (lines 777–835); current composeApp/build.gradle.kts kotlin { } block (lines 13–71) |
role+flow match |
build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts |
NEW | precompiled script plugin | config | RESEARCH.md § Pattern 4 (lines 447–477) | canonical |
build-logic/src/main/kotlin/recipe.android.application.gradle.kts |
NEW | precompiled script plugin | config | RESEARCH.md § Pattern 6 (lines 516–552); current composeApp/build.gradle.kts android { } block (lines 73–98) |
canonical + repo mirror |
build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts |
NEW | precompiled script plugin | config | RESEARCH.md § Pattern 7 (lines 558–601); current server/build.gradle.kts |
canonical + repo mirror |
build-logic/src/main/kotlin/recipe.quality.gradle.kts |
NEW | precompiled script plugin | config | RESEARCH.md § Pattern 5 (lines 483–512) | canonical |
gradle/libs.versions.toml |
MODIFIED | version catalog | config | itself (lines 1–53); add new [versions] + [libraries] + [plugins] entries for koin, kermit, spotless, flyway, postgres |
self |
gradle.properties |
MODIFIED | gradle daemon + K/N flags | config | itself (lines 1–10) + RESEARCH.md § gradle.properties (lines 1083–1102) |
self |
settings.gradle.kts |
MODIFIED | root settings | config | itself (lines 1–37); add includeBuild("build-logic") |
self |
build.gradle.kts |
MODIFIED | root build | config | itself (lines 1–12); keep apply false list, extend with new plugins |
self |
B. Module refactors (apply convention plugins, drop js)
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
composeApp/build.gradle.kts |
MODIFIED | module build | config | itself (lines 1–114); rewrite plugins block to convention-plugin IDs, drop js { } (lines 36–39), drop compose.desktop { nativeDistributions { ... } } packaging (lines 104–114, per D-03) |
self |
shared/build.gradle.kts |
MODIFIED | module build | config | itself (lines 1–55); rewrite plugins block, drop js { } (lines 25–27), add explicitApi() (D-12), possibly drop androidLibrary plugin (see D-07 + Pattern 6 note) |
self |
server/build.gradle.kts |
MODIFIED | module build | config | itself (lines 1–23); replace alias(libs.plugins.*) with convention-plugin IDs, add Flyway + Postgres deps via recipe.jvm.server |
self |
C. Client DI + logging bootstrap (new files in composeApp)
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt |
NEW | DI bootstrap | init-once | RESEARCH.md § Koin bootstrap (lines 840–861) | canonical |
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt |
NEW | DI module declaration | config | RESEARCH.md § Koin bootstrap (lines 852–861) | canonical |
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt |
NEW | logger bootstrap | init-once | RESEARCH.md § Kermit bootstrap (lines 933–946) | canonical |
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt |
NEW | platform bridge (Kotlin→Swift symbol) | init-once | RESEARCH.md § Koin bootstrap (lines 865–870); sibling: MainViewController.kt is the only existing iosMain file |
canonical + structural |
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt |
NEW | Android app entry | init-once | RESEARCH.md § Koin bootstrap (lines 896–911); sibling: MainActivity.kt (lines 1–19) |
canonical + sibling |
composeApp/src/androidMain/AndroidManifest.xml |
MODIFIED | Android manifest | config | itself (lines 1–22); add android:name=".MainApplication" to <application> tag |
self |
composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt |
MODIFIED | Desktop entry | init-once | itself (lines 1–13); add initKoin() + configureLogging() at top of main() |
self |
composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt |
MODIFIED | Wasm entry | init-once | itself (lines 1–10); add initKoin() + configureLogging() before ComposeViewport { } (PITFALL #8) |
self |
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt |
UNMODIFIED in Phase 1 | Compose root | render | keep current template body (lines 1–49); do NOT call startKoin from inside @Composable (anti-pattern in Pattern 4 notes) |
n/a |
D. iOS Swift bootstrap (wire doInitKoin)
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
iosApp/iosApp/iOSApp.swift |
MODIFIED | iOS app entry | init-once | itself (lines 1–11); add init() { KoinIosKt.doInitKoin() } (RESEARCH.md lines 874–891) |
self |
iosApp/iosApp/ContentView.swift |
UNMODIFIED | SwiftUI shell | render | n/a | n/a |
E. Shared module scaffold
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep (or placeholder package-info.kt-style file) |
NEW | empty scaffold | n/a | D-19: shared/commonMain is empty in Phase 1 beyond placeholder; existing Greeting.kt / Platform.kt / Constants.kt stay where they are (untouched this phase) |
structural |
shared/src/jsMain/** |
DELETED | n/a | n/a | D-01 drops js target; remove the entire directory (Platform.js.kt) |
delete |
composeApp/src/jsMain/** (if any) |
DELETED | n/a | n/a | D-01 drops js target; composeApp does not currently have a jsMain/ dir but has js { browser() } in its build — the build edit alone suffices |
delete |
F. Server infrastructure (new /health + Flyway + config)
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
server/src/main/kotlin/dev/ulfrx/recipe/Application.kt |
MODIFIED | Ktor module + entry | request-response | itself (lines 1–20); rewrite per RESEARCH.md § Ktor /health (lines 952–985) — swap get("/") + respondText(...) for install(ContentNegotiation) { json() } + Database.migrate(this) + get("/health") |
self + canonical |
server/src/main/kotlin/dev/ulfrx/recipe/Database.kt |
NEW | Flyway + DataSource bootstrap | init-once | RESEARCH.md § Database.kt (lines 988–1023) |
canonical |
server/src/main/resources/application.conf |
NEW | Ktor HOCON config | config | RESEARCH.md § application.conf (lines 1031–1051) |
canonical |
server/src/main/resources/db/migration/.gitkeep |
NEW | empty Flyway dir | n/a | convention (Flyway convention path) | structural |
server/src/main/resources/logback.xml |
UNMODIFIED | log config | config | keep as-is (lines 1–12); D-16 decides server uses SLF4J/Logback, not Kermit | self |
server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt |
MODIFIED | Ktor test | test | itself (lines 1–20); replace testRoot() body to assert /health returns 200 with {"status":"ok"} |
self |
G. Dev ergonomics (repo root)
| File | NEW/MODIFIED | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|---|
docker-compose.yml |
NEW | local postgres | config | RESEARCH.md § docker-compose.yml (lines 1053–1077) |
canonical |
README.md |
MODIFIED | dev docs | n/a | itself (lines 1–100); add "Local development" section with docker compose up -d postgres + env defaults, drop "Build and Run Web Application" JS section (D-01) |
self |
.gitignore |
MODIFIED (optional) | vcs config | n/a | itself (lines 1–20); add build-logic/build/, **/.gradle/ patterns if not already covered |
self |
tools/verify-no-version-literals.sh |
NEW (optional, Wave 0 gap) | shell validator | test | no analog — small shell script, RESEARCH.md § Wave 0 Gaps (line 1244) describes behavior | no analog |
tools/verify-shared-pure.sh |
NEW (optional, Wave 0 gap) | shell validator | test | no analog — same pattern as above | no analog |
tools/verify-ios-flags.sh |
NEW (optional, Wave 0 gap) | shell validator | test | no analog — same pattern as above | no analog |
Pattern Assignments
build-logic/settings.gradle.kts (included-build settings)
Analog: RESEARCH.md § Pattern 1 (lines 314–331). No in-repo analog; this is a greenfield Gradle idiom.
Complete excerpt to copy:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
Why this shape: build-logic/ is its own Gradle build. The from(files("../gradle/libs.versions.toml")) line is load-bearing — without it, the root catalog is invisible inside precompiled plugins and the findLibrary("...") lookups fail. [CITED: gradle best practices, VersionCatalogSample]
build-logic/build.gradle.kts (plugin buildscript)
Analog: RESEARCH.md § Pattern 1 (lines 333–358).
Complete excerpt to copy:
plugins {
`kotlin-dsl`
}
dependencies {
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())
}
fun Provider<PluginDependency>.asDependency(): Provider<String> =
map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" }
Critical detail: The .asDependency() extension is the bridge between catalog plugin entries and the buildscript classpath. Without it, precompiled plugins cannot write plugins { id("...") } without inlining a version (which D-09 forbids). The compileOnly(...) scope is correct — the plugin markers are needed only at plugin-compile time, not at runtime.
build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
Analogs (two sources):
- RESEARCH.md § Code Examples (lines 777–835) — the canonical form for this plugin.
composeApp/build.gradle.ktslines 13–71 — the current template'skotlin { }block that needs to be generalized and moved into this plugin.
Imports pattern:
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
Core pattern (from RESEARCH.md lines 789–834 — copy verbatim, adjusted for D-# decisions):
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21) // D-08: JVM 21 toolchain
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) // D-08: Android bytecode stays JVM 11
}
}
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp" // D-20; shared/build.gradle.kts overrides to "Shared"
isStatic = true
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) // D-08: server + desktop on 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())
}
}
}
Deltas vs. current composeApp/build.gradle.kts (lines 13–71):
- DROP the
js { browser(); binaries.executable() }block (lines 36–39) — D-01. - DROP
iosX64— already absent; keep absent (D-02). - Keep
iosArm64,iosSimulatorArm64,jvm,wasmJs,androidTarget(D-05). - PROMOTE the
kotlin { ... }block verbatim into this plugin. - ADD Koin + Kermit + kotlin-test catalog references (deps did not exist on current
composeApp). - ADD
compilerOptions { allWarningsAsErrors.set(true) }at thekotlin { }extension level (D-11). - Note the exact framework basename
"ComposeApp"— do not typo as"composeApp". PITFALL #10.
Anti-patterns to avoid (from RESEARCH.md § Anti-Patterns, lines 607–618):
- Do NOT re-declare
org.jetbrains.kotlin.multiplatforminrecipe.compose.multiplatform.gradle.kts— applying THIS plugin already applies it (PITFALL #2). - Do NOT open per-target
compilerOptions { }to setallWarningsAsErrors— set it once at thekotlin { compilerOptions { } }extension level (PITFALL #3). - Do NOT use deprecated
kotlinOptions { }DSL — Kotlin 2.3 removes it;compilerOptions { property.set(...) }is the only correct form (PITFALL #7).
build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts
Analog: RESEARCH.md § Pattern 4 (lines 447–477). No in-repo analog — the plugin did not exist.
Complete excerpt to copy:
plugins {
id("recipe.kotlin.multiplatform") // layers on top — do not re-declare KMP plugin
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose.hot-reload") // preserve commit c50d747 (hot-reload 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())
}
}
}
Deltas vs. current composeApp/build.gradle.kts lines 52–62:
- The current file declares Compose deps in
commonMain.dependencies— MOVE these into THIS plugin soshared/does not inherit Compose (D-19 / INFRA-06). - ADD koin-compose + koin-composeViewmodel (not in catalog yet).
Why separate from recipe.kotlin.multiplatform: if Compose deps were in the KMP plugin, shared/ would pull Compose, violating D-19 / INFRA-06. This plugin layers Compose on top — shared/ applies only recipe.kotlin.multiplatform, so it stays Compose-free.
build-logic/src/main/kotlin/recipe.android.application.gradle.kts
Analogs:
- RESEARCH.md § Pattern 6 (lines 516–552) — canonical form.
composeApp/build.gradle.ktslines 73–98 — the current template'sandroid { }block, moved verbatim.
Complete excerpt to copy:
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" // D-20
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
}
}
Delta vs. current composeApp/build.gradle.kts (lines 73–98):
- Replace
libs.versions.android.compileSdk.get().toInt()withlibs.findVersion("android-compileSdk").get().toString().toInt()— catalog accessor syntax changes inside precompiled plugins (PITFALL #1).
Anti-pattern (RESEARCH.md line 554): do NOT apply this plugin to shared/. shared/ is a KMP library. If shared/ needs Android, it applies com.android.library separately — but verify in Phase 1 whether shared/ actually needs that plugin; the current shared/build.gradle.kts line 7 applies it but it may be redundant with androidTarget in KMP.
build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts
Analogs:
- RESEARCH.md § Pattern 7 (lines 558–601) — canonical form.
server/build.gradle.ktslines 1–23 — current plugins block + dependencies, extended.
Complete excerpt to copy:
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 {
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
baselineOnMigrate = true
validateOnMigrate = true
}
Quoted-config footgun (RESEARCH.md line 603): inside a precompiled plugin, implementation(...) is not a typed method. You MUST write "implementation"(libs.findLibrary(...).get()) with quoted config name, or the build will fail with "unresolved reference: implementation".
Delta vs. current server/build.gradle.kts:
application { }block (lines 7–14 of current file) stays in the MODULEserver/build.gradle.kts, not this plugin — per-module concern.- ADD Flyway + Postgres + ContentNegotiation + kotlinx-serialization deps (catalog entries to add).
- Current
server/build.gradle.ktsuseslibs.plugins.kotlinJvm/libs.plugins.ktoraliases — REPLACE with convention-plugin IDid("recipe.jvm.server").
Flyway caveat (PITFALL #6, RESEARCH.md lines 719–724): the Flyway Gradle plugin is for CLI ergonomics (./gradlew flywayMigrate) only. Runtime migration happens through the Flyway Java API in Database.kt — do NOT wire Flyway tasks as a dependency of classes or build, or ./gradlew build will fail when Postgres is not running.
build-logic/src/main/kotlin/recipe.quality.gradle.kts
Analog: RESEARCH.md § Pattern 5 (lines 483–512). No in-repo analog.
Complete excerpt to copy:
plugins {
id("com.diffplug.spotless")
}
spotless {
kotlin {
target("src/**/*.kt")
targetExclude("**/build/**", "**/generated/**")
ktlint() // latest stable (Spotless default)
}
kotlinGradle {
target("*.gradle.kts")
ktlint()
}
format("markdown") {
target("*.md", "docs/**/*.md")
endWithNewline()
trimTrailingWhitespace()
}
}
// Redundancy guard for modules that apply recipe.quality without recipe.kotlin.multiplatform
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
}
}
Note on ktlint ruleset version: D-10 says pick the latest stable; Spotless's bare ktlint() call uses its default, which is fine. Pin only if drift becomes a problem.
gradle/libs.versions.toml (MODIFIED — catalog extensions)
Analog: itself (lines 1–53).
Imports pattern: n/a (TOML).
Delta — add under [versions]:
koin = "4.1.0" # bump to current at plan time; verify compose-multiplatform compat
kermit = "2.0.6" # bump to current at plan time
spotless = "7.2.1" # bump to current at plan time
flyway = "11.10.0" # bump to current at plan time
postgres-jdbc = "42.7.7" # bump to current at plan time
Delta — add under [libraries]:
# Koin (client DI)
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" }
# Note: BOM-managed deps omit version.ref
# Kermit (client logger)
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
# Server: ContentNegotiation + Flyway + Postgres
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" }
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 = "postgres-jdbc" }
Delta — add under [plugins]:
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }
Hard invariant (D-09 / INFRA-01 SC#2): after this edit, grep -rn 'version = \"[0-9]' --include='*.gradle.kts' . should return zero hits outside build-logic/build.gradle.kts auto-generated accessors. Wave 0 gap tools/verify-no-version-literals.sh enforces this.
gradle.properties (MODIFIED — add K/N flags)
Analog: itself (lines 1–10) + RESEARCH.md § gradle.properties (lines 1083–1102).
Delta — append to file (D-18, INFRA-03, PITFALL #1):
# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1
kotlin.native.binary.gc=cms
kotlin.native.binary.objcDisposeOnMain=false
Verification (RESEARCH.md lines 1104–1107): ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info | grep -E 'objcDisposeOnMain|gc=cms' should echo both flags. Wave 0 gap tools/verify-ios-flags.sh automates the grep.
settings.gradle.kts (MODIFIED — include build-logic)
Analog: itself (lines 1–37).
Delta: add includeBuild("build-logic") in the right position.
PITFALL #9 (RESEARCH.md lines 749–767): includeBuild("build-logic") must appear before pluginManagement { } consumes plugin IDs that come from build-logic, OR more simply: put pluginManagement { includeBuild("build-logic") } at the top. The proven layout:
rootProject.name = "recipe"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
includeBuild("build-logic")
repositories {
google { mavenContent { includeGroupAndSubgroups("androidx"); includeGroupAndSubgroups("com.android"); includeGroupAndSubgroups("com.google") } }
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google { mavenContent { includeGroupAndSubgroups("androidx"); includeGroupAndSubgroups("com.android"); includeGroupAndSubgroups("com.google") } }
mavenCentral()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
include(":composeApp")
include(":server")
include(":shared")
Note that includeBuild lives inside pluginManagement { } — not at top level — so the build-logic plugins are resolvable by ID in child module plugins { } blocks.
build.gradle.kts (root — MODIFIED)
Analog: itself (lines 1–12).
Delta: add apply false entries for spotless and flywayPlugin (so Gradle's classloader hint covers them):
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.ktor) apply false
alias(libs.plugins.spotless) apply false // NEW
alias(libs.plugins.flywayPlugin) apply false // NEW
}
composeApp/build.gradle.kts (MODIFIED — apply conventions)
Analog: itself (lines 1–114).
Core pattern — new plugins block:
plugins {
id("recipe.kotlin.multiplatform")
id("recipe.compose.multiplatform")
id("recipe.android.application")
id("recipe.quality")
}
kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
}
commonMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(projects.shared)
// Compose + Koin + Kermit + kotlin-test come via convention plugins
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
}
}
}
dependencies {
debugImplementation(libs.compose.uiTooling)
}
Deltas vs. current file:
- REPLACE plugins block (lines 5–11) with convention-plugin IDs.
- DROP the entire
kotlin { androidTarget { ... } ... }block (lines 13–71) — moved torecipe.kotlin.multiplatform. Keep only the per-modulesourceSets { ... }overrides forandroidMain/commonMain/jvmMaindeps that are NOT shared. - DROP the
android { }block (lines 73–98) — moved torecipe.android.application. - DROP
js { browser(); binaries.executable() }(lines 36–39) — D-01. - DROP
compose.desktop { application { ... nativeDistributions { ... } } }(lines 104–114) — D-03 says no desktop packaging.
shared/build.gradle.kts (MODIFIED — apply conventions + explicitApi)
Analog: itself (lines 1–55).
Core pattern:
plugins {
id("recipe.kotlin.multiplatform")
id("recipe.quality")
// NOTE: recipe.android.application is NOT applied — shared is a library, not an app
// NOTE: if com.android.library is still needed for androidTarget resources, apply directly:
// alias(libs.plugins.androidLibrary)
}
kotlin {
explicitApi() // D-12: strict only on shared/
sourceSets {
commonMain.dependencies {
// Phase 1: empty — domain models + DTOs land Phase 2+
}
}
// Override framework baseName for this module
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget>().configureEach {
binaries.withType<org.jetbrains.kotlin.gradle.plugin.mpp.Framework>().configureEach {
baseName = "Shared"
}
}
}
// Optional (see Open Questions in RESEARCH.md)
android {
namespace = "dev.ulfrx.recipe.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
}
Deltas vs. current file (lines 1–55):
- REPLACE plugins block (lines 4–7) with convention-plugin IDs; retain
androidLibraryalias if Android namespace / resources are required (decision pending per RESEARCH.md Anti-Patterns §). - DROP entire
kotlin { androidTarget { ... } ... }target block (lines 9–33) — moved torecipe.kotlin.multiplatform. - DROP
js { browser() }(lines 25–27) — D-01. - ADD
explicitApi()(D-12) — lives in the MODULE file so app modules don't inherit it. - OVERRIDE framework baseName to
"Shared"(the KMP plugin defaults to"ComposeApp"; shared needs its own symbol — PITFALL #10).
Anti-pattern check (D-19): commonMain.dependencies { } must stay empty in Phase 1. Do NOT add Ktor, Compose, or SQLDelight here — EVER. Only kotlinx-serialization + kotlinx-datetime are whitelisted for future phases.
server/build.gradle.kts (MODIFIED — apply convention)
Analog: itself (lines 1–23).
Core pattern:
plugins {
id("recipe.jvm.server")
id("recipe.quality")
}
group = "dev.ulfrx.recipe"
version = "1.0.0"
application {
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
dependencies {
implementation(projects.shared)
}
Deltas vs. current file (lines 1–23):
- REPLACE plugins block (lines 1–5) with convention-plugin IDs.
- DROP individual library implementations (lines 16–22) — moved to
recipe.jvm.server. - KEEP
application { mainClass.set(...) }(lines 9–14) — per-module concern. - KEEP
implementation(projects.shared)(line 17) — module-specific project dependency.
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt (NEW)
Analog: RESEARCH.md § Koin bootstrap (lines 840–850). No in-repo analog.
Complete file:
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)
}
Usage contract (RESEARCH.md § Kermit bootstrap line 948): call configureLogging() BEFORE initKoin(), so Koin module loading can use Kermit. Order per platform:
- Android:
MainApplication.onCreate()→configureLogging(); initKoin { androidContext(this) } - iOS:
iOSApp.init()→ Swift side callsKoinIosKt.doInitKoin()which invokesconfigureLogging(); initKoin() - Desktop:
main()top →configureLogging(); initKoin(); application { Window { App() } } - Wasm:
main()top →configureLogging(); initKoin(); ComposeViewport { App() }
Anti-pattern (PITFALL #4): do NOT call startKoin { } from inside MainViewController() AND from iOSApp.init() — you'll hit KoinApplicationAlreadyStartedException on second cold launch. Pick one: the canonical choice is iOSApp.init() → doInitKoin(). MainViewController() stays as-is (fun MainViewController() = ComposeUIViewController { App() }).
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt (NEW)
Analog: RESEARCH.md § Koin bootstrap (lines 852–861).
Complete file:
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
}
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt (NEW)
Analog: RESEARCH.md § Kermit bootstrap (lines 935–946).
Complete file:
package dev.ulfrx.recipe.logging
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
}
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt (NEW)
Analogs:
- RESEARCH.md § Koin bootstrap (lines 865–870) — the canonical symbol.
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt(lines 1–5) — sibling showing theexpect/actual-free simple iosMain style.
Complete file:
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() {
configureLogging()
initKoin()
}
Why the naming: Kotlin's top-level fun doInitKoin() in package dev.ulfrx.recipe.di becomes the Swift symbol KoinIosKt.doInitKoin() (framework baseName is ComposeApp per D-20, but the generated Swift class is <KotlinFileName>Kt — so KoinIos.kt → KoinIosKt). PITFALL #10 warns about basename mismatches; here the class suffix Kt is automatic and tied to the file name.
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt (NEW)
Analogs:
- RESEARCH.md § Koin bootstrap (lines 896–911) — canonical.
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt(lines 1–19) — sibling showing package + import conventions for this target.
Complete file:
package dev.ulfrx.recipe
import android.app.Application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
import org.koin.android.ext.koin.androidContext
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
configureLogging()
initKoin {
androidContext(this@MainApplication)
}
}
}
Additional catalog entry needed: koin-android = { module = "io.insert-koin:koin-android" } under [libraries] (BOM-managed, no version.ref). Wire via androidMain.dependencies in composeApp/build.gradle.kts OR add to recipe.compose.multiplatform if every Android consumer needs it.
composeApp/src/androidMain/AndroidManifest.xml (MODIFIED)
Analog: itself (lines 1–22).
Delta: line 4 <application tag — add android:name=".MainApplication".
After edit:
<application
android:name=".MainApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt (MODIFIED)
Analog: itself (lines 1–13) + RESEARCH.md § Koin bootstrap (lines 918–924).
Full replacement:
package dev.ulfrx.recipe
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
) {
App()
}
}
}
composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt (MODIFIED)
Analog: itself (lines 1–10) + RESEARCH.md § Koin bootstrap (lines 927–931); PITFALL #8 (lines 733–747).
Full replacement:
package dev.ulfrx.recipe
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
configureLogging()
initKoin()
ComposeViewport {
App()
}
}
Critical: both configureLogging() and initKoin() must run before ComposeViewport { } — otherwise first koinViewModel<X>() inside composition throws (PITFALL #8). Phase 1 has no ViewModels so this is defensive, but the template's shape must be right from day 1.
iosApp/iosApp/iOSApp.swift (MODIFIED)
Analog: itself (lines 1–11) + RESEARCH.md § Koin bootstrap (lines 874–891).
Full replacement:
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinIosKt.doInitKoin() // calls Kotlin's fun doInitKoin() in dev.ulfrx.recipe.di
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Deltas vs. current file (lines 1–11):
- ADD
import ComposeApp(the framework baseName — D-20 / PITFALL #10). - ADD
init() { KoinIosKt.doInitKoin() }.
server/src/main/kotlin/dev/ulfrx/recipe/Application.kt (MODIFIED)
Analog: itself (lines 1–20) + RESEARCH.md § Ktor /health (lines 952–985).
Full replacement:
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"))
}
}
}
Deltas vs. current file (lines 1–20):
- DROP
get("/") { call.respondText("Ktor: ${Greeting().greet()}") }— replaced by/health. - ADD
install(ContentNegotiation) { json() }— required for@Serializableresponse. - ADD
Database.migrate(this)— Flyway bootstrap (fails server boot if DB unreachable). - CHANGE imports from wildcard (
io.ktor.server.application.*) to specific — D-11 may warn on unused wildcards; be explicit. SERVER_PORTconstant continues to live inshared/commonMain/.../Constants.kt.
server/src/main/kotlin/dev/ulfrx/recipe/Database.kt (NEW)
Analog: RESEARCH.md § Database.kt (lines 990–1023). No in-repo analog.
Important substitution (RESEARCH.md lines 1025–1027): Kermit is the client logger. The server uses SLF4J + Logback (already wired via logback.xml). So this file must use SLF4J, not Kermit:
Complete file (server-adjusted SLF4J variant):
package dev.ulfrx.recipe
import io.ktor.server.application.Application
import org.flywaydb.core.Flyway
import org.slf4j.LoggerFactory
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
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.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway.configure()
.dataSource(url, user, password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.validateOnMigrate(true)
.cleanDisabled(true)
.load()
.migrate()
}.onFailure { ex ->
log.error("Flyway migration failed — cannot start server", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
}
Fail-loud contract (D-16): the throw IllegalStateException(...) is load-bearing — the server MUST refuse to start if Postgres is unreachable. This surfaces config errors immediately instead of letting the server run with a broken DB.
server/src/main/resources/application.conf (NEW)
Analog: RESEARCH.md § application.conf (lines 1031–1051). No in-repo analog (the current server has no application.conf; it's purely programmatic in Application.kt).
Complete file:
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}
}
HOCON substitution contract (PITFALL #5, RESEARCH.md lines 692–717): the two-line pattern url = "default"; url = ${?DATABASE_URL} is load-bearing. Use ${?X} (optional substitution), NOT ${X} (required, parse-time failure) or ${X:default} (wrong syntax for HOCON defaults).
Interaction with programmatic main(): the current Application.kt uses embeddedServer(Netty, port = SERVER_PORT, ...) programmatically. When application.conf is introduced, Ktor will read application.modules = [...] at boot time. The programmatic embeddedServer form in main() is still valid — HOCON overrides happen at application.environment.config.property(...) lookups (as Database.kt does). Verify at plan time whether to keep programmatic boot or switch to EngineMain (HOCON-driven). Either works; RESEARCH.md excerpt keeps the programmatic form for simplicity.
server/src/main/resources/db/migration/.gitkeep (NEW)
Analog: Flyway convention — empty directory placeholder.
Complete file: empty .gitkeep file. Phase 3 drops V1__init.sql here.
Why .gitkeep: git does not track empty directories; the .gitkeep convention (a zero-byte file) ensures the directory ships in-repo so Flyway.locations("classpath:db/migration") finds it.
server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (MODIFIED)
Analog: itself (lines 1–20).
Full replacement:
package dev.ulfrx.recipe
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ApplicationTest {
@Test
fun `health endpoint returns 200 with status ok`() = testApplication {
// Note: testApplication uses in-memory config; Database.migrate() must be skipped or mocked
// for this test to run without Postgres. Recommend: extract a `Application.configureRouting()`
// and test only routing in isolation. See plan note.
application {
install(io.ktor.server.plugins.contentnegotiation.ContentNegotiation) {
io.ktor.serialization.kotlinx.json.json()
}
io.ktor.server.routing.routing {
io.ktor.server.routing.get("/health") {
io.ktor.server.response.respond(mapOf("status" to "ok"))
}
}
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("\"status\""))
assertTrue(response.bodyAsText().contains("\"ok\""))
}
}
Deltas vs. current file (lines 1–20):
- DROP
testRoot()(testsGreeting().greet()output). - ADD
health endpoint returns 200test. - CAVEAT: the current
module()callsDatabase.migrate(this)which needs a real Postgres. Split the Ktor config: extractApplication.configureRouting()+Application.configureSerialization()helpers so tests can compose routing without the DB. Plan should capture this refactor.
docker-compose.yml (NEW)
Analog: RESEARCH.md § docker-compose.yml (lines 1055–1077). No in-repo analog.
Complete file:
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:
Matched defaults: POSTGRES_* env values here match application.conf localhost defaults exactly — running docker compose up -d postgres + ./gradlew :server:run works with zero additional env setup. Homelab deploy (Phase 11) uses a different compose file with real creds + Authentik alongside.
README.md (MODIFIED)
Analog: itself (lines 1–100).
Delta:
- DROP "Build and Run Web Application" JS sections (lines 77–85) — D-01 drops
jstarget; keep only thewasmJssection. - ADD a new "Local development" section documenting
docker compose up -d postgres+./gradlew :server:run+curl localhost:8080/health. - ADD mention of
./gradlew spotlessApplybefore commits (D-10 / D-13).
tools/verify-*.sh (NEW — Wave 0 gap shell scripts)
Analog: none — small bespoke shell scripts, RESEARCH.md § Wave 0 Gaps lines 1242–1256 describes behavior.
tools/verify-no-version-literals.sh — sketch:
#!/usr/bin/env bash
# Enforces INFRA-01 SC#2 / D-09: no literal version strings outside catalog.
set -e
VIOLATIONS=$(grep -rn 'version\s*=\s*"[0-9]' --include='*.gradle.kts' . | grep -v 'build-logic/build.gradle.kts' || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: version literals found outside catalog:"
echo "$VIOLATIONS"
exit 1
fi
echo "OK: no version literals outside catalog."
tools/verify-shared-pure.sh — sketch:
#!/usr/bin/env bash
# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight.
set -e
VIOLATIONS=$(grep -rn -E 'import\s+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: shared/commonMain has forbidden imports:"
echo "$VIOLATIONS"
exit 1
fi
echo "OK: shared/commonMain is pure."
tools/verify-ios-flags.sh — sketch:
#!/usr/bin/env bash
# Enforces INFRA-03 / D-18: iOS K/N flags present.
set -e
grep -q '^kotlin\.native\.binary\.gc=cms' gradle.properties || { echo "MISSING: kotlin.native.binary.gc=cms"; exit 1; }
grep -q '^kotlin\.native\.binary\.objcDisposeOnMain=false' gradle.properties || { echo "MISSING: kotlin.native.binary.objcDisposeOnMain=false"; exit 1; }
echo "OK: iOS binary flags present."
All three scripts are simple greps; no complex analog needed. Make executable (chmod +x) and optionally wire into ./gradlew check via a Exec task in recipe.quality.
Shared Patterns
Version catalog accessor inside precompiled plugins
Source: RESEARCH.md § Pattern 2 (lines 362–380), PITFALL #1 (lines 654–660).
Apply to: every .gradle.kts file under build-logic/src/main/kotlin/.
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
// Then:
implementation(libs.findLibrary("koin-core").get())
val kotlinVersion = libs.findVersion("kotlin").get().toString()
val minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
Anti-pattern: implementation(libs.koin.core) inside a precompiled plugin → unresolved reference compile error.
Quoted configuration names in precompiled-plugin dependencies { } blocks
Source: RESEARCH.md § Pattern 7 footnote (lines 603–605).
Apply to: recipe.jvm.server.gradle.kts and any future precompiled plugin that adds module dependencies.
dependencies {
"implementation"(libs.findLibrary("ktor-serverCore").get())
"testImplementation"(libs.findLibrary("ktor-serverTestHost").get())
}
Not this: implementation(libs.findLibrary(...).get()) — the unquoted form is a typed DSL method that only exists on module build scripts, not on precompiled plugins.
allWarningsAsErrors at extension level only
Source: D-11, PITFALL #3, PITFALL #7.
Apply to: recipe.kotlin.multiplatform, recipe.jvm.server, recipe.quality (as a safety net).
kotlin {
compilerOptions {
allWarningsAsErrors.set(true) // at kotlin { } extension level
}
// NOT inside androidTarget { compilerOptions { ... } } or jvm { compilerOptions { ... } }
}
Init order on every platform entry: configureLogging → initKoin → compose
Source: RESEARCH.md § Kermit bootstrap notes (line 948), PITFALLS #4 + #8.
Apply to: MainApplication.onCreate() (Android), KoinIos.doInitKoin() (iOS), main() (jvm + wasmJs).
configureLogging() // set Kermit tag first
initKoin() // Koin modules may log during load
// THEN composition entry: application { Window { App() } }
// OR ComposeViewport { App() }
// OR setContent { App() }
Anti-pattern: calling startKoin from inside a @Composable function — races with recomposition, panics.
iOS framework baseName consistency (ComposeApp / Shared)
Source: D-20, PITFALL #10.
Apply to: recipe.kotlin.multiplatform (default: "ComposeApp"), shared/build.gradle.kts override ("Shared"), iosApp/iosApp/iOSApp.swift import ComposeApp, iosApp/iosApp/ContentView.swift MainViewControllerKt.MainViewController().
Invariant: whatever string is set as baseName MUST match the import X in Swift, and the generated Swift class name is <KotlinFileName>Kt (e.g. KoinIos.kt → KoinIosKt, MainViewController.kt → MainViewControllerKt).
Catalog-only version hard rule (D-09 / INFRA-01 SC#2)
Source: D-09.
Apply to: every *.gradle.kts except build-logic/build.gradle.kts (which needs literal asDependency() version in coordinates).
Verification: grep -rn 'version\s*=\s*"[0-9]' --include='*.gradle.kts' . must return zero hits outside build-logic/build.gradle.kts.
Files with No Analog
These files are small bespoke shell scripts or structural placeholders; RESEARCH.md documents their intent but no other file in the repo has the same shape.
| File | Role | Data Flow | Reason |
|---|---|---|---|
tools/verify-no-version-literals.sh |
validator | test/grep | Bespoke grep pipeline; canonical form shown above |
tools/verify-shared-pure.sh |
validator | test/grep | Bespoke grep pipeline; canonical form shown above |
tools/verify-ios-flags.sh |
validator | test/grep | Bespoke grep pipeline; canonical form shown above |
server/src/main/resources/db/migration/.gitkeep |
empty dir marker | n/a | Convention; zero-byte file |
shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/.gitkeep |
empty dir marker | n/a | Convention; zero-byte file |
Metadata
Analog search scope:
/Users/rwilk/dev/repo/recipe/composeApp/(template Kotlin + resources)/Users/rwilk/dev/repo/recipe/shared/(template Kotlin)/Users/rwilk/dev/repo/recipe/server/(template Ktor + resources)/Users/rwilk/dev/repo/recipe/iosApp/(SwiftUI shell)/Users/rwilk/dev/repo/recipe/gradle/, root-level*.gradle.kts,gradle.properties,settings.gradle.kts,.gitignore/Users/rwilk/dev/repo/recipe/.planning/phases/01-.../01-RESEARCH.md§ Code Examples + § Architecture Patterns
Files scanned: ~30 (all existing sources in the refactor surface).
Pattern extraction date: 2026-04-24.
Confidence: HIGH for all in-repo-analog files (they are the exact files the executor will edit). HIGH for RESEARCH.md-canonical files — those excerpts were written with Phase 1 D-# decisions already applied. Residual risk is in two areas:
- Exact plugin/library versions to pin in catalog (bump to latest stable at plan time; RESEARCH.md notes this).
- Whether
shared/build.gradle.ktsretainscom.android.libraryor drops it (see RESEARCH.md § Open Questions; plan must decide — default: keep, sinceshared/currently usesnamespace = "dev.ulfrx.recipe.shared"for Android resources).
Not covered by this document (intentionally deferred): detekt, konsist, CI config, git hooks, compose-desktop packaging, js target, iosX64 target — all are in CONTEXT.md § Deferred Ideas.