Settings for a many-module multiplatform repo

SkillFiles & storage

Settings-file patterns for a many-module Kotlin Multiplatform repo — mapping deeply nested in-repo directories onto flat Gradle project paths, turning on typesafe project accessors and knowing how they mangle names, declaring repositories in the two places that need them, and pinning one transitive artifact repo-wide for a conflict that only shows at runtime. Reach for it when Gradle reports a project that "does not exist" from a module you never edited, when a project accessor will not resolve, or when a repository you added is invisible to plugin resolution.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Settings for a many-module multiplatform repo skill

What this skill tells your AI

The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/kmp-gradle-settings-catalog/SKILL.md and read by ahel’s review.

The Gradle project path and the directory on disk are two independent things. include() declares the path; projectDir says where it lives. Use that to keep a flat, memorable set of project paths over a nested directory tree:

// settings.gradle.kts — adapted
val sharedDir  = File(rootDir, "<shared>")
val serviceDir = File(rootDir, "<shared>/service")
val mediaDir   = File(rootDir, "<shared>/media")

rootProject.name = "<App>"

include(
    ":<android-launcher>", ":<shared-ui>", ":<desktop-launcher>",
    ":common", ":data", ":domain",
    ":<service-a>", ":<service-b>",
    ":<media-a>", ":<media-a>-ui",
    ":<feature>", ":<feature>-empty",
)

project(":common").projectDir     = File(sharedDir, "common")
project(":<service-a>").projectDir = File(serviceDir, "<service-a>")
project(":<media-a>").projectDir   = File(mediaDir, "<media-a>")

enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

Consumers then write :<service-a> and projects.<serviceA> and never learn how deep the file actually sits. Move the directory, change one line here, and nothing else in the repo changes.

Traps

include() must come first. project(":x") looks up an already-included path; calling it before the include block fails in the settings file itself. Keep all includes in one block, all projectDir assignments after it, in the same order.

A wrong projectDir fails nowhere near where you wrote it. Pointing at a directory with no build script does not error at assignment. It surfaces at the first module that depends on it, as a missing project or an unresolvable accessor — naming a module the reader never touched. Check the mapping directly: grep -c 'projectDir' settings.gradle.kts against find <shared> -maxdepth 3 -name 'build.gradle.kts' | wc -l. The two should agree, plus any modules at the repo root that need no mapping.

Anchor every path on rootDir, never on a relative walk-up. A lookup that probes a sibling directory outside the repo — ../<name> for co-development — binds to whatever happens to sit there. The settings file here records exactly that failure: another checkout one level up used the same folder name, the probe bound to it, and configuration died on a module that "does not exist". File(rootDir, …) cannot reach outside the repo, which is the property you want.

Typesafe accessors are generated from the Gradle path, and the mangling is where twin modules bite. Each path segment is camel-cased across - and _, and the disk path never appears:

Gradle pathAccessor
:<feature>-emptyprojects.<feature>Empty
:<media-a>-uiprojects.<mediaA>Ui
:<service-a> (already camel)projects.<serviceA>

So renaming a directory changes nothing, while renaming an include() entry rewrites every consumer. Pick the project paths once, before the accessors spread.

Repositories are declared in two blocks and one does not feed the other. pluginManagement resolves plugin markers; dependencyResolutionManagement resolves everything else. A repository added to only one is invisible to the other, and the failure reads as "plugin not found" even though the artifact is plainly in your other list. The two lists here overlap but are not identical: the same third-party hosts appear in both, while dependencyResolutionManagement carries two more that plugin resolution never needs — a snapshot repository and a raw-git-hosted one.

FAIL_ON_PROJECT_REPOS is what keeps the list honest. With repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS), any module declaring its own repositories { } fails the build instead of quietly resolving from a source no one else has. Turn it on early — retrofitting it means finding every stray block at once.

Delete a dead repository, and say why in a comment. Both blocks here carry a comment recording which host was removed and what its removal fixed — as recorded there, an unreachable host answering with gateway errors disabled the whole set and blocked fallback to a repository that did have the artifact, so the symptom was "nothing resolves" rather than "one thing is slow". Whatever your own resolver does with a dead host, the comment is what stops the next reader adding it back.

A version force belongs in exactly one place, above every module — and in this repo that place is the root build script, not the settings file:

// root build.gradle.kts — adapted
subprojects {
    // Two third-party libraries depend on the same artifact pinned to different source
    // revisions. Default resolution picks the one missing a method the other library's
    // fallback path calls, so the app stops only when that fallback runs — at runtime,
    // on a code path most sessions never reach. Force one revision everywhere so the
    // merged output carries a single copy with the API both callers expect.
    configurations.all {
        resolutionStrategy {
            force("com.example.group:shared-json:<pinned-revision>")
        }
    }
}

Two things make this repo-wide rather than a module fix. The coordinate's "version" is a source revision string, so the default newest-wins comparison is not comparing anything meaningful — the winner is arbitrary from your point of view. And the packaged artifact merges the runtime classpath of every module, so forcing it only in the module you were debugging leaves the others free to reintroduce the loser. Pin it once, and keep the reason in the comment: a bare force(...) line is unmaintainable, because the next reader cannot tell whether it is still needed. See transitive-version-pinning.

Toolchain provisioning is a settings-level plugin. The resolver convention plugin belongs in the settings plugins { } block, so jvmToolchain(…) in any module can be satisfied on a machine that lacks that JDK. Without it a contributor's build fails on the toolchain, not on what they changed.

Verifying it

# what the settings file actually declares, in order
grep -n 'include(\|projectDir\|enableFeaturePreview\|RepositoriesMode' settings.gradle.kts

# every accessor in use must correspond to an included path
grep -rhoE 'projects\.[A-Za-z0-9]+' --include='*.kts' . | grep -v '/build/' | sort -u

# nothing outside settings may declare repositories
grep -rn '^\s*repositories {' --include='*.kts' . | grep -v settings.gradle.kts | grep -v '/build/'

The last command must return nothing; a hit means FAIL_ON_PROJECT_REPOS is off, or about to fail someone else's build. When the module tree spans a git submodule the mapping is the same but the failure modes differ — see kmp-git-submodule-module-mapping.

Signals

GitHub stars
315
Forks
6
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
kmp-gradle-settings-catalog
Source
github.com/maxrave-dev/kotlin-footguns