Files
2026-04-29 21:07:49 +02:00

82 KiB
Raw Permalink Blame History

Phase 1: Project Infrastructure & Module Wiring - Research

Researched: 2026-04-24 Domain: Gradle build infrastructure for KMP + CMP + Ktor (iOS-primary, self-hosted server) Confidence: HIGH

Summary

Phase 1 is build-plumbing only — no feature logic, no auth, no domain tables. The risk is not "can we pick the right library?" (the stack is locked) but "will the plumbing we lay now be painful to unwind?" Four things have to be right on day 1: (a) iOS native binary flags (objcDisposeOnMain, gc=cms), (b) a clean split between the 5 convention plugins so shared/ never pulls Compose and the server never pulls Android, (c) a version-catalog-only dependency surface so grep-for-version-literals returns nothing, and (d) Ktor booting against Postgres with Flyway already scheduled to run in Phase 3 without refactor.

The single highest-leverage construct is the precompiled-script-plugin pattern in build-logic/ — fine-grained (per D-06) so each module's plugins { } block reads as a role declaration. Accessing the version catalog from inside a precompiled plugin requires a deliberate extension trick (extensions.getByType<VersionCatalogsExtension>().named("libs")) because the libs accessor is only auto-generated in module build scripts, not inside build-logic/. Everything else (Koin bootstrap, Kermit setup, /health route, docker-compose) is pattern-matching against well-documented idioms.

Primary recommendation: Build recipe.quality first (smallest, testable in isolation), then recipe.kotlin.multiplatform (the dependency root), then the three leaf plugins (compose.multiplatform, android.application, jvm.server) in parallel. Wire each module's build.gradle.kts only after its convention plugins are green. Gate Phase 1 completion on ./gradlew build producing an Android APK + iOS framework + server fat JAR with zero version literals outside the catalog.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Target matrix

  • D-01: Drop the js target from composeApp and shared. Keep wasmJs as the strategic future-web bet.
  • D-02: Skip iosX64. User is on Apple Silicon; no Intel-Mac contributors anticipated.
  • D-03: Keep jvm target in composeApp for Desktop — as a dev tool only (hot-reload). No Compose Desktop packaging; not a release surface.
  • D-04: shared/ ships the same target set as composeApp: androidTarget, iosArm64, iosSimulatorArm64, jvm, wasmJs. Plus jvm covers the server dependency.
  • D-05: Final target matrix repo-wide: androidTarget, iosArm64, iosSimulatorArm64, jvm (Desktop + Server), wasmJs.

Convention plugins (build-logic/)

  • D-06: Fine-grained plugin split (5 plugins): recipe.kotlin.multiplatform, recipe.compose.multiplatform, recipe.android.application, recipe.jvm.server, recipe.quality.
  • D-07: recipe.kotlin.multiplatform locks in the D-05 target set, JVM toolchain, framework basename convention (ComposeApp / Shared), and kotlin-test as a common-test dep.
  • D-08: JVM toolchain is JVM 21 for server, desktop, and shared/jvm. Android bytecode target stays JVM 11 (Android 7 minSdk constraint per template). Document this split in convention plugin comments.
  • D-09: All library versions live in gradle/libs.versions.toml. Hard rule: grep for a non-test version literal inside any build.gradle.kts returns zero matches. Plugin versions also routed through the catalog.

Code-quality toolchain

  • D-10: Minimal baseline — ktlint via Spotless only. Spotless handles Kotlin + Gradle files + markdown. Commands: ./gradlew spotlessCheck, ./gradlew spotlessApply. No Detekt, no Konsist in Phase 1.
  • D-11: allWarningsAsErrors = true everywhere (configured in recipe.kotlin.multiplatform).
  • D-12: explicitApi() strict on shared/ only. Configured in shared/build.gradle.kts directly, not in the KMP plugin.
  • D-13: No git hooks. ./gradlew check is the local gate; CI gate deferred to Phase 11.

Phase 1 scope beyond the template

  • D-14: Koin bootstrap. Add Koin deps (koin-core, koin-compose, koin-compose-viewmodel) via recipe.kotlin.multiplatform. Ship an empty appModule in composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt.
  • D-15: Kermit logger bootstrap. Set a single top-level tag ("recipe") during app init.
  • D-16: Server: /health endpoint + Flyway scaffold + Postgres conn config. GET /health returns 200 with trivial JSON body. Flyway Gradle plugin + runtime dep wired; src/main/resources/db/migration/ created empty. application.conf reads DATABASE_URL, DATABASE_USER, DATABASE_PASSWORD from env with localhost defaults. Server fails loudly if Postgres unreachable.
  • D-17: docker-compose.yml at repo root defines postgres:16 service with named volume. README gets a "Local development" section.

Infrastructure hygiene

  • D-18: iOS binary flags: kotlin.native.binary.objcDisposeOnMain=false and kotlin.native.binary.gc=cms in gradle.properties.
  • D-19: shared/commonMain stays pure: domain models + @Serializable DTOs only; no Ktor, Compose, or SQLDelight imports. Phase 1 ships an empty package scaffold under dev.ulfrx.recipe.shared.
  • D-20: Namespace dev.ulfrx.recipe. Framework basename ComposeApp for iOS. No feature modules in v1.

Claude's Discretion

  • Exact ordering of plugin application inside each build.gradle.kts.
  • Specific spotless { kotlin { ktlint(...) } } ruleset version (pick latest stable from catalog).
  • Whether application.conf or ApplicationConfig.kt code owns env-var parsing.
  • Flyway cleanDisabled and baselineOnMigrate flag choices (use sane defaults for dev).
  • Whether Koin bootstrap in MainViewController uses KoinApplication vs startKoin (iOS-specific idiom).
  • Whether docker-compose.yml uses a .env file or inlines localhost defaults.
  • The exact sentinel JSON body for /health (empty object is fine).

Deferred Ideas (OUT OF SCOPE)

  • Detekt — add only if code review starts missing bugs that Detekt would catch.
  • Konsist — revisit ~Phase 4.
  • CI pipeline — Phase 11.
  • Git hooks — explicitly rejected.
  • explicitApi for composeApp/server — rejected (app code, not library).
  • iosX64 target — rejected.
  • js target — rejected.
  • Compose Desktop packaging (dmg/msi/exe) — out of scope entirely. </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
INFRA-01 Gradle version catalog (gradle/libs.versions.toml) is the single source of truth for library versions §Standard Stack table + version-lookup patterns for libs.versions.X.get().toInt() and libs.plugins.X.get().pluginId
INFRA-02 build-logic/ convention plugins centralize Kotlin/Compose/test configuration across modules §Convention Plugin Mechanics + §Architecture Patterns with full skeleton for all 5 plugins
INFRA-03 iOS Kotlin/Native binary options set from day 1: kotlin.native.binary.objcDisposeOnMain=false, gc=cms §iOS Binary Flags with exact property keys, rationale, and verification procedure
INFRA-06 shared/commonMain contains only domain models + API DTOs — no UI, no HTTP, no DB code §Pattern: Shared module as a pure-Kotlin library + Anti-Patterns + verification grep
</phase_requirements>

Project Constraints (from CLAUDE.md)

The following CLAUDE.md conventions are load-bearing for Phase 1 and must be honored by all plans:

  • #5 Exposed DSL only, never DAO. Phase 1 doesn't wire Exposed at all, but the recipe.jvm.server plugin must not accidentally pull exposed-dao transitively (it won't if Exposed isn't added in Phase 1 — verified below).
  • #6 newSuspendedTransaction for every coroutine-touching handler. Not applicable in Phase 1 (no DB operations yet) but the server plugin must not preclude it.
  • #7 iOS binary flags on day 1. Directly addressed by D-18 / §iOS Binary Flags.
  • #8 shared/commonMain stays light. Directly addressed by D-19 / INFRA-06.
  • #9 Strings externalized from day 1. Phase 1 only needs the composeApp module to have Compose Resources wired (already present from template — compose.components.resources in commonMain deps). Real copy lands in Phase 11.

Architectural Responsibility Map

Phase 1 ships infrastructure — not features — so the "tier ownership" exercise is about where each concern's configuration lives, not where business logic runs:

Capability Primary Tier Secondary Tier Rationale
Target matrix (KMP) recipe.kotlin.multiplatform plugin module build.gradle.kts Plugin defines targets; module applies plugin; target-specific source sets live in the module.
Compose runtime wiring recipe.compose.multiplatform plugin composeApp/build.gradle.kts only Server + shared must NOT get Compose; this plugin applies only to composeApp.
Android app shell recipe.android.application plugin composeApp/build.gradle.kts only Namespace, SDK versions, application ID; shared is a KMP library (no android-application).
Server JVM + Ktor + Flyway recipe.jvm.server plugin server/build.gradle.kts only Applies kotlin("jvm"), Ktor plugin, Flyway plugin; pulls only server-side deps.
Code quality (ktlint, warnings-as-errors) recipe.quality plugin every module Cross-cutting; must be reusable across KMP/Android/JVM modules without breakage.
iOS native runtime flags gradle.properties (project root) Global K/N compiler options; cannot be set per-module.
Version constraints gradle/libs.versions.toml Single source of truth (D-09). Modules and convention plugins read from here.
Koin DI container composeApp (di/AppModule.kt, App(), MainViewController) Client-side DI only in Phase 1; server DI added in Phase 2+.
Kermit logger composeApp init path shared/ (usage later) Client-side logger; server uses Logback (already wired).
Ktor server module + /health server/src/main/kotlin/.../Application.kt application.conf Routing lives in Kotlin; boot config in HOCON.
Database connection config application.conf (HOCON env vars) ApplicationConfig.kt reader Env wins; HOCON provides localhost defaults matching docker-compose.
Local Postgres docker-compose.yml (repo root) .env file optional Dev ergonomics only; homelab deploy is Phase 11.

Standard Stack

Core (already in catalog — versions below are what's pinned in libs.versions.toml)

Library Version Purpose Why Standard
Kotlin 2.3.20 Compiler / stdlib [CITED: libs.versions.toml] — locked per PROJECT.md
AGP 8.11.2 Android build [CITED: libs.versions.toml] — template default
Compose Multiplatform 1.10.3 UI framework [CITED: libs.versions.toml]
Compose Hot Reload 1.0.0 Desktop dev iteration [CITED: libs.versions.toml] — preserve existing wiring (commit c50d747)
Ktor 3.4.1 Server + Client [CITED: libs.versions.toml] — locked per PROJECT.md
Kotlinx Coroutines 1.10.2 Async primitives [CITED: libs.versions.toml]
Logback 1.5.32 Server logging [CITED: libs.versions.toml] — already wired

New additions for Phase 1 (to be added to catalog)

Library Version Purpose Source
Koin (BOM) 4.2.1 DI container [VERIFIED: central.sonatype.com] — latest stable Apr 2026, supports Kotlin 2.3.x
Koin Compose Viewmodel (via BOM) CMP ViewModel integration [CITED: Koin KMP setup docs] — required for koinViewModel() with Jetpack Nav CMP
Kermit 2.1.0 KMP logger [VERIFIED: github.com/touchlab/Kermit/releases] — latest stable Mar 2025
Spotless plugin 8.4.0 Formatter harness [VERIFIED: web search 2026-03] — requires JRE 17+ (we use 21, fine)
Flyway plugin 12.4.0 DB migration runner [VERIFIED: plugins.gradle.org 2026-04-14] — standard ID org.flywaydb.flyway
Flyway core 12.4.0 Runtime migrator [VERIFIED: central.sonatype.com] — match plugin version
Flyway postgresql 12.4.0 Postgres dialect [VERIFIED: Flyway docs] — required for Postgres 15+ support
PostgreSQL JDBC 42.7.10 JDBC driver (runtime) [VERIFIED: mvnrepository.com] — current stable; Flyway pulls indirectly but we pin explicitly
kotlinx-serialization-json 1.7.3+ Ktor JSON content-negotiation [VERIFIED: via ktor-serialization-kotlinx-json coords] — version bundled with Ktor 3.4.1
ktor-server-content-negotiation 3.4.1 Content negotiation plugin [VERIFIED: same as ktor version] — required for /health JSON
ktor-serialization-kotlinx-json 3.4.1 JSON serializer for Ktor [VERIFIED: same as ktor version]
Postgres JDBC test driver 42.7.10 Server integration tests (Phase 3+) Deferred — not required in Phase 1

Alternatives considered:

Instead of Could Use Why not for v1
Spotless + ktlint ktlint standalone plugin (jlleitschuh.gradle.ktlint) Spotless covers Kotlin + Gradle KTS + markdown in one plugin; simpler config surface
Koin KoinApplication composable startKoin { } at app entry See §Koin Bootstrap below; both work but startKoin via top-level initKoin() is the Koin-docs-canonical pattern for KMP
Flyway via programmatic API only (no Gradle plugin) Kept the Gradle plugin anyway Plugin gives ./gradlew flywayInfo / flywayMigrate for ops ergonomics; runtime Flyway.configure().load().migrate() still drives boot-time migration
ktor-server-auto-head / ktor-server-call-logging skip in Phase 1 Not needed for a single /health route; added incrementally in later phases

Installation (new catalog entries — illustrative TOML fragments):

[versions]
koin = "4.2.1"
kermit = "2.1.0"
spotless = "8.4.0"
flyway = "12.4.0"
postgresql = "42.7.10"
kotlinx-serialization = "1.7.3"

[libraries]
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-composeViewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
ktor-serverContentNegotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" }
ktor-serializationKotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" }
ktor-serverConfigYaml = { module = "io.ktor:ktor-server-config-yaml-jvm", version.ref = "ktor" } # only if HOCON→YAML is preferred; see §Ktor config
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }

[plugins]
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }

Version verification commands (Wave 0 — before locking):

# Verify each added version against current registry
curl -s 'https://repo1.maven.org/maven2/io/insert-koin/koin-bom/' | grep 'href="4\.'
curl -s 'https://repo1.maven.org/maven2/co/touchlab/kermit/' | grep 'href="2\.'
curl -s 'https://plugins.gradle.org/m2/com/diffplug/spotless/com.diffplug.spotless.gradle.plugin/' | grep 'href'
curl -s 'https://plugins.gradle.org/m2/org/flywaydb/flyway/org.flywaydb.flyway.gradle.plugin/' | grep 'href'

If any version is newer than what's listed above, use the newer stable and update this table.

Architecture Patterns

System Architecture Diagram

┌──────────────────────────────────────────────────────────────────┐
│  gradle/libs.versions.toml  (single source of truth, D-09)       │
└──────────────────────────────────────────────────────────────────┘
                               │
                               ▼ read by
┌──────────────────────────────────────────────────────────────────┐
│  build-logic/  (included build: includeBuild("build-logic"))     │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │ recipe.quality.gradle.kts          (Spotless + ktlint,     │  │
│  │                                     allWarningsAsErrors     │  │
│  │                                     — reusable everywhere)  │  │
│  └────────────────────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │ recipe.kotlin.multiplatform.gradle.kts                     │  │
│  │   Targets: androidTarget, iosArm64, iosSimulatorArm64,     │  │
│  │            jvm, wasmJs.  JVM toolchain 21.                 │  │
│  │   Deps:  Koin BOM + koin-core (commonMain)                 │  │
│  │          Kermit (commonMain)                                │  │
│  │          kotlin-test (commonTest)                           │  │
│  └────────────────────────────────────────────────────────────┘  │
│       │                             │                            │
│       ▼                             ▼                            │
│  ┌────────────┐       ┌───────────────────────────┐              │
│  │ recipe.    │       │ recipe.compose.            │              │
│  │ android.   │       │ multiplatform.gradle.kts   │              │
│  │ app        │       │  (Compose plugin +         │              │
│  └────────────┘       │   hot-reload +             │              │
│                       │   compose deps commonMain) │              │
│                       └───────────────────────────┘              │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │ recipe.jvm.server.gradle.kts                                │  │
│  │   kotlin("jvm") + Ktor plugin + Flyway plugin               │  │
│  │   Ktor server-core / netty / content-negotiation / json    │  │
│  │   Flyway core / postgresql + PostgreSQL JDBC driver        │  │
│  └────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘
                               │ applied by
                               ▼
