Kotlin Multimodule Projects: Organizing for Scale

Jeen Broekstra · April 20, 2026

In my previous post on Kotlin package conventions, I discussed how to structure your Kotlin code within a single module. Today, I want to dive deeper into Gradle multimodule projects - how they work, what advantages they offer, and how they relate to package organization.

Why Multimodule?

As your Kotlin project grows, you’ll inevitably face challenges with:

  • Build times: Everything recompiles when you change one file
  • Dependency management: Different parts of your app need different dependencies
  • Team coordination: Multiple developers working on the same codebase
  • Code organization: Keeping related functionality together while maintaining separation of concerns

Multimodule projects address these challenges by breaking your monolithic codebase into smaller, focused modules.

What is a Gradle Multimodule Project?

A Gradle multimodule project is a collection of related projects that are developed, built, and tested together while maintaining clear boundaries between components. Let’s break down the key components and how they work together:

Core Components

  1. Root Project (build.gradle.kts):
    • Defines global configuration shared across all modules
    • Declares plugins and dependencies that all modules can use
    • Configures build-wide settings like repositories and global properties
  2. Subprojects/Modules:
    • Self-contained units with their own build.gradle.kts files
    • Can depend on other modules within the same project
    • Each module has its own source code, resources, and dependencies
  3. Settings File (settings.gradle.kts):
    • The “table of contents” for your multimodule project
    • Explicitly lists which modules are included in the build
    • Defines the project hierarchy and module names

How the Files Relate: A Concrete Example

Let’s examine how this works with a practical example. We’ll go old-skool and imagine we’re building a customer relationship management (CRM) system where different teams work on different aspects of the application.

Here’s what the directory structure could look like:

my-crm-project/
├── build.gradle.kts          # Root build script (shown below)
├── settings.gradle.kts       # Module definitions (shown below)
├── gradle.properties         # Global properties
├── app/                     # Main application module
│   ├── build.gradle.kts
│   └── src/
│       └── main/kotlin/com/example/
│           └── Main.kt      # Application entry point
├── core/                    # Shared core functionality
│   ├── build.gradle.kts
│   └── src/
│       └── main/kotlin/com/example/core/
│           ├── data/        # Shared data models
│           ├── domain/      # Core business logic
│           └── utils/       # Utility functions
├── data/                    # Data access layer
│   ├── build.gradle.kts
│   └── src/
│       └── main/kotlin/com/example/data/
│           ├── repository/  # Repository implementations
│           └── api/         # Data access interfaces
└── features/                # Feature modules
    ├── user-management/      # User management feature
    │   ├── build.gradle.kts
    │   └── src/
    │       └── main/kotlin/com/example/user_management/
    │           ├── ui/        # User interface components
    │           ├── domain/    # User-specific business logic
    │           └── data/      # User data access
    └── reporting/            # Reporting feature
        ├── build.gradle.kts
        └── src/
            └── main/kotlin/com/example/reporting/
                ├── service/  # Report generation services
                └── export/   # Report export functionality

The app module serves as the main entry point, containing the application’s main() function and coordinating the overall flow. It depends on all other modules but contains minimal business logic itself - primarily handling configuration and module orchestration.

The core module contains the fundamental building blocks that everything else depends on. This is where shared data models and core utilities live. For example, the Customer data class with validation rules would be here, ensuring all modules work with consistent customer data.

Other modules build on this foundation: data handles persistence, while feature modules contain self-contained business functionality. This structure allows different teams to work independently - the database team on data, the authentication team on user-management - without stepping on each other’s code.

Now let’s look at the Gradle configuration (slightly simplified - these snippets are intended to get the point across, not work out of the box):

Root build.gradle.kts:

// Global plugins that modules can apply
plugins {
    kotlin("jvm") version "1.9.0" apply false
    `java-library` version "8.1.0" apply false
}

// Shared dependency versions
ext {
    val kotlinVersion = "1.9.0"
}

// Configuration applied to all modules
allprojects {
    repositories {
        mavenCentral()
    }
}

// Common configuration for all Kotlin modules
subprojects {
    apply(plugin = "org.jetbrains.kotlin.jvm")
    group = "com.example"
    version = "1.0.0"
}

This root build file sets up the foundation for all modules: it makes plugins available (without immediately applying them), defines shared versions of dependencies, sets a common repository config, and other project-wide things, like compilation settings.

Settings File (settings.gradle.kts):

rootProject.name = "MyKotlinProject"

// Include all modules - this creates the project hierarchy
include(":app")
include(":core")
include(":data")
include(":features:user-management")
include(":features:reporting")

The settings file is simple but crucial - it defines which modules exist and their names. Gradle uses this to discover the module structure and build the dependency graph.

Module Dependencies: The Wiring Between Components

Dependencies between modules are declared in each module’s build.gradle.kts file using project references. This creates a clean hierarchy where the app module depends on features, features depend on data and core, and core remains independent.

This structure ensures core functionality is reusable while keeping feature modules independent. Let’s look at the specific declarations:

Main Application Module (app/build.gradle.kts):

plugins {
    kotlin("jvm")
    application
}

mainClass.set("com.example.MainKt")

dependencies {
    // Dependency on the core module
    implementation(project(":core"))

    // Dependency on feature modules
    implementation(project(":features:user-management"))
    implementation(project(":features:reporting"))

    // External dependencies
    implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.5")
}

