Files
recipe/.planning/phases/01-project-infrastructure-module-wiring/01-PATTERNS.md
2026-04-24 16:21:25 +02:00

55 KiB
Raw Blame History

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.ktMainApplication.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 7771107)

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 7771107 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 314331) canonical
build-logic/build.gradle.kts NEW plugin buildscript config RESEARCH.md § Pattern 1 (lines 333358) canonical
build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts NEW precompiled script plugin config RESEARCH.md § Code Examples (lines 777835); current composeApp/build.gradle.kts kotlin { } block (lines 1371) role+flow match
build-logic/src/main/kotlin/recipe.compose.multiplatform.gradle.kts NEW precompiled script plugin config RESEARCH.md § Pattern 4 (lines 447477) canonical
build-logic/src/main/kotlin/recipe.android.application.gradle.kts NEW precompiled script plugin config RESEARCH.md § Pattern 6 (lines 516552); current composeApp/build.gradle.kts android { } block (lines 7398) canonical + repo mirror
build-logic/src/main/kotlin/recipe.jvm.server.gradle.kts NEW precompiled script plugin config RESEARCH.md § Pattern 7 (lines 558601); 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 483512) canonical
gradle/libs.versions.toml MODIFIED version catalog config itself (lines 153); add new [versions] + [libraries] + [plugins] entries for koin, kermit, spotless, flyway, postgres self
gradle.properties MODIFIED gradle daemon + K/N flags config itself (lines 110) + RESEARCH.md § gradle.properties (lines 10831102) self
settings.gradle.kts MODIFIED root settings config itself (lines 137); add includeBuild("build-logic") self
build.gradle.kts MODIFIED root build config itself (lines 112); 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 1114); rewrite plugins block to convention-plugin IDs, drop js { } (lines 3639), drop compose.desktop { nativeDistributions { ... } } packaging (lines 104114, per D-03) self
shared/build.gradle.kts MODIFIED module build config itself (lines 155); rewrite plugins block, drop js { } (lines 2527), 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 123); 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 840861) canonical
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt NEW DI module declaration config RESEARCH.md § Koin bootstrap (lines 852861) canonical
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt NEW logger bootstrap init-once RESEARCH.md § Kermit bootstrap (lines 933946) canonical
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt NEW platform bridge (Kotlin→Swift symbol) init-once RESEARCH.md § Koin bootstrap (lines 865870); 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 896911); sibling: MainActivity.kt (lines 119) canonical + sibling
composeApp/src/androidMain/AndroidManifest.xml MODIFIED Android manifest config itself (lines 122); add android:name=".MainApplication" to <application> tag self
composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt MODIFIED Desktop entry init-once itself (lines 113); add initKoin() + configureLogging() at top of main() self
composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt MODIFIED Wasm entry init-once itself (lines 110); 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 149); 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 111); add init() { KoinIosKt.doInitKoin() } (RESEARCH.md lines 874891) 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 120); rewrite per RESEARCH.md § Ktor /health (lines 952985) — 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 9881023) canonical
server/src/main/resources/application.conf NEW Ktor HOCON config config RESEARCH.md § application.conf (lines 10311051) 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 112); 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 120); 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 10531077) canonical
README.md MODIFIED dev docs n/a itself (lines 1100); 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 120); 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 314331). 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 333358).

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):

  1. RESEARCH.md § Code Examples (lines 777835) — the canonical form for this plugin.
  2. composeApp/build.gradle.kts lines 1371 — the current template's kotlin { } 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 789834 — 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 1371):

  • DROP the js { browser(); binaries.executable() } block (lines 3639) — 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 the kotlin { } 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 607618):

  • Do NOT re-declare org.jetbrains.kotlin.multiplatform in recipe.compose.multiplatform.gradle.kts — applying THIS plugin already applies it (PITFALL #2).
  • Do NOT open per-target compilerOptions { } to set allWarningsAsErrors — set it once at the kotlin { 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 447477). 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 5262:

  • The current file declares Compose deps in commonMain.dependencies — MOVE these into THIS plugin so shared/ 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 topshared/ applies only recipe.kotlin.multiplatform, so it stays Compose-free.


build-logic/src/main/kotlin/recipe.android.application.gradle.kts

Analogs:

  1. RESEARCH.md § Pattern 6 (lines 516552) — canonical form.
  2. composeApp/build.gradle.kts lines 7398 — the current template's android { } 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 7398):

  • Replace libs.versions.android.compileSdk.get().toInt() with libs.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:

  1. RESEARCH.md § Pattern 7 (lines 558601) — canonical form.
  2. server/build.gradle.kts lines 123 — 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 714 of current file) stays in the MODULE server/build.gradle.kts, not this plugin — per-module concern.
  • ADD Flyway + Postgres + ContentNegotiation + kotlinx-serialization deps (catalog entries to add).
  • Current server/build.gradle.kts uses libs.plugins.kotlinJvm / libs.plugins.ktor aliases — REPLACE with convention-plugin ID id("recipe.jvm.server").

