Multi-Module Builds: Splitting, Wiring, and Sharing Config
Compose a Gradle build from independent modules and stop copy-pasting configuration.
A Gradle build is a tree of projects. The root project rarely holds production code; instead it acts as a container that ties subprojects (modules) together. Every module Gradle should know about must be declared in `settings.gradle.kts` with `include(...)`. A call like `include(":app", ":core")` tells Gradle that the build is composed of two subprojects, `:app` and `:core`, each living in its own directory with its own `build.gradle.kts`. The leading colon is a path separator from the root: `:core` means the `core` directory at the build root, and `:feature:login` would map to `feature/login`. The `settings.gradle.kts` file is the single source of truth for which modules exist โ if a directory isn't `include`d, Gradle simply ignores it.
Once modules exist, you wire them together with project dependencies. Inside `app/build.gradle.kts` you write `implementation(project(":core"))` (or the type-safe `implementation(projects.core)` once you turn on type-safe project accessors with `enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")` in `settings.gradle.kts`). This expresses that `:app` depends on the compiled output and the API of `:core`. Gradle builds `:core` first, then `:app`, and it does so incrementally: change a file in `:app` and `:core` is not recompiled. Choosing `api` versus `implementation` matters here โ `api(project(":core"))` leaks `:core`'s own dependencies onto consumers of `:app`, while `implementation` keeps them internal, which speeds up compilation and prevents accidental coupling.
The hard part of a multi-module build is not splitting code โ it is keeping configuration consistent. You do not want the Kotlin version, the JVM target, the test framework, and the compiler options redefined (and slowly diverging) in every module. Gradle offers two mechanisms. `buildSrc` is a special directory that Gradle compiles before the rest of the build; anything you put there (helper functions, constants, and especially precompiled script plugins) becomes available to every `build.gradle.kts`. The more modern and recommended approach is convention plugins: small precompiled plugins, typically authored in `buildSrc` or an included `build-logic` build, that bundle a reusable slice of configuration behind a single plugin id.
A convention plugin is just a `*.gradle.kts` file placed under `buildSrc/src/main/kotlin/`. A file named `kotlin-library-conventions.gradle.kts` automatically becomes a plugin you can apply with `id("kotlin-library-conventions")`. Inside it you apply the Kotlin plugin, set the JVM toolchain, add the standard test dependencies, and configure compiler options โ once. Every library module then reduces to `plugins { id("kotlin-library-conventions") }`, and its build file shrinks to just the dependencies unique to that module. This is how large codebases stay sane: shared decisions live in one plugin, not duplicated across dozens of files.
Version catalogs (`gradle/libs.versions.toml`) are the third pillar and pair naturally with multi-module builds. The catalog centralizes every dependency coordinate and version in one TOML file, exposing them as type-safe accessors like `libs.kotlinx.coroutines` across all modules (and, with a little wiring, inside your convention plugins too). This means a single edit bumps a library everywhere, your IDE autocompletes dependencies, and there is no risk of `:app` and `:core` silently pulling two different versions of the same library. Combine the catalog with convention plugins and a module's build script becomes almost purely a declaration of what it is and what it talks to.
When should you actually split? Reach for a new module when you have a clear boundary worth enforcing: a domain layer that must not depend on the web framework, code shared by several entry points (an API server and a CLI), or a slow-to-compile piece you want cached and parallelized independently. Modules give you faster incremental and parallel builds, enforceable architecture (the compiler rejects forbidden dependencies), and better build caching. But each module adds overhead โ configuration, a dependency graph to reason about, and cross-module navigation cost. Do not pre-split a small app into a dozen modules on day one; let real boundaries and real build-time pain pull modules out of a monolith, not speculation.
// settings.gradle.kts โ declares which modules make up the buildrootProject.name = "my-app"dependencyResolutionManagement {// shared repositories used to resolve dependencies in every modulerepositories { mavenCentral() }}// each path maps to a directory: :core -> core/, :app -> app/include(":app", ":core")
settings.gradle.kts is the single source of truth for the module list and the shared repositories.
// app/build.gradle.kts โ a thin module file: conventions + its own depsplugins {// all shared Kotlin/JVM/test config comes from this one pluginid("kotlin-library-conventions")}dependencies {// depend on the :core module's compiled output and APIimplementation(project(":core"))// type-safe accessor sourced from gradle/libs.versions.tomlimplementation(libs.kotlinx.coroutines.core)}
implementation(project(":core")) wires modules together; the convention plugin removes boilerplate.
// buildSrc/src/main/kotlin/kotlin-library-conventions.gradle.kts// A convention plugin: configure shared decisions exactly once.plugins {kotlin("jvm")}kotlin {jvmToolchain(21) // every module that applies this plugin gets JDK 21}tasks.test {useJUnitPlatform() // standardized test runner for all modules}
Apply this with id("kotlin-library-conventions") in any module to inherit the shared setup.
๐ง Check your understanding
0/1 ยท 0/1 answered1. In a multi-module Gradle build, what is the purpose of a convention plugin placed in buildSrc (e.g. kotlin-library-conventions.gradle.kts)?