Simplifying Build Logic in Kotlin Multiplatform Projects with Gradle Convention Plugins

Creative Software logomark
Reshaka Weerasinghe
November 7, 2025

Kotlin Multiplatform (KMP) is becoming one of the best ways to share code across Android, iOS, Desktop, and beyond. Pair it with Compose Multiplatform (CMP) for a shared UI, and suddenly you can build apps for multiple platforms with a single codebase.

But as soon as your project grows into a multi-module setup, things can quickly get messy.

  • Each module needs the same Gradle configuration.
  • You end up copy-pasting boilerplate code like Kotlin targets, Android settings, or Compose dependencies.
  • Updating versions or making global changes becomes frustrating and error prone.

This is where Gradle convention plugins come to the rescue. They let you centralize and standardize build logic, so every module follows the same rules. Instead of rewriting the same configuration everywhere, you define it once and apply it to any module with a single line.

In this guide, we’ll walk step by step through creating and applying convention plugins in a real-world Kotlin Multiplatform + Compose Multiplatform project. Even if you’ve never touched Gradle plugins before, you’ll be able to keep your build scripts clean, DRY, and scalable.

Why Do We Need Convention Plugins?

The Pain Points Without Them

If you’ve worked on a growing KMP project, you’ve probably faced these problems:

  • Repeated kotlin { ... } blocks in every module
  • Duplicated Android SDK & compiler configs
  • Forgetting dependencies in new modules (like Coroutines, Compose)
  • Global updates (like changing the Compose version) being error-prone
  • Copy - paste errors across library and feature modules

What Convention Plugins Solve

Convention plugins help by:

  • Centralizing build logic → One source of truth
  • Enforcing consistency → Every module follows the same rules
  • Simplifying setup → Add new modules with almost zero config
  • Improving maintainability → Update once, reflect everywhere

What Are Convention Plugins?

Convention plugins are custom Gradle plugins you write for your own project.

They’re different from standard plugins (like com.android.application or org.jetbrains.kotlin.multiplatform) because they live inside your project and are tailored to your needs.

Think of them as pre-packaged Gradle configurations. For example, instead of repeating Compose setup in every module, you define it once in a convention plugin and then just apply

Creating Convention Plugins

Step 1: Setting up build-logic Module

Let’s start by creating build-logic folder at the root project directory. Inside this folder, create gradle.properties and settings.gradle.kts for the build-logic module.

gradle.properties file
kotlin.code.style=official
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configureondemand=true

settings.gradle.kts file
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"
include(":convention")

In settings.gradle.kts of the root project directory, include the new build-logic module:

pluginManagement {
    includeBuild("build-logic")
}

This tells Gradle: “Looks inside build-logic for plugins.”

Let’s create the convention module. Inside the build-logic folder, we now create a sub-module called convention. This module will host all our custom Gradle plugins.

In the build.gradle.kts of the convention module, enable the Kotlin DSL plugin and add compile-only dependencies for the Gradle plugins your modules will use:

plugins {
    `kotlin-dsl`
}

group = "com.example.app.buildlogic"

dependencies {
    compileOnly(libs.android.gradlePlugin) // If targeting Android
    compileOnly(libs.kotlin.gradlePlugin)
    compileOnly(libs.compose.gradlePlugin) // If using Compose Multiplatform
}

/** the above plugins should contains in your libs.versions.toml as belows **/
/**
[plugins]
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
compose-gradlePlugin = { module = "org.jetbrains.compose:org.jetbrains.compose.gradle.plugin", version.ref = "compose" }
**/

Next, create the package structure inside src/main/kotlin/com/example/app/convention. Your helper functions will live here, and the custom Gradle plugin classes will be placed in the Kotlin directory. After that the file structure of the build-logic module will look like below.

build-logic/
 ├─ gradle.properties
 ├─ settings.gradle.kts
 └─ convention/
     ├─ build.gradle.kts
     └─ src/
          └─ main/
              └─ kotlin/
                  └─ com/example/app/convention