┌────────────────┐  ┌────────────────┐  ┌────────────────┐
│ composeApp/    │  │ shared/        │  │ server/        │
│ plugins {       │  │ plugins {       │  │ plugins {       │
│   id("recipe.   │  │   id("recipe.   │  │   id("recipe.   │
│     kotlin.mp") │  │     kotlin.mp") │  │     jvm.server")│
│   id("recipe.   │  │   id("recipe.   │  │   id("recipe.   │
│     compose.mp")│  │     quality")   │  │     quality")   │
│   id("recipe.   │  │ }               │  │ }               │
│     android.app")│ │ kotlin{         │  │ (no Compose,    │
│   id("recipe.   │  │  explicitApi()  │  │   no Android)   │
│     quality")   │  │ }               │  │                 │
│ }               │  └────────────────┘  └────────────────┘
└────────────────┘
                                                │
                                                ▼ boots
                                      ┌──────────────────────┐
                                      │ ApplicationKt.main() │
                                      │  → Flyway.migrate()  │ ◄── fails loudly
                                      │  → /health (JSON)    │     if Postgres
                                      │  → Netty :8080       │     unreachable
                                      └──────────────────────┘
                                                │
                                                ▼
                                      ┌──────────────────────┐
                                      │ docker-compose.yml   │
                                      │   postgres:16        │
                                      │   volume: pgdata     │
                                      │   :5432              │
                                      └──────────────────────┘
recipe/
├── composeApp/
│   └── src/commonMain/kotlin/dev/ulfrx/recipe/
│       ├── App.kt                    # (exists) — add startKoin call
│       ├── di/
│       │   └── AppModule.kt          # NEW — empty module placeholder
│       └── logging/
│           └── Logging.kt            # NEW — `Logger.setTag("recipe")`
├── iosApp/iosApp/
│   ├── iOSApp.swift                  # MODIFY — call KoinKt.doInitKoin()
│   └── ContentView.swift             # (exists) — no change
├── server/
│   ├── build.gradle.kts              # REWRITE — applies recipe.jvm.server + quality
│   └── src/main/
│       ├── kotlin/dev/ulfrx/recipe/
│       │   ├── Application.kt        # MODIFY — install ContentNegotiation, /health, Flyway boot
│       │   └── Database.kt           # NEW — reads ApplicationConfig, runs Flyway.migrate()
│       └── resources/
│           ├── application.conf      # NEW — HOCON with ${?DATABASE_URL} env overrides
│           ├── logback.xml           # (exists)
│           └── db/migration/         # NEW — empty dir, .gitkeep
├── shared/
│   ├── build.gradle.kts              # REWRITE — recipe.kotlin.mp + explicitApi() + quality
│   └── src/commonMain/kotlin/dev/ulfrx/recipe/shared/   # NEW pkg (empty)
├── build-logic/                      # NEW — included build
│   ├── settings.gradle.kts           # NEW
│   ├── build.gradle.kts              # NEW — kotlin-dsl, kotlinGradlePlugin, agp, spotless-plugin as compileOnly
│   └── src/main/kotlin/
│       ├── recipe.quality.gradle.kts                # NEW
│       ├── recipe.kotlin.multiplatform.gradle.kts   # NEW
│       ├── recipe.compose.multiplatform.gradle.kts  # NEW
│       ├── recipe.android.application.gradle.kts    # NEW
│       └── recipe.jvm.server.gradle.kts             # NEW
├── gradle/
│   └── libs.versions.toml            # EXTEND — add Koin/Kermit/Spotless/Flyway/Postgres
├── gradle.properties                 # EXTEND — append 2 iOS binary flags
├── settings.gradle.kts               # EXTEND — add includeBuild("build-logic")
├── docker-compose.yml                # NEW — postgres:16 service
├── .env.example                      # NEW (if D-17 .env route chosen)
└── README.md                         # EXTEND — "Local development" section

Pattern 1: build-logic/ as an included build (not a subproject)

What: build-logic/ is its own Gradle build, composed into the main build via includeBuild("build-logic") in settings.gradle.kts. Plugins are written as precompiled script plugins: a Kotlin file named foo.bar.gradle.kts under build-logic/src/main/kotlin/ automatically becomes a plugin with ID foo.bar.

When to use: Always for multi-module Kotlin/Android/KMP projects. Prefer precompiled .gradle.kts plugins over full Plugin<Project> classes unless you need parameterization — the DSL is identical to a build.gradle.kts, so your mental model transfers.

Example — build-logic/settings.gradle.kts [CITED: gradle best practices, VersionCatalogSample]:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"

Example — build-logic/build.gradle.kts:

plugins {
    `kotlin-dsl`
}

dependencies {
    // These must be on the buildscript classpath so precompiled plugins
    // can use `plugins { id("...") }` for alias-based IDs.
    compileOnly(libs.plugins.kotlinMultiplatform.asDependency())
    compileOnly(libs.plugins.androidApplication.asDependency())
    compileOnly(libs.plugins.androidLibrary.asDependency())
    compileOnly(libs.plugins.composeMultiplatform.asDependency())
    compileOnly(libs.plugins.composeCompiler.asDependency())
    compileOnly(libs.plugins.composeHotReload.asDependency())
    compileOnly(libs.plugins.kotlinJvm.asDependency())
    compileOnly(libs.plugins.ktor.asDependency())
    compileOnly(libs.plugins.spotless.asDependency())
    compileOnly(libs.plugins.flywayPlugin.asDependency())
}

// Helper extension because plugin catalog entries have .pluginId but not .asDependency() directly
fun Provider<PluginDependency>.asDependency(): Provider<String> =
    map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version.requiredVersion}" }

Note: the asDependency() trick maps a catalog plugin entry to its marker artifact coordinates so compileOnly() can resolve it on the buildscript classpath. This is how precompiled plugins can write plugins { id("org.jetbrains.kotlin.multiplatform") } without an explicit version.

Pattern 2: Accessing the version catalog from inside a precompiled plugin

What: The libs.xxx.yyy accessor is auto-generated only in module build scripts, not in precompiled plugins under build-logic/. Inside a precompiled plugin you must look up the catalog explicitly. [CITED: Gradle docs — Using a catalog in buildSrc]

Example — top of every precompiled plugin that reads versions:

import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

// Now read versions/libraries/plugins:
val kotlinVersion = libs.findVersion("kotlin").get().toString()
val koinBom = libs.findLibrary("koin-bom").get()
val minSdk = libs.findVersion("android-minSdk").get().toString().toInt()

Anti-pattern: Writing implementation(libs.koin.core) inside a precompiled plugin — does not compile. Use implementation(libs.findLibrary("koin-core").get()) instead.

Pattern 3: Applying another plugin by ID from inside a precompiled plugin

What: Inside a precompiled .gradle.kts you can write a normal plugins { id("...") } block, but only for plugin IDs whose markers are on the buildscript classpath via build-logic/build.gradle.kts (Pattern 1). Use bare string IDs — catalog accessors (libs.plugins.X) are NOT available in precompiled plugins, same constraint as Pattern 2.

Example — recipe.kotlin.multiplatform.gradle.kts (excerpt):

plugins {
    id("org.jetbrains.kotlin.multiplatform")
}

import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

kotlin {
    jvmToolchain(21)

    androidTarget {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)  // Android bytecode only (D-08)
        }
    }

    listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "ComposeApp"  // D-20; shared overrides to "Shared"
            isStatic = true
        }
    }

    jvm {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_21)  // Server + Desktop (D-08)
        }
    }

    @OptIn(ExperimentalWasmDsl::class)
    wasmJs { browser() }

    // D-11: warnings as errors at extension level
    compilerOptions {
        allWarningsAsErrors.set(true)
    }

    sourceSets {
        commonMain.dependencies {
            implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get()))
            implementation(libs.findLibrary("koin-core").get())
            implementation(libs.findLibrary("kermit").get())
        }
        commonTest.dependencies {
            implementation(libs.findLibrary("kotlin-test").get())
        }
    }
}

Pattern 4: Layering compose on top of kotlin-multiplatform

What: recipe.compose.multiplatform applies itself on top of recipe.kotlin.multiplatform — it does NOT re-declare the KMP plugin, because a precompiled plugin applying another precompiled plugin is supported, but double-applying causes errors. shared/ does not apply recipe.compose.multiplatform, so it never pulls Compose.

Example — recipe.compose.multiplatform.gradle.kts:

plugins {
    id("recipe.kotlin.multiplatform")            // assumes kotlin-mp is already configured
    id("org.jetbrains.compose")
    id("org.jetbrains.kotlin.plugin.compose")
    id("org.jetbrains.compose.hot-reload")        // preserve commit c50d747 wiring
}