Flyway caveat (PITFALL #6, RESEARCH.md lines 719724): 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 483512). 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 153).

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 110) + RESEARCH.md § gradle.properties (lines 10831102).

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 11041107): ./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 137).

Delta: add includeBuild("build-logic") in the right position.

PITFALL #9 (RESEARCH.md lines 749767): 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 112).

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 1114).

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 511) with convention-plugin IDs.
  • DROP the entire kotlin { androidTarget { ... } ... } block (lines 1371) — moved to recipe.kotlin.multiplatform. Keep only the per-module sourceSets { ... } overrides for androidMain / commonMain / jvmMain deps that are NOT shared.
  • DROP the android { } block (lines 7398) — moved to recipe.android.application.
  • DROP js { browser(); binaries.executable() } (lines 3639) — D-01.
  • DROP compose.desktop { application { ... nativeDistributions { ... } } } (lines 104114) — D-03 says no desktop packaging.

shared/build.gradle.kts (MODIFIED — apply conventions + explicitApi)

Analog: itself (lines 155).

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 155):

  • REPLACE plugins block (lines 47) with convention-plugin IDs; retain androidLibrary alias if Android namespace / resources are required (decision pending per RESEARCH.md Anti-Patterns §).
  • DROP entire kotlin { androidTarget { ... } ... } target block (lines 933) — moved to recipe.kotlin.multiplatform.
  • DROP js { browser() } (lines 2527) — 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 123).

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 123):

  • REPLACE plugins block (lines 15) with convention-plugin IDs.
  • DROP individual library implementations (lines 1622) — moved to recipe.jvm.server.
  • KEEP application { mainClass.set(...) } (lines 914) — 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 840850). 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 calls KoinIosKt.doInitKoin() which invokes configureLogging(); 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 852861).

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 935946).

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:

  1. RESEARCH.md § Koin bootstrap (lines 865870) — the canonical symbol.
  2. composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/MainViewController.kt (lines 15) — sibling showing the expect/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.ktKoinIosKt). 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:

  1. RESEARCH.md § Koin bootstrap (lines 896911) — canonical.
  2. composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt (lines 119) — 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 122).

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 113) + RESEARCH.md § Koin bootstrap (lines 918924).

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 110) + RESEARCH.md § Koin bootstrap (lines 927931); PITFALL #8 (lines 733747).

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 111) + RESEARCH.md § Koin bootstrap (lines 874891).

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 111):

  • 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 120) + RESEARCH.md § Ktor /health (lines 952985).

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 120):

  • DROP get("/") { call.respondText("Ktor: ${Greeting().greet()}") } — replaced by /health.
  • ADD install(ContentNegotiation) { json() } — required for @Serializable response.
  • 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_PORT constant continues to live in shared/commonMain/.../Constants.kt.

server/src/main/kotlin/dev/ulfrx/recipe/Database.kt (NEW)

Analog: RESEARCH.md § Database.kt (lines 9901023). No in-repo analog.

Important substitution (RESEARCH.md lines 10251027): 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 10311051). 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 692717): 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 120).

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 120):

  • DROP testRoot() (tests Greeting().greet() output).
  • ADD health endpoint returns 200 test.
  • CAVEAT: the current module() calls Database.migrate(this) which needs a real Postgres. Split the Ktor config: extract Application.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 10551077). 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 1100).

Delta:

  1. DROP "Build and Run Web Application" JS sections (lines 7785) — D-01 drops js target; keep only the wasmJs section.
  2. ADD a new "Local development" section documenting docker compose up -d postgres + ./gradlew :server:run + curl localhost:8080/health.
  3. ADD mention of ./gradlew spotlessApply before 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 12421256 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 362380), PITFALL #1 (lines 654660). 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 603605). 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.ktKoinIosKt, MainViewController.ktMainViewControllerKt).


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:

  1. Exact plugin/library versions to pin in catalog (bump to latest stable at plan time; RESEARCH.md notes this).
  2. Whether shared/build.gradle.kts retains com.android.library or drops it (see RESEARCH.md § Open Questions; plan must decide — default: keep, since shared/ currently uses namespace = "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.