With the convention module set up, we can now move on to defining individual feature configurations.

Step 2: Creating feature configurations

The idea here is to break down your Gradle setup into independent, reusable configurations each handling a specific concern. Later, we’ll combine them to build custom plugins.

For example, we’ll create feature configurations for:

  1. Android - standardizes Android configs (namespace, SDK versions, JVM target, packaging rules)
  2. Kotlin Multiplatform (KMP) - Defining targets, common dependencies, and source sets
  3. Compose Multiplatform (CMP) - centralizes Compose dependencies for all platforms

By keeping each configuration independent, it becomes easier to maintain, extend, or reuse in other plugins.

Let’s begin by creating an extension function on the Project class to access the Gradle Version Catalog. This will allow us to conveniently retrieve dependencies and versions defined in libs.versions.toml in all our feature configurations. Create Libs.kt file in your convention module and copy the below

Libs.kt
val Project.libs
    get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")

Configure Android Modules

Let’s start with Android. We’ll create an extension function configureKotlinAndroid inside configureKotlinAndroid.kt file. This function applies consistent Android configuration across all library modules:

internal fun Project.configureKotlinAndroid(
    extension: CommonExtension<*, *, *, *, *, *>,
) = extension.apply {
    // Dynamically compute the namespace from the module path
    val moduleName = path.split(":").drop(2).joinToString(".")
    namespace = if (moduleName.isNotEmpty()) "com.example.app.$moduleName" else "com.example.app"  //replace the "com.example.app" with your project id
    
    // Pull SDK versions from version catalog
    compileSdk = libs.findVersion("android-compileSdk").get().requiredVersion.toInt()
    defaultConfig {
        minSdk = libs.findVersion("android-minSdk").get().requiredVersion.toInt()
    }

    // Set Java 17 as source/target compatibility
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    // Kotlin JVM target for Android
    tasks.withType<KotlinCompile>().configureEach {
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_17)
        }
    }

    // Exclude unwanted META-INF files to avoid packaging conflicts
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
}

Here, we:

  • Auto-generate namespace dynamically from the Gradle module path.
  • Keep SDK versions consistent via the version catalog.
  • Standardize Java toolchain.
  • Apply packaging rules globally.

If you have any other android related build logic that need to be consistent across all the modules (such as build logic related to build types), you could include those as well.

Configure Kotlin Multiplatform

Now let’s define a helper for KMP itself. This function sets up all targets, applies default hierarchies, and wires in common dependencies:

internal fun Project.configureKotlinMultiplatform(
    extension: KotlinMultiplatformExtension
) = extension.apply {
    // Use JDK 17 for compilation
    jvmToolchain(17)

    // targets
    androidTarget()

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "ComposeApp"
            isStatic = true
        }
    }

    jvm()

    // Add common dependencies
    sourceSets.apply {
        commonMain {
            dependencies {
                implementation(libs.findLibrary("koin.core").get())
                implementation(libs.findLibrary("kotlinx.datetime").get())
                implementation(libs.findLibrary("napier").get())
                implementation(libs.findLibrary("kotlinx.serialization.core").get())
            }
        }
        androidMain {
            dependencies {
                implementation(libs.findLibrary("koin.android").get())
            }
        }
        commonTest {
            dependencies {
                implementation(libs.findLibrary("kotlin.test").get())
            }
        }
    }

Let’s break down the code:

jvmToolchain(17) - Ensures that Kotlin compilation uses Java 17, which is recommended for KMP projects for better compatibility and performance.

Targets: we explicitly declare targets for:

  • Android
  • iOS devices (ARM64 and Intel simulators)
  • iOS simulators on Apple Silicon

This ensures every module compiles for all the platforms your project supports.

sourceSets : Central place to define dependencies:

  • commonMain → shared libraries across all platforms
  • androidMain → Android-specific libraries
  • commonTest → shared testing dependencies for all platforms

Centralized dependencies: By defining common libraries here (DI, Coroutines, Serialization, etc.), all modules automatically get the same baseline, reducing repetition and errors.