import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.findLibrary("compose-runtime").get())
            implementation(libs.findLibrary("compose-foundation").get())
            implementation(libs.findLibrary("compose-material3").get())
            implementation(libs.findLibrary("compose-ui").get())
            implementation(libs.findLibrary("compose-components-resources").get())
            implementation(libs.findLibrary("androidx-lifecycle-viewmodelCompose").get())
            implementation(libs.findLibrary("androidx-lifecycle-runtimeCompose").get())
            implementation(libs.findLibrary("koin-compose").get())
            implementation(libs.findLibrary("koin-composeViewmodel").get())
        }
    }
}

Why separate: If Compose deps were inside recipe.kotlin.multiplatform, shared/ would pull Compose — violating INFRA-06 / D-19.

Pattern 5: recipe.quality as the cross-cutting plugin

plugins {
    id("com.diffplug.spotless")
}

spotless {
    kotlin {
        target("src/**/*.kt")
        targetExclude("**/build/**", "**/generated/**")
        ktlint()           // pick up default version; bump via `.ktlint("1.x.y")` if needed
    }
    kotlinGradle {
        target("*.gradle.kts")
        ktlint()
    }
    format("markdown") {
        target("*.md", "docs/**/*.md")
        endWithNewline()
        trimTrailingWhitespace()
    }
}

// D-11 redundancy guard: if a module applies recipe.quality WITHOUT recipe.kotlin.multiplatform
// (e.g. a future pure-JVM utility), ensure allWarningsAsErrors still applies:
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
    compilerOptions {
        allWarningsAsErrors.set(true)
    }
}

Pattern 6: recipe.android.application — applied ONLY to composeApp

plugins {
    id("com.android.application")
}

import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

