Introduction
The Default Hierarchy Template in KMP projects is a great way to reduce boilerplate code and start working quickly. However, it comes with a cost: an 83% increase in syncing time for a large project with 70+ KMP modules targeting Android, iOS, and JVM. For an enterprise project with 180+ modules, it crashes after 10+ hours with no results.
This wasn't a misconfiguration or a rogue plugin. The culprit? A single, seemingly innocent line of code introduced with Kotlin 1.9.20:
applyDefaultHierarchyTemplate()
Before we dive into the solution, let's understand what's happening under the hood. What are hierarchy templates, and why does the default one create such a performance bottleneck?
What Are Hierarchy Templates in Kotlin Multiplatform?
At its core, Kotlin Multiplatform is built on a elegant but complex system of source sets—logical collections of code that share common dependencies and compilation settings.
When you create a KMP project, you declare targets (the platforms you're compiling for) and source sets (where your code lives):
kotlin {
androidTarget()
jvm()
iosArm64()
iosX64()
iosSimulatorArm64()
}
Each target automatically gets its own source set (androidMain, jvmMain, iosArm64Main), where you can write platform-specific code with access to platform APIs. But the real power of KMP lies in commonMain—code written here is shared across all your targets.
The dependsOn Relationship: Connecting the Dots
Source sets form a hierarchy through the dependsOn relationship. When iosArm64Main depends on commonMain, it can access all the code written in the common source set. This relationship creates a directed graph that determines:
- Code visibility - Which declarations are accessible where
-
Dependency propagation - Libraries added to
commonMainflow down to all dependent source sets - API safety - The compiler ensures you only use APIs available on all platforms a source set compiles to
Intermediate Source Sets: The Middle Ground
Here's where it gets interesting. What if you want to share code between some platforms, but not all?
Imagine you have iOS-specific logic that works across all iOS variants (arm64 for devices, x64 for Intel simulators, simulatorArm64 for Apple Silicon simulators). You don't want to duplicate this code in three places, but you also can't put it in commonMain because it uses iOS-specific APIs.
Enter intermediate source sets. An iosMain source set sits between commonMain and your platform-specific iOS source sets, allowing you to:
- Access iOS-specific APIs (like Foundation framework)
- Share that code across all iOS targets
- Keep it separate from Android and JVM code
This hierarchy might look like:
commonMain
├── androidMain
├── jvmMain
└── iosMain (intermediate)
├── iosArm64Main
├── iosX64Main
└── iosSimulatorArm64Main
What Hierarchy Templates Do
Manually creating intermediate source sets and wiring up all the dependsOn relationships was tedious and error-prone. You'd write something like:
val iosMain by creating {
dependsOn(commonMain.get())
}
val iosArm64Main by getting {
dependsOn(iosMain)
}
// ... repeat for each iOS target
Hierarchy templates automate this boilerplate. They're predefined blueprints that analyze your declared targets and automatically create the appropriate intermediate source sets with the correct dependency relationships.
Starting with Kotlin 1.9.20, the default hierarchy template became active automatically, eliminating the need to manually configure iOS source sets. Sounds great, right?
It is—until it isn't.
The Default Hierarchy Template in Action
To understand the performance problem, we need to see what the default template actually does.
When you call applyDefaultHierarchyTemplate() (or let it apply automatically), the Kotlin Gradle Plugin analyzes your targets and creates intermediate source sets based on a comprehensive, predefined structure designed to support all possible Kotlin Multiplatform targets.
Let's consider a common real-world scenario. Your project targets:
kotlin {
applyDefaultHierarchyTemplate()
androidTarget()
jvm()
iosArm64()
iosX64()
iosSimulatorArm64()
}
You might expect a simple hierarchy:
commonMain
├── androidMain
├── jvmMain
└── iosMain
├── iosArm64Main
├── iosX64Main
└── iosSimulatorArm64Main
But here's what the default template actually creates:
commonMain
├── androidMain
├── jvmMain
├── nativeMain (shared by ALL native targets)
└── appleMain (shared by ALL Apple targets)
└── iosMain (shared by iOS targets)
├── iosArm64Main
├── iosX64Main
└── iosSimulatorArm64Main
Notice the extra layers: nativeMain and appleMain. These intermediate source sets don't exist in your project structure—you don't have src/nativeMain or src/appleMain directories. They're purely conceptual, created by the template to enable code sharing in scenarios like:
-
nativeMain: Share code across all Kotlin/Native targets (iOS, macOS, Linux, Windows Native, watchOS, tvOS, etc.) -
appleMain: Share code across all Apple platforms (iOS, macOS, watchOS, tvOS)
The design philosophy is sound. The default template optimizes for the most comprehensive code-sharing scenario. If you later add macosArm64() to your targets, it will automatically slot into the existing hierarchy under appleMain, and any code you've written there will just work.
This is "convention over configuration" at its finest—the template handles the complexity for you.
But here's the critical question: What if you're never going to target macOS, Linux, or tvOS? What if your "native" targets are only iOS?
The Hidden Cost: A Task Explosion
Source sets aren't just a conceptual model—they have real, tangible consequences in your build system. Every source set in your hierarchy triggers the creation of multiple Gradle tasks.
When the Kotlin Gradle Plugin processes your source set hierarchy, it generates tasks for each source set. The pattern is predictable and measurable.
The results were striking:
- Optimized template: 158 tasks per module
- Default template: 166 tasks per module
- Difference: 8 extra tasks per module
Extrapolate to our production codebase with 70 modules, and you're looking at 560 wasteful tasks. In our enterprise codebase with 180+ modules we have "only" 1440 wasteful tasks 🫣.
For every intermediate source set (nativeMain, appleMain), Gradle creates a family of tasks:
-
compile<SourceSet>KotlinMetadata- Compiles the source set into platform-agnostic Kotlin IR (Intermediate Representation) stored in a.klibfile -
metadata<SourceSet>Classes- Assembles compilation outputs -
metadata<SourceSet>ProcessResources- Processes resources for the source set -
transform<SourceSet>DependenciesMetadata- Generates serialized dependency metadata for IDE tooling
Task Deep Dive: The Metadata Compilation Tasks
compileNativeMainKotlinMetadata and compileAppleMainKotlinMetadata are responsible for compiling the (conceptual) nativeMain and appleMain source sets into Kotlin metadata.
Here's the problem: These source sets have no code. We don't have src/nativeMain/kotlin or src/appleMain/kotlin directories because we're not sharing any code at those levels. Yet the Kotlin compiler still runs, processing an empty source set, generating an (essentially empty) .klib file.
The source sets exist in the dependency graph because the template created them. The iosArm64Main compilation needs to know what APIs are available from appleMain, which needs to know what's available from nativeMain. Even if those source sets are empty, the metadata must be compiled to satisfy the dependency chain.
Think of it like compiling an empty .kt file—the compiler still has to initialize, parse (nothing), run analysis passes, and write output. The overhead isn't zero.
Task Deep Dive: The IDE Transform Tasks
transformNativeMainCInteropDependenciesMetadataForIde and transformAppleMainCInteropDependenciesMetadataForIde are even more insidious.
If you have tests under iosTest you will get an extra transformNativeTestCInteropDependenciesMetadataForIde and transformAppleTestCInteropDependenciesMetadataForIde as well.
These tasks exist specifically for IDE support. When you sync your project in Android Studio or IntelliJ IDEA, these tasks run to process C-interop dependencies (Kotlin/Native bindings to C/Objective-C libraries) and make them understandable to the IDE's code analysis engine.
The irony? Our project has no C-interop dependencies in nativeMain or appleMain because those source sets don't exist in our codebase. We're transforming... nothing.
But the task still runs. It still needs to:
- Resolve the dependency graph for the source set
- Check for C-interop
.klibfiles - Process (empty) results
- Write metadata for the IDE
These tasks had a significant impact on our codebase. After we introduced the Default Hierarchy Template to our project, multiple developers shared their experiences of syncing issues.
The Solution: Custom Optimized Hierarchy
Once we understood the problem, the solution became clear: build exactly the hierarchy we need, no more, no less.
Kotlin provides the applyHierarchyTemplate() DSL for precisely this purpose—defining custom hierarchies that match your project's actual structure.
The Optimized Hierarchy
Instead of the default template's deep, general-purpose hierarchy, we created a minimal, flat structure:
kotlin {
applyHierarchyTemplate {
common {
withAndroidTarget()
withJvm()
group("ios") {
withIosArm64()
withIosX64()
withIosSimulatorArm64()
}
}
}
androidTarget()
jvm()
iosArm64()
iosX64()
iosSimulatorArm64()
}
This creates the hierarchy:
commonMain
├── androidMain
├── jvmMain
└── iosMain
├── iosArm64Main
├── iosX64Main
└── iosSimulatorArm64Main
Notice what's missing: nativeMain and appleMain. We've collapsed the hierarchy to only include the intermediate source sets we actually use.
With that configuration, we reduced 83% sync time in our 70+ modules project, and the 180+ modules project was working again ✨.
When to Use Default vs Custom Hierarchy
The default hierarchy template isn't inherently bad—it's solving for a different use case than ours. Understanding when to use each approach is critical.
If your project genuinely targets macOS, Linux, Windows, iOS, and watchOS, the nativeMain source set becomes valuable. You want to share native-specific code across all these platforms, so the Default Hierarchy is gold here.
In the other hand, if you starting a new project and not sure if you'll add macOS support in six months, the default template provides a stable foundation that scales as you add targets. The performance cost on a small project (<20 modules) may be negligible.
However, if "native" means exclusively iOS in your project, nativeMain and appleMain are dead weight. The task multiplication effect becomes severe at scale, as it adds 8-10 tasks per module.
So, when to use Default Hiearchy Template? Sorry, but "it depends" 🫠.
Conclusion
The default hierarchy template in Kotlin Multiplatform is a powerful tool that embodies the "convention over configuration" philosophy. For many projects, it's the right choice—it simplifies setup, reduces boilerplate, and scales effortlessly as you add targets.
But as our story demonstrates, the default optimizes for maximum flexibility, not maximum performance. When you know your platform constraints (iOS-only native targets) and operate at scale (70+ modules), that flexibility becomes a liability. You're paying the build-time cost of supporting platforms you'll never target.
Our 83% improvement in Gradle sync time—from 1 hour 20 minutes to 14 minutes—came from a simple realization: we don't need a hierarchy designed for the entire Kotlin Multiplatform universe. We need one designed for our project.
The applyHierarchyTemplate() DSL gave us the precision to define exactly that. By eliminating nativeMain and appleMain, we removed hundreds of wasteful tasks, slashed configuration time, and made our 180+module project usable again.
That's it! ✌️ Hope you can apply to our project today and give your day a performance boost!
Top comments (0)