This setup ensures that all KMP modules in your project share the same targets and core libraries, making your project consistent and easier to maintain.

Configure Compose Multiplatform

For projects using Compose across Android, iOS, Desktop, and shared modules, we can centralize Compose dependencies with a helper function. This ensures every module uses the same versions and avoids duplication.

internal fun Project.configureComposeMultiplatform(
    extension: KotlinMultiplatformExtension,
) = extension.apply {
    val composeDeps = extensions.getByType<ComposePlugin.Dependencies>()
    sourceSets.apply {
        androidMain {
            dependencies {
                implementation(composeDeps.preview)
                implementation(libs.findLibrary("androidx.activity.compose").get())
            }
        }
        commonMain {
            dependencies {
                implementation(composeDeps.runtime)
                implementation(composeDeps.foundation)
                implementation(composeDeps.material3)
                implementation(composeDeps.ui)
                implementation(composeDeps.components.resources)
                implementation(composeDeps.components.uiToolingPreview)
                implementation(libs.findLibrary("androidx.lifecycle.viewmodel").get())
                implementation(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
                implementation(libs.findLibrary("navigation.compose").get())
            }
        }
        jvmMain.dependencies {
            implementation(composeDeps.desktop.currentOs)
            implementation(libs.findLibrary("kotlinx.coroutinesSwing").get())
        }
    }
}

Let’s break down the code:

  1. composeDeps - Fetches dependencies exposed by the Compose plugin, so versions are centralized.
  2. sourceSets - Organizes platform-specific compose multiplatform dependencies.
  3. Centralized management - By defining Compose dependencies here, all modules remain consistent, reducing errors and simplifying updates when Compose versions change.

Step 3: Turning Configurations into Convention Plugins

Now that we’ve defined reusable helper functions (configureKotlinAndroid, configureKotlinMultiplatform, configureComposeMultiplatform), the next step is to wrap them into actual Gradle convention plugins. This allows us to apply them in modules as plugins.

When creating convention plugins, you can choose to combine configurations together to create plugins. It depends on how you are managing your project structure and build logic. For this example, I’m creating below conventions plugins:

  • KotlinMultiplatformLibraryConventionPlugin - manages KMP and Android Library related configurations and common plugins
  • ComposeMultiplatformConventionPlugin - manages CMP related configurations and common plugins
You can also create a single plugin by combining KMP and CMP configurations together.

To create custom Gradle Convention Plugins, we need to create classes inheriting the Gradle Plugin interface as below.

KotlinMultiplatformLibraryConventionPlugin.kt
class KotlinMultiplatformLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.findPlugin("androidLibrary").get().get().pluginId)
                apply(libs.findPlugin("kotlinMultiplatform").get().get().pluginId)
            }

            extensions.configure<LibraryExtension> {
                configureKotlinAndroid(this)
            }
            extensions.configure<KotlinMultiplatformExtension> {
                configureKotlinMultiplatform(this)
            }
        }
    }
}

ComposeMultiplatformConventionPlugin.kt
class ComposeMultiplatformConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply(libs.findPlugin("composeMultiplatform").get().get().pluginId)
                apply(libs.findPlugin("composeCompiler").get().get().pluginId)
            }

            extensions.configure<KotlinMultiplatformExtension> {
                configureComposeMultiplatform(this)
            }
        }
    }
}

Step 4: Registering custom convention plugins

Once the custom convention plugin classes are created, the next step is to register them so that we can apply those custom convention plugins in our modules. In the build.gradle.kts file of the convention module, declare each custom convention plugin as below.

gradlePlugin {
    plugins {
        register("kotlinMultiplatformLibrary") {
            id = "com.example.app.kotlinMultiplatform.library"
            implementationClass = "KotlinMultiplatformLibraryConventionPlugin"
        }
        register("composeMultiplatform") {
            id = "com.example.app.composeMultiplatform"
            implementationClass = "ComposeMultiplatformConventionPlugin"
        }
    }
}