android {
    namespace = "dev.ulfrx.recipe"
    compileSdk = libs.findVersion("android-compileSdk").get().toString().toInt()

    defaultConfig {
        applicationId = "dev.ulfrx.recipe"
        minSdk = libs.findVersion("android-minSdk").get().toString().toInt()
        targetSdk = libs.findVersion("android-targetSdk").get().toString().toInt()
        versionCode = 1
        versionName = "1.0"
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
    buildTypes {
        getByName("release") {
            isMinifyEnabled = false
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}

Anti-pattern: Applying this to shared/. shared/ is a KMP library — if it ever needs Android, it should apply com.android.library (and we'd build a recipe.android.library plugin). Phase 1 does not require this; shared/ builds android-less through KMP's androidTarget only when a consumer (composeApp) applies the android-application plugin. (Note: the current template's shared/build.gradle.kts DOES apply com.android.library directly. Verify whether this is still needed after the refactor; if the shared Android target compiles via KMP + androidTarget alone, we can drop the android-library plugin. See §Open Questions.)

Pattern 7: recipe.jvm.server — Ktor + Flyway, no Compose/Android

plugins {
    id("org.jetbrains.kotlin.jvm")
    id("io.ktor.plugin")
    id("org.flywaydb.flyway")
    application
}

import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

kotlin {
    jvmToolchain(21)
    compilerOptions {
        allWarningsAsErrors.set(true)
    }
}

dependencies {
    "implementation"(libs.findLibrary("ktor-serverCore").get())
    "implementation"(libs.findLibrary("ktor-serverNetty").get())
    "implementation"(libs.findLibrary("ktor-serverContentNegotiation").get())
    "implementation"(libs.findLibrary("ktor-serializationKotlinxJson").get())
    "implementation"(libs.findLibrary("logback").get())
    "implementation"(libs.findLibrary("flyway-core").get())
    "implementation"(libs.findLibrary("flyway-database-postgresql").get())
    "implementation"(libs.findLibrary("postgresql").get())
    "testImplementation"(libs.findLibrary("ktor-serverTestHost").get())
    "testImplementation"(libs.findLibrary("kotlin-testJunit").get())
}

// Flyway plugin config — dev ergonomics only; runtime uses Flyway Java API in Application.kt
flyway {
    url = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/recipe"
    user = System.getenv("DATABASE_USER") ?: "recipe"
    password = System.getenv("DATABASE_PASSWORD") ?: "recipe"
    locations = arrayOf("classpath:db/migration")
    cleanDisabled = true          // safety: no accidental `flywayClean` in dev
    baselineOnMigrate = true      // tolerate an existing DB with no Flyway history
    validateOnMigrate = true
}

Note on dependencies { "implementation"(...) }: inside precompiled plugins the named configurations aren't statically typed, so you must quote them. (In module build.gradle.kts files, implementation(...) is a typed method from the plugin's extension.)

Quoted configurations are a common footgun — plan-checkers should verify these compile before Phase 1 sign-off.

Anti-Patterns to Avoid

  • Putting Compose plugin ID inside recipe.kotlin.multiplatform: breaks shared/ (no Compose). Instead, layer recipe.compose.multiplatform on top (Pattern 4).
  • Applying recipe.android.application to shared/: shared/ is a library, not an app. The current template applies com.android.library directly in shared/build.gradle.kts — we may or may not need to keep that after refactor (see Open Questions).
  • Calling startKoin inside a @Composable: composition-timed side effects race with UI rendering and panic on recomposition. Call at app entry, before composition starts (Android: Application.onCreate(); iOS: iOSApp.init(); Desktop: top of main() before application { }).
  • Adding Ktor Client, Compose, or SQLDelight deps to shared/commonMain: violates D-19 / INFRA-06. Only kotlinx-serialization + kotlinx-datetime are allowed non-stdlib deps in shared/. (Phase 1 adds neither yet — shared/commonMain is truly empty beyond the placeholder package.)
  • Configuring allWarningsAsErrors via kotlinOptions {}: deprecated in Kotlin 2.2+ (removed in 2.3). Use compilerOptions { allWarningsAsErrors.set(true) } at the kotlin { } extension level. [CITED: kotlinlang.org/docs/gradle-compiler-options.html]
  • Using deprecated js() target: D-01 explicitly drops it; current composeApp and shared still reference it and must be removed. (Current files confirm js { browser() } blocks exist.)
  • Referencing iosX64(): D-02 skips it; the current template doesn't reference it (verified in composeApp/build.gradle.kts and shared/build.gradle.kts), so this is a "don't add" guideline.
  • Calling startKoin twice on iOS: if iOSApp.init() calls doInitKoin() AND MainViewController also calls startKoin, the second throws KoinApplicationAlreadyStartedException. Pick one call site (recommendation: iOSApp.init() — see §Koin Bootstrap).
  • Using the Flyway Gradle plugin for runtime migration at server boot: the plugin is for ops ergonomics (CLI); runtime migration uses Flyway.configure().dataSource(...).load().migrate() in Database.kt. Mixing the two leads to "why didn't my migration run on boot?" debugging.
  • Using transaction {} in a coroutine / suspend context: PITFALLS.md #5. Phase 1 doesn't touch DB yet, but the recipe.jvm.server plugin must not preclude using newSuspendedTransaction later — verify by NOT adding exposed-dao deps in Phase 1 (we don't; Exposed isn't added at all until Phase 3).

Don't Hand-Roll

Problem Don't Build Use Instead Why
Multi-module Gradle conventions Scripted copy-paste of kotlin { } blocks build-logic/ + precompiled .gradle.kts plugins One file per module, DSL identical to build.gradle.kts, full IDE support
Version management Hardcoded versions in each module gradle/libs.versions.toml + catalog accessors Single source of truth (INFRA-01 hard rule)
Kotlin formatter/linter harness Script ktlint as a Exec task Spotless plugin Handles multi-file, editor-config, caching, spotlessApply fix command
DB migration runner Hand-rolled version table + SQL runner Flyway (core + postgres dialect + plugin) Industry standard; auto-apply on boot; repair command for corruption
KMP DI container Service-locator singletons Koin Explicit graph, koinViewModel() integration with Jetpack Nav CMP, no codegen
KMP logger println + NSLog via expect/actual Kermit Platform-correct defaults; withTag; format control; same API everywhere
Plugin classpath for build-logic Manually adding kgp/agp as buildscript classpath compileOnly(libs.plugins.X.asDependency()) in build-logic/build.gradle.kts Plugin markers resolve the real JARs; version stays in catalog
Postgres local dev "install Postgres via brew" instructions docker-compose up -d Works identically on macOS/Linux; clean teardown; version-pinned
iOS K/N GC tuning Custom finalizer plumbing in Koin modules Set objcDisposeOnMain=false, gc=cms in gradle.properties Addresses PITFALL #1 once, for all iOS code in the repo

Key insight: every item above is "the plumbing everyone eventually rebuilds badly; starting with the library means you get the edge-case handling for free."

Runtime State Inventory

Phase 1 scaffolds infrastructure; it does not rename or migrate. Still, because it modifies the template, I audit what persists:

Category Items Found Action Required
Stored data None — no DB tables exist yet; Postgres volume (pgdata) is created by docker-compose on first boot but carries no meaningful state until Phase 3. None. Developers can docker compose down -v freely during Phase 1.
Live service config None — the repo has no deployed services yet. Authentik is on user's homelab but untouched by Phase 1. None.
OS-registered state None — no Windows Task Scheduler / launchd / pm2 involvement. Hot-reload is a ./gradlew :composeApp:jvmRun -DmainClass=... --no-daemon invocation, nothing persistent. None.
Secrets/env vars DATABASE_URL, DATABASE_USER, DATABASE_PASSWORD — introduced in Phase 1 via application.conf. Not secret in dev (localhost Postgres via docker-compose). No homelab secrets in Phase 1. Document defaults in README "Local development". Real secrets Phase 11 (homelab deploy).
Build artifacts / installed packages ~/.gradle/caches pulls new artifacts (Koin, Kermit, Spotless, Flyway, Postgres JDBC). First ./gradlew build after Phase 1 will download ~80 MB. Existing build/ and composeApp/build/Kotlin/ caches from the template may contain stale js target outputs (D-01 removes this target). Developers should ./gradlew clean once after Phase 1 to flush stale js/ target outputs. Document this in the migration note.

Canonical question — After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?

Answer for Phase 1: Build caches only. A single ./gradlew clean resolves it. No external systems are affected.

Common Pitfalls

Pitfall 1: precompiled plugin can't see libs.xxx accessor

What goes wrong: You write implementation(libs.koin.core) inside a build-logic/src/main/kotlin/recipe.foo.gradle.kts file — compile error: "Unresolved reference: libs". Why it happens: Type-safe catalog accessors are generated only for module build scripts, not for precompiled plugins. [CITED: Gradle docs — Using a catalog in buildSrc] How to avoid: Use the explicit lookup pattern (extensions.getByType<VersionCatalogsExtension>().named("libs").findLibrary("koin-core").get()). Warning signs: First red squiggle when you start writing a convention plugin.

Pitfall 2: double-applying KMP plugin

What goes wrong: recipe.compose.multiplatform.gradle.kts applies both id("org.jetbrains.kotlin.multiplatform") and id("recipe.kotlin.multiplatform") — Gradle error "Plugin X already applied". Why it happens: Forgetting that recipe.kotlin.multiplatform already applies KMP. How to avoid: Compose plugin applies only id("recipe.kotlin.multiplatform") (which internally applies KMP) plus the Compose-specific plugins. See Pattern 4. Warning signs: Clear error message; easy to fix once seen.

Pitfall 3: forgot allWarningsAsErrors on a specific compilation

What goes wrong: Setting compilerOptions { allWarningsAsErrors = true } at the kotlin { } extension level covers common, but a per-target override (e.g. androidTarget { compilerOptions { ... } }) can silently mask it. Why it happens: Kotlin 2.x DSL inherits options top-down but any child compilerOptions { } block creates its own scope. How to avoid: Set allWarningsAsErrors.set(true) at the kotlin { compilerOptions { } } extension level only; don't re-open per-target compilerOptions { } blocks unless setting target-specific things (like jvmTarget). The recipe.quality plugin also has a tasks.withType<KotlinCompilationTask<*>>().configureEach { ... } safety net. Warning signs: Build passes despite a deprecation warning in some sourceSet.

Pitfall 4: Koin startKoin called twice on iOS

What goes wrong: iOSApp.swift calls KoinKt.doInitKoin(), THEN MainViewController() calls startKoin { modules(appModule) } again → KoinApplicationAlreadyStartedException on second app launch (or on cold iOS re-entry). Why it happens: Both Android and iOS samples in Koin docs show different entry points; it's easy to copy both. How to avoid: Pick a single initKoin() helper in commonMain, called from ONE place per platform. Canonical pattern:

// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
fun initKoin(config: KoinAppDeclaration? = null) = startKoin {
    config?.invoke(this)
    modules(appModule)
}

Then Android calls initKoin { androidContext(applicationContext) } from Application.onCreate() or MainActivity.onCreate(); iOS calls initKoin() from KoinKt.doInitKoin() in iosMain, invoked once in iOSApp.init(). MainViewController() does NOT start Koin — it assumes Koin is already started. [CITED: insert-koin.io KMP setup docs] Warning signs: App crashes on second launch on iOS; works fine the first time after a clean install.

Pitfall 5: Ktor HOCON env-var syntax confusion

What goes wrong: port = ${?PORT} behaves differently from port = ${PORT:8080}. The first makes the whole assignment disappear if PORT is unset (not what you want for defaults); the second fails at parse time if PORT is unset. Why it happens: HOCON substitution semantics are non-obvious. [VERIFIED: Ktor docs — heroku.md, sevalla.md, dokku.md] How to avoid: Use the two-line pattern for a default that can be overridden by env:

ktor {
    deployment {
        port = 8080
        port = ${?PORT}        # if PORT is set, overrides; otherwise this line is a no-op
    }
}

database {
    url = "jdbc:postgresql://localhost:5432/recipe"
    url = ${?DATABASE_URL}
    user = "recipe"
    user = ${?DATABASE_USER}
    password = "recipe"
    password = ${?DATABASE_PASSWORD}
}

Read via application.environment.config.propertyOrNull("database.url")?.getString() in Database.kt. Warning signs: Server crashes on startup with "Could not resolve substitution to a value" (fix: use ${?X} form); or env override silently ignored (fix: the second line overrides the first only if env is set).

Pitfall 6: Flyway connects to Postgres at plugin-task time, not server-boot time

What goes wrong: You run ./gradlew build without Postgres running, and the build fails with a JDBC error — even though you didn't invoke flywayMigrate. Why it happens: Some Flyway plugin versions evaluate the flyway { ... } block eagerly at configuration time. How to avoid: Keep the Flyway plugin config simple and don't depend Flyway tasks on classes/build. Use the plugin only for CLI tasks (./gradlew flywayInfo, ./gradlew flywayMigrate). The runtime migration path is through Flyway.configure().dataSource(...).load().migrate() in Database.kt. D-17's docker-compose documents starting Postgres before running the server; CI in Phase 11 will bring its own Postgres. Warning signs: ./gradlew build fails with JDBC connection refused despite not targeting Flyway.

Pitfall 7: Kotlin 2.x compilerOptions vs kotlinOptions

What goes wrong: Copy-pasting kotlinOptions { jvmTarget = "21" } from an older tutorial — deprecation warning under Kotlin 2.x, fails the build under D-11 (allWarningsAsErrors). Why it happens: Legacy DSL still "works" but is deprecated. How to avoid: Always write compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } (note .set() — it's a Provider property). [CITED: kotlinlang.org/docs/gradle-compiler-options.html] Warning signs: Deprecation message: "The 'kotlinOptions' DSL is deprecated."

Pitfall 8: Wasm + Koin init order

What goes wrong: wasmJsMain doesn't have an iOSApp-style init hook; ComposeViewport { App() } runs composition immediately. If Koin isn't started before App() composes, koinViewModel<X>() throws. Why it happens: The template's webMain/main.kt enters composition directly. How to avoid: Call initKoin() at the top of the Wasm main():

fun main() {
    initKoin()
    ComposeViewport { App() }
}

Phase 1 doesn't ship any ViewModels yet, so this is a future-proofing note — but since D-01 keeps wasmJs and composeApp/src/webMain/main.kt exists, we should add it now to avoid "it broke silently in Phase 5" discoveries. Warning signs: First Koin usage in wasmJs throws NoDefinitionFoundException despite appModule being correct.

Pitfall 9: includeBuild order in settings.gradle.kts

What goes wrong: includeBuild("build-logic") is placed inside dependencyResolutionManagement { } instead of pluginManagement { }; the main project can't find recipe.* plugin IDs. Why it happens: includeBuild inside pluginManagement makes the included build's plugins available to the root project's plugins { } blocks. Inside dependencyResolutionManagement it affects dependency resolution instead. How to avoid: Place includeBuild("build-logic") inside pluginManagement { } at the top of settings.gradle.kts:

pluginManagement {
    includeBuild("build-logic")
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

Warning signs: "Plugin [id: 'recipe.kotlin.multiplatform'] was not found" — almost always this.

Pitfall 10: iOS framework basename mismatch

What goes wrong: baseName = "ComposeApp" in composeApp (D-20) but baseName = "shared" (template default) in shared/. Xcode imports both as import ComposeApp and import shared — works but inconsistent; if shared later gains its own framework publication, name casing will trip developers. Why it happens: The template defaults to lowercase "shared". How to avoid: Since shared/ in this project is NOT published as its own iOS framework (it's a dependency of composeApp's framework, compiled in), its baseName is irrelevant — but set it to "Shared" anyway for consistency, per D-07. The composeApp framework ComposeApp re-exports shared symbols automatically. Warning signs: Xcode import statements inconsistent casing.

Code Examples

Convention plugin: recipe.kotlin.multiplatform.gradle.kts (full)

// build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts
// Establishes the D-05 target matrix + JVM toolchain + common deps.
// Android bytecode is JVM 11; everything else is JVM 21 (D-08).

import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    id("org.jetbrains.kotlin.multiplatform")
}

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

kotlin {
    jvmToolchain(21)

    androidTarget {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)
        }
    }

    listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "ComposeApp"   // modules override (shared → "Shared")
            isStatic = true
        }
    }

    jvm {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_21)
        }
    }

    @OptIn(ExperimentalWasmDsl::class)
    wasmJs { browser() }

    compilerOptions {
        allWarningsAsErrors.set(true)  // D-11
    }

    sourceSets {
        commonMain.dependencies {
            implementation(project.dependencies.platform(libs.findLibrary("koin-bom").get()))
            implementation(libs.findLibrary("koin-core").get())
            implementation(libs.findLibrary("kermit").get())
        }
        commonTest.dependencies {
            implementation(libs.findLibrary("kotlin-test").get())
        }
    }
}