The app module applies the application plugin (indicating it’s the runnable main module) and declares dependencies on all other modules. It’s the only module that should depend on feature modules directly.

Core Module (core/build.gradle.kts):

plugins {
    kotlin("jvm")
    `java-library`
}

dependencies {
    // Only core dependencies here - no feature modules!
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
}

The core module is the foundation - it should never depend on feature modules or the app module. It contains shared code that other modules can use, like utility functions, data models, and common interfaces.

Data Module (data/build.gradle.kts):

plugins {
    kotlin("jvm")
    `java-library`
}

dependencies {
    // Depends on core for shared models and utilities
    implementation(project(":core"))

    // Database dependencies
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
}

The data module handles persistence and depends on core for shared data models. It’s a good place for repository interfaces, database access code, and data mapping logic.

Feature Module (features/user-management/build.gradle.kts):

plugins {
    kotlin("jvm")
    `java-library`
}

dependencies {
    // Can depend on core and data, but not on other features!
    implementation(project(":core"))
    implementation(project(":data"))

    // Feature-specific dependencies
    implementation("io.github.micronaut:micronaut-validation:4.0.0")
}

Feature modules contain self-contained business functionality. They can depend on core and data modules but should never depend on other feature modules to maintain clean separation.

Dependency Flow and Build Order

When you build this project, Gradle first reads settings.gradle.kts to discover all modules and their relationships. It then compiles them in dependency order: coredatafeaturesapp.

The real power comes with incremental builds. If you modify a core data model, only dependent modules rebuild. Change a feature-specific file, and only that feature and the main app rebuild. This selective compilation dramatically improves build times in large projects.

Key Dependency Management Principles

The dependency structure follows a clean hierarchy:

graph TD
  app --> user-management
  app --> reporting
  user-management --> data
  reporting --> data
  data --> core
  core -->|no dependencies| nothing

This shows how app depends on features, features depend on data and core, but features don’t depend on each other. Dependencies flow downward, preventing circular dependencies and keeping the architecture clean.

Transitive dependencies work automatically - if core uses coroutines, all modules get access without redeclaring the dependency. This reduces duplication while maintaining clear module boundaries.

Practical Benefits of This Structure

Multimodule projects offer significant advantages over monolithic codebases:

Faster Builds: Changes in one module only rebuild that module and its dependents. Modify a core data model, and only dependent modules rebuild. Change a feature-specific file, and only that feature and the main app rebuild. This selective compilation dramatically improves development speed.

Clear Boundaries: Modules can’t accidentally depend on each other. The core stays independent and reusable. Circular dependencies are impossible by design. Each module has a single, well-defined responsibility.

Better Testing: Test modules in isolation or combination. Unit test core logic without database dependencies. Integration test features with their specific dependencies. This modular testing approach leads to more reliable code.

Improved Collaboration: Different teams can work on different modules independently. Clear interfaces reduce conflicts. The architecture naturally enforces separation of concerns.

Easier Maintenance: Isolate bug fixes to specific modules. Add features without risking unrelated functionality. Replace or upgrade modules more easily. New developers can focus on relevant modules rather than the entire codebase.

Package Organization Across Modules

In my previous post on Kotlin package conventions, we discussed how to structure packages within a single module. With multimodule projects, package organization becomes even more important, building on those same principles but adding another layer of structure.

Each module should have its own root package that matches its purpose. This approach offers significant advantages:

// In core module
package com.example.core.data
package com.example.core.domain
package com.example.core.utils

// In user-management feature module
package com.example.user_management.ui
package com.example.user_management.domain
package com.example.user_management.data

Benefits of module-level packages:

  1. Clear Ownership: Each module’s code has a distinct namespace, making it immediately obvious which module a class belongs to. When you see com.example.core.data.CustomerRepository, you know it’s in the core module.

  2. Prevents Collisions: Without module-specific packages, you risk naming conflicts. Two modules might want to define a UserService class, but with separate packages (com.example.core.user.UserService vs com.example.user_management.UserService), there’s no conflict.

  3. Enforces Boundaries: Module-level packages make it harder to accidentally create circular dependencies. If module A tries to import from module B’s package, it’s immediately visible in the import statement.

  4. Better Navigation: In IDEs, you can quickly jump to a module’s code by its package structure. Need to find core functionality? Look in com.example.core.*.

  5. Easier Refactoring: Moving code between modules becomes clearer when packages reflect module boundaries. The package name changes make it obvious what was moved.

Avoiding Circular Dependencies

One of the biggest benefits of multimodule projects is the enforced dependency hierarchy that prevents circular dependencies. Circular dependencies create tight coupling that makes your codebase fragile. If module A depends on module B, and module B depends on module A, you create a situation where:

  1. Changes become risky: Modifying one module might break the other in unexpected ways
  2. Testing becomes harder: You can’t test modules in isolation because they’re interdependent
  3. Builds become slower: Changes in one module force rebuilds of the circular dependency chain
  4. Refactoring becomes difficult: It’s hard to modify or replace modules that are circularly dependent

The multimodule structure with clear package organization naturally prevents these problems by making dependency relationships explicit and visible.

Conclusion

Gradle multimodule projects provide powerful tools for organizing growing Kotlin codebases, going beyond what packages can offer. Modules reduce build times through incremental compilation, enforce architectural boundaries, enable parallel development, and generally improve code organization and maintainability.