Step 5: Applying Convention Plugins in Modules

Once plugins are registered, using them in your project modules is clean and simple. Instead of repeating the same configuration across modules, we can simply apply it with apply("com.example.app.kotlinMultiplatform.Library").”

For example, Gradle file of a our module will look like below:

plugins {
    //our custom plugins
    id("com.example.app.kotlinMultiplatform.library")
    id("com.example.app.composeMultiplatform")
}
kotlin {
    sourceSets {
        androidMain.dependencies {
          // additional module specific dependencies
        }
        commonMain.dependencies {
          // additional module specific dependencies
          implementation(libs.ktor.client.core)
        }
        jvmMain.dependencies {
          // additional module specific dependencies
        }
    }
}

This concludes all the steps for creating convention plugins. Build configurations are now centralized and automatically applied across all modules, eliminating the need for duplication. Additionally, module-specific dependencies can be easily added when required.

Let’s compare our Gradle file of a module before and after applying custom convention plugins.

Original Gradle file before using custom convention plugin

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.composeMultiplatform)
}

@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
kotlin {
    jvmToolchain(17)

    androidTarget {
        @OptIn(ExperimentalKotlinGradlePluginApi::class)
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)
        }
    }
    
    jvm()
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "ComposeApp"
            isStatic = true
        }
    }
    
    sourceSets {        
        androidMain.dependencies {
            implementation(compose.preview)
            implementation(libs.androidx.activity.compose)
            implementation(libs.koin.android)
        }
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(compose.components.resources)
            implementation(compose.components.uiToolingPreview)
            implementation(libs.androidx.lifecycle.viewmodel)
            implementation(libs.androidx.lifecycle.runtimeCompose)
            implementation(libs.koin.core)
            implementation(libs.coroutines.core)
            implementation(libs.kotlinx.dateTime)
            implementation(libs.napier)
            implementation(libs.kotlinx.serialization)
        }
        commonTest.dependencies {
            implementation(libs.kotlin.test)
        }
        jvmMain.dependencies {
            implementation(compose.desktop.currentOs)
            implementation(libs.kotlinx.coroutinesSwing)
        }
    }
}
android {
    namespace = "com.example.app.filepicker"
    compileSdk = androidLibs.versions.compileSdk.get().toInt()
    defaultConfig {
        minSdk =  androidLibs.versions.minSdk.get().toInt()
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
    kotlin {
        jvmToolchain(17)
    }
}

New Gradle file after refactoring it with custom Gradle Plugins

plugins {
    //our custom plugins
    id("com.example.app.kotlinMultiplatform.library")
    id("com.example.app.composeMultiplatform")
}
kotlin {
    sourceSets {
        androidMain.dependencies {
          // additional module specific dependencies
        }
        commonMain.dependencies {
          // additional module specific dependencies
        }
        jvmMain.dependencies {
          // additional module specific dependencies
        }
    }
}

There is a huge difference and lots of repetitive configurations transferred to custom plugins. This is just a simple project targeting Android, iOS and Desktop platform. When project got more complex and using multi modules it is becoming easier to manage Gradle scripts with custom Gradle plugins.

Starter Project Template

To help you get understanding project setup with convention plugins easily, I’ve created a starter project template with convention plugins already set up.

👉 GitHub Repository - KMP + CMP Starter with Convention Plugins

Clone it, explore the build-logic module, and start customizing your own project.

Wrapping Up

Convention plugins are a game-changer when working on multimodule Kotlin Multiplatform projects. They keep your build setup clean, consistent, and scalable so you can focus more on writing features rather than fighting Gradle.

If you’re starting a new KMP + CMP project, I strongly recommend setting up convention plugins from the beginning. It might take a little setup upfront, but it pays off big time as your project grows. Once you try this approach, you won’t want to go back.

Happy coding! 🚀

Share this post
Creative Software logomark
Reshaka Weerasinghe
November 7, 2025
5 min read