Koin bootstrap: initKoin() in commonMain, called once per platform

// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
package dev.ulfrx.recipe.di

import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration

fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin {
    config?.invoke(this)
    modules(appModule)
}

// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
package dev.ulfrx.recipe.di

import org.koin.dsl.module

// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule = module {
    // intentionally empty in Phase 1
}

iOS-side wrapper [CITED: insert-koin.io KMP setup docs]:

// composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt
package dev.ulfrx.recipe.di

fun doInitKoin() { initKoin() }

Swift call site [CITED: insert-koin.io cmp.md]:

// iosApp/iosApp/iOSApp.swift
import SwiftUI
import ComposeApp

@main
struct iOSApp: App {
    init() {
        KoinIosKt.doInitKoin()   // Kotlin fun `doInitKoin` → generated Swift symbol KoinIosKt.doInitKoin
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Android call site:

// composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
package dev.ulfrx.recipe

import android.app.Application
import dev.ulfrx.recipe.di.initKoin
import org.koin.android.ext.koin.androidContext

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        initKoin {
            androidContext(this@MainApplication)
        }
    }
}

Then AndroidManifest.xml gains android:name=".MainApplication" on the <application> tag.

Desktop + Wasm call sites (top of main() before composition):

// composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
fun main() {
    initKoin()
    application {
        Window(onCloseRequest = ::exitApplication, title = "recipe") { App() }
    }
}

// composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
fun main() {
    initKoin()
    ComposeViewport { App() }
}

Kermit bootstrap: set tag once, BEFORE Koin

// composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt
package dev.ulfrx.recipe.logging

import co.touchlab.kermit.Logger

fun configureLogging() {
    Logger.setTag("recipe")
    // Platform-specific log writers (OSLog on iOS, LogCat on Android, System.out on JVM)
    // are installed automatically by Kermit's default Logger setup.
}

Call configureLogging() at the very top of initKoin() (or each platform main()/Application.onCreate()/iOSApp.init()) so logging is available inside Koin module loading itself. Order: configureLogging() → initKoin() → composition.

Ktor /health + ContentNegotiation + JSON

// server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
package dev.ulfrx.recipe

import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable

fun main() {
    embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

@Serializable
private data class Health(val status: String)

fun Application.module() {
    install(ContentNegotiation) {
        json()
    }
    Database.migrate(this)   // fails loudly if Postgres unreachable (D-16)

    routing {
        get("/health") {
            call.respond(Health(status = "ok"))
        }
    }
}

Database.kt — fail-loud Postgres + Flyway boot

// server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
package dev.ulfrx.recipe

import co.touchlab.kermit.Logger
import io.ktor.server.application.Application
import org.flywaydb.core.Flyway

object Database {
    private val log = Logger.withTag("Database")

    fun migrate(app: Application) {
        val url = app.environment.config.property("database.url").getString()
        val user = app.environment.config.property("database.user").getString()
        val password = app.environment.config.property("database.password").getString()

        log.i { "Connecting to $url as $user and running Flyway migrations" }

        runCatching {
            Flyway.configure()
                .dataSource(url, user, password)
                .locations("classpath:db/migration")
                .baselineOnMigrate(true)
                .validateOnMigrate(true)
                .cleanDisabled(true)
                .load()
                .migrate()
        }.onFailure { ex ->
            log.e(ex) { "Flyway migration failed — cannot start server" }
            throw IllegalStateException("Database unreachable or migration failed", ex)
        }
    }
}

Note: server uses Logback (already wired) not Kermit — Kermit is the client-side logger. Kept for consistency at the API level but server logs go through SLF4J/Logback. For the Database object, if Kermit isn't set up on the server side, substitute org.slf4j.LoggerFactory.getLogger(...) — recommend using SLF4J on the server throughout, keeping Kermit for composeApp/shared.

Decision recommendation: Server uses SLF4J+Logback; client uses Kermit. Kermit's server-side JVM log writer exists but adds no value over the Logback stack already present.

application.conf — HOCON with env overrides

// server/src/main/resources/application.conf
ktor {
    deployment {
        port = 8080
        port = ${?PORT}
    }
    application {
        modules = [ dev.ulfrx.recipe.ApplicationKt.module ]
    }
}

database {
    url = "jdbc:postgresql://localhost:5432/recipe"
    url = ${?DATABASE_URL}
    user = "recipe"
    user = ${?DATABASE_USER}
    password = "recipe"
    password = ${?DATABASE_PASSWORD}
}

docker-compose.yml

# repo root docker-compose.yml
services:
  postgres:
    image: postgres:16
    container_name: recipe-postgres
    environment:
      POSTGRES_DB: recipe
      POSTGRES_USER: recipe
      POSTGRES_PASSWORD: recipe
    ports:
      - "5432:5432"
    volumes:
      - recipe-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U recipe -d recipe"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  recipe-pgdata:

Recommendation on .env vs inline: inline is fine for single-dev + matching application.conf defaults. .env adds a file to .gitignore and an .env.example; more surface area without much benefit for a 2-person project. Deferring .env is safe; revisit when a second environment (staging) appears.

gradle.properties — iOS binary flags (D-18)

# Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M

# Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true

# Android
android.nonTransitiveRClass=true
android.useAndroidX=true

# Kotlin/Native iOS (PITFALLS.md #1; D-18; INFRA-03) — MANDATORY day 1
# CMS GC: reduces pause spikes on UI-heavy iOS apps (Compose Multiplatform)
kotlin.native.binary.gc=cms
# Prevents Obj-C deinit from blocking the main thread — ships deinit to a special GC thread
kotlin.native.binary.objcDisposeOnMain=false

Verification of the two flags [CITED: kotlinlang.org/docs/native-binary-options.html]:

  • Run ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info and grep the output for objcDisposeOnMain and gc=cms — Kotlin's K/N link step echoes the binary options it's compiling with.
  • On simulator launch, absence of warnings like "legacy memory manager" or "freeze()" deprecations confirms the new MM is active.

State of the Art

Old Approach Current Approach When Changed Impact
kotlinOptions { jvmTarget = "11" } compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } Kotlin 2.2 (deprecated), 2.3 (still works but warns) D-11 treats warnings as errors, so must migrate
freeze() / @SharedImmutable / kotlin.native.concurrent.AtomicReference new K/N memory manager (default since 1.9); StateFlow is thread-safe Kotlin 1.9 PITFALLS.md #2 — old tutorials still show this, reject them
kotlin.native.binary.memoryModel=experimental No flag needed; new MM is default Kotlin 1.9 Don't re-add legacy flag
js { browser() } target for web wasmJs { browser() } CMP 1.6+ D-01 drops js entirely
build-src/ directory build-logic/ as included build via includeBuild Gradle 7+ Cleaner build isolation, faster rebuilds
Flyway 9.x flyway-core only Flyway 12.x splits Postgres dialect into flyway-database-postgresql artifact Flyway 10 Must add flyway-database-postgresql for Postgres 15+
io.ktor:ktor-serialization:... io.ktor:ktor-serialization-kotlinx-json-jvm:... Ktor 3.x Separate content-negotiation plugin + serializer plugin
Compose hot reload via third-party plugin org.jetbrains.compose.hot-reload (JetBrains) CMP 1.9+ Already in catalog (template commit c50d747)

Deprecated/outdated:

  • kotlinOptions { } block — replaced by compilerOptions { } with Provider properties
  • iosX64() target — D-02 rejects; Apple Silicon only
  • js() target — D-01 rejects; wasmJs covers the niche
  • freeze() / @SharedImmutable / old K/N memory-management APIs — all no-ops or deprecated under Kotlin 2.x

Assumptions Log

# Claim Section Risk if Wrong
A1 Koin 4.2.1 (BOM) is the current stable and works under Kotlin 2.3.20 + CMP 1.10.3 Standard Stack If 4.2.x has a KMP regression with CMP 1.10.3, need to pin to last known-good 4.1.x until resolved. Verify by ./gradlew build after bumping; mitigation is easy (single version.ref change).
A2 Flyway 12.4.0's programmatic API signature (Flyway.configure().dataSource(...).load().migrate()) is stable and compatible with Postgres 16 Code Examples: Database.kt Low — API hasn't changed in years. Doc [CITED: Flyway API (Java).md].
A3 baselineOnMigrate = true is the right default for a dev environment with a fresh DB (no-op) and a homelab prod with pre-existing tables in Phase 11 Pattern 7; Database.kt Low — this is the recommended setting for projects adopting Flyway partway; we're adopting from day 1, so it's technically unnecessary but defensive.
A4 The current template's shared/build.gradle.kts applies com.android.library because Android cross-compilation needs it; we may drop it if androidTarget { } inside KMP is sufficient Anti-Patterns (android.application to shared) Medium — dropping android-library might break :shared:androidDebugLibrary consumption. Keep applying it through a future recipe.android.library plugin (deferred beyond Phase 1) OR keep the existing direct application in shared/build.gradle.kts for now. Research inconclusive; recommend keeping direct application until tested.
A5 The "Kermit for client, SLF4J/Logback for server" split is preferable to Kermit on both Pattern 6; Database.kt Low — Kermit-JVM works on the server, just adds a dep. The split is a style call, not a correctness issue.
A6 Hot-reload wiring (commit c50d747) continues to work after refactor into recipe.compose.multiplatform Pattern 4 Medium — if the plugin's id("org.jetbrains.compose.hot-reload") must be applied AFTER id("org.jetbrains.compose"), the order inside the precompiled plugin matters. Verify by ./gradlew :composeApp:jvmRun producing a hot-reloadable Desktop window after Phase 1.
A7 Postgres JDBC 42.7.10 works under JVM 21 with Postgres 16 Standard Stack Low — well-documented.
A8 explicitApi() in shared/build.gradle.kts will not force the current empty Greeting/Platform classes to add visibility modifiers — they're already class (public by default in Kotlin, so no changes) Pattern 4 / shared configuration Low — current shared/src/commonMain/.../Greeting.kt uses default-public classes, which explicitApi() accepts. Verify by running ./gradlew :shared:build after enabling.

If this table is non-empty: Planner and /gsd-discuss-phase already ran; these assumptions are residual and should be verified during implementation, not re-surfaced. Items A4 and A6 are the ones most likely to surprise — include in Wave 0 smoke checks.

Open Questions (RESOLVED)

  1. Should shared/ keep com.android.library directly applied, or rely on androidTarget in the recipe.kotlin.multiplatform plugin alone?

    • What we know: Current template applies com.android.library directly. KMP's androidTarget { } declares the Android target but doesn't strictly require the android-library plugin for every module — sometimes it does.
    • What's unclear: Whether dropping com.android.library from shared/ breaks the composeApp Android consumer.
    • RESOLVED: Keep com.android.library applied in shared/build.gradle.kts directly in Phase 1. Build a recipe.android.library convention plugin in a future phase if the direct application becomes a pattern. Don't block Phase 1 on this refactor.
  2. Does ./gradlew build invoke flywayMigrate? Should it?

    • What we know: Flyway plugin exposes flywayMigrate, flywayInfo, etc. as tasks; it does NOT hook them into build by default.
    • What's unclear: Nothing — this is a choice.
    • RESOLVED: Do NOT wire Flyway tasks into build in Phase 1. Migration is a server-boot concern; the plugin is for CLI ops (developer runs ./gradlew flywayInfo manually to inspect state). CI integration lands in Phase 11.
  3. Should we add ktor-server-config-yaml for a application.yaml alternative to HOCON?

    • What we know: Ktor 3.x supports YAML config via the ktor-server-config-yaml artifact; HOCON remains the default.
    • What's unclear: Team preference.
    • RESOLVED: Stick with HOCON. Our server dev is Kotlin/Ktor background (user profile) and HOCON is the historically canonical Ktor config. YAML is a nice-to-have, not worth the added dep.
  4. How to verify iOS binary flags take effect without shipping a build to hardware?

    • What we know: Simulator launch eliminates most of the visible symptoms of PITFALL #1; Instruments on a real device would be the gold standard.
    • What's unclear: Whether simulator-level verification is sufficient for Phase 1 sign-off.
    • RESOLVED: Verify at two levels: (a) grep gradle.properties for the two flags (trivial but catches omission); (b) build the iOS framework and capture the Kotlin/Native link log for a line showing the GC + objcDisposeOnMain options. Real-device verification under Instruments is deferred to Phase 10 (UI chrome) when there's meaningful UI work to stress-test.
  5. Does recipe.quality need a targetExclude for generated Compose Resources code?

    • What we know: Compose Multiplatform generates Res.kt under build/generated/compose/resourceGenerator/....
    • What's unclear: Whether Spotless/ktlint visit build/ by default (they shouldn't, but worth confirming).
    • RESOLVED: Add targetExclude("**/build/**", "**/generated/**") explicitly in the Spotless config (already in Pattern 5 example) to future-proof against any .kt file landing in those paths.

Environment Availability

Dependency Required By Available Version Fallback
Java / JDK All Gradle builds OpenJDK 25.0.2 — (JDK 21 toolchain resolved via Foojay if project JDK differs)
Docker D-17 docker-compose / Postgres local 27.3.1
Docker Compose D-17 v2.40.0-desktop.1 docker compose (v2 as subcommand) works identically
Xcode + iOS SDK iOS framework build, simulator verification (not probed — user on macOS; assume present given iOS-primary target) If missing, developer installs from App Store before Phase 1 Wave 2 (iOS tasks)
Gradle daemon All builds Implicit (bundled with wrapper) 8.x (from wrapper)
npx (for ctx7 docs lookups) Research only, not build ✓ (npm present)
Node / npm Not required at build time Project is pure Kotlin; no Node
Homebrew Not required

Missing dependencies with no fallback: None detected. Xcode is assumed but not probed; if missing, the iOS framework build will fail with a clear error at Phase 1 Wave 2 (iOS task execution).

Missing dependencies with fallback: None.

Validation Architecture

Phase 1 success is defined by 5 success criteria in ROADMAP.md (SC1SC5) and 4 phase requirements (INFRA-01/02/03/06). Each maps to a specific verification command.

Test Framework

Property Value
Framework kotlin.test (commonTest) + JUnit 4 (server test via ktor-server-test-host) + existing template tests
Config file composeApp/src/commonTest/kotlin/ComposeAppCommonTest.kt, shared/src/commonTest/kotlin/SharedCommonTest.kt, server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt (all present)
Quick run command ./gradlew :server:test :composeApp:jvmTest :shared:jvmTest (JVM-only subset — <30s)
Full suite command ./gradlew check (runs spotlessCheck + all test tasks across all targets)

Phase 1 primarily adds build-level verification (Gradle tasks succeed, file structure correct, version literals absent) rather than unit tests. The existing ApplicationTest.kt is updated to cover the /health endpoint.

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
INFRA-01 No version literals in any build.gradle.kts shell grep ! grep -rE '(version[[:space:]]*=[[:space:]]*"[0-9]|"[0-9]+\.[0-9])' composeApp/build.gradle.kts server/build.gradle.kts shared/build.gradle.kts build-logic/src/main/kotlin/ Wave 0 (script tools/verify-no-version-literals.sh)
INFRA-01 gradle/libs.versions.toml is single source of truth manual visual + grep grep -rE "libs\\.(versions|plugins|bundles)" build-logic/src/main/kotlin/ returns all version lookups existing catalog
INFRA-02 Convention plugins apply without duplication or errors Gradle build ./gradlew :composeApp:help :server:help :shared:help (each emits applied-plugins section including recipe.*) Wave 0 (plugins don't exist yet)
INFRA-02 Adding a new KMP module only needs id("recipe.kotlin.multiplatform") visual review of plugin Demonstrated by the refactored shared/build.gradle.kts being ≤15 lines after refactor Target for Wave 2
INFRA-03 gradle.properties contains the two iOS flags grep grep -E '^kotlin\\.native\\.binary\\.(gc=cms|objcDisposeOnMain=false)$' gradle.properties | wc -l returns 2 Wave 0
INFRA-03 iOS simulator build boots without legacy memory-manager warnings build log inspection ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --info 2>&1 | grep -i 'legacy|freeze' | grep -v '^$' returns empty Target for Wave 2 (iOS tasks)
INFRA-06 shared/commonMain has no Ktor/Compose/SQLDelight imports grep ! grep -rE '^import (io\\.ktor|androidx\\.compose|org\\.jetbrains\\.compose|app\\.cash\\.sqldelight)' shared/src/commonMain/kotlin/ Wave 0 (script tools/verify-shared-pure.sh)
INFRA-06 shared/ package scaffold exists file existence test -d shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared Target for Wave 2

Success Criteria → Test Map (SC1SC5 from ROADMAP.md)

SC Success Statement Automated Command Pass Criteria
SC1 ./gradlew build succeeds across composeApp, server, shared; produces iOS framework and Android APK ./gradlew build then test -f composeApp/build/outputs/apk/debug/composeApp-debug.apk && test -d composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework exit 0 + both files/dirs present
SC2 No version literals in any build.gradle.kts tools/verify-no-version-literals.sh exit 0
SC3 iOS gradle.properties carries the two flags; simulator debug launch has no legacy-MM warnings tools/verify-ios-flags.sh (grep gradle.properties) + optional simulator boot both flags present; simulator warning grep empty
SC4 build-logic convention plugins apply to every module ./gradlew :composeApp:help -q | grep 'recipe.kotlin.multiplatform' etc. each plugin shows in its applicable modules' help output
SC5 shared/commonMain contains only domain models + DTOs tools/verify-shared-pure.sh exit 0

Additional acceptance beyond ROADMAP SC list:

Check Automated Command Pass Criteria
Server /health returns 200 JSON {"status":"ok"} docker compose up -d postgres && ./gradlew :server:run &; sleep 5; curl -s http://localhost:8080/health | grep -o '"status":"ok"'; kill %1 curl returns expected substring
Server fails loudly if Postgres missing docker compose down; ./gradlew :server:run server exits non-zero within ~10s with "Database unreachable" in logs
Spotless formatting clean ./gradlew spotlessCheck exit 0
./gradlew check runs full suite ./gradlew check exit 0
Koin starts without error in JVM target ./gradlew :composeApp:jvmTest (existing template test runs composition path) exit 0; no KoinApplicationAlreadyStartedException

Sampling Rate

  • Per task commit: ./gradlew spotlessCheck :server:test :shared:jvmTest (fast subset, <30s)
  • Per wave merge: ./gradlew build (full build including iOS framework link and Android APK)
  • Phase gate: ./gradlew check + manual server /health curl + iOS simulator boot verification

Wave 0 Gaps

  • tools/verify-no-version-literals.sh — shell script grepping for version literals outside catalog
  • tools/verify-shared-pure.sh — shell script grepping for forbidden imports in shared/commonMain
  • tools/verify-ios-flags.sh — shell script grepping gradle.properties for the two K/N flags
  • build-logic/ directory scaffold with 5 empty placeholder .gradle.kts files
  • server/src/main/resources/application.conf (does not exist yet)
  • server/src/main/resources/db/migration/.gitkeep (directory placeholder)
  • docker-compose.yml at repo root
  • Extended ApplicationTest.kt covering /health endpoint
  • composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt + AppModule.kt
  • composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt
  • composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt + AndroidManifest.xml registration
  • composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt
  • iosApp/iosApp/iOSApp.swift — modify to call KoinIosKt.doInitKoin()

Security Domain

Phase 1 is infrastructure-only — no authentication, no user data, no network-facing multi-tenant endpoints. The /health route is unauthenticated by design (observability); it reveals only server liveness, not implementation detail.

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no (Phase 2) ktor-server-auth-jwt (Phase 2)
V3 Session Management no (Phase 2)
V4 Access Control no (Phase 3) household scoping (Phase 3)
V5 Input Validation no (no request bodies yet) kotlinx.serialization validation (future)
V6 Cryptography no
V7 Error Handling partial Database fails loudly with opaque message (no stack trace in HTTP response)
V8 Data Protection partial .env / application.conf defaults use non-secret localhost creds; never check real secrets into git
V12 API Security n/a /health is the only endpoint, intentionally unauthenticated
V14 Configuration yes HOCON env-var overrides (${?DATABASE_URL}) ensure production creds come from environment, not from git

Known Threat Patterns for this stack (Phase 1 subset)

Pattern STRIDE Standard Mitigation
Secret in application.conf committed to git Information Disclosure Defaults must be non-secret (recipe/recipe/recipe localhost only). Real secrets arrive via env vars in Phase 11. Add *.env to .gitignore if .env route chosen.
Flyway clean wiping prod data Destruction / Tampering cleanDisabled = true in both plugin config and Database.kt programmatic call (Pattern 7 + Code Examples).
Unauthenticated /health leaking runtime details Information Disclosure Body is {"status":"ok"} only — no version, no commit hash, no uptime. (Leave build identifiers out until Phase 11.)
Postgres port 5432 exposed on 0.0.0.0 Exposure docker-compose.yml binds 5432:5432 on host; document in README that this is dev-local only, firewall required for any accidental multi-host use.

Other security threats (auth bypass, SQL injection, CSRF, XSS) have no surface area in Phase 1 and are deferred to the phases that introduce them.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence — cross-verified with primary sources)

Tertiary (training + synthesis)

  • Kotlin 2.3.20 DSL behavior and compilerOptions provider properties — training + kotlinlang.org cross-checks
  • kotlin.native.binary.* defaults under Kotlin 2.x — kotlinlang.org + PITFALLS.md #1 synthesis

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all versions verified against Maven Central / Gradle plugin portal during this session
  • Convention plugin mechanics: HIGH — directly sourced from Gradle docs, including the critical VersionCatalogsExtension access pattern
  • Koin KMP bootstrap: HIGH — Koin 4.x docs explicitly show the initKoin() / doInitKoin() pattern; settles D-14 Claude's-discretion item
  • Kermit setup: HIGH — single-line tag + default platform writers is the documented canonical path
  • Ktor /health + HOCON: HIGH — exact code + exact HOCON syntax both verified
  • Flyway programmatic API + Gradle plugin: HIGH — both documented, both pinned to 12.4.0
  • iOS binary flags: HIGH — PITFALLS.md #1 + kotlinlang.org binary options reference
  • Docker Compose service shape: HIGH — trivial postgres:16 pattern
  • "What NOT to do" pitfalls: MEDIUM-HIGH — most are verified from docs; some (A4 android-library plugin on shared/, A6 hot-reload order) are conservative-assumption recommendations awaiting Wave 2 verification

Research date: 2026-04-24 Valid until: 2026-05-24 (4 weeks). Stack is stable; only risk is a Kotlin/CMP minor bump or a Koin 4.2.x regression. Re-verify before Phase 2 if this phase stretches beyond the valid-until window.