Mobile Modularization

Production engineering guide for mobile modularization covering patterns, implementation strategies, and operational best practices.

Mobile modularization is the practice of structuring a mobile application as a collection of independently buildable, testable, and deployable components, each responsible for a cohesive set of features or concerns. It matters when teams scale beyond a single monolithic codebase, when multiple teams work on different parts of the app, when release cycles diverge across features, or when shared logic must be reused across iOS and Android with minimal duplication.

Modularizing the Codebase

To extract a feature into a standalone module, begin by defining a clear boundary: a module must have a well-defined API, a single responsibility, and minimal external dependencies. Use standard project structure and build tools common across mobile ecosystems.

For example, in an Android project using Gradle, create a module named feature-user-profile under src/main/java/com/example/myapp/feature/userprofile. This module must declare its public API in a public package and export its dependencies via build.gradle:

// feature-user-profile/build.gradle
plugins {
    id 'com.android.library'
    id 'kotlin-android'
}

android {
    namespace 'com.example.myapp.feature.userprofile'
    compileSdk 34

    defaultConfig {
        minSdk 24
        targetSdk 34
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = '17'
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
    implementation project(':shared-ui')
    implementation project(':domain-models')

    api 'com.google.dagger:hilt-android:2.48'
    annotationProcessor 'com.google.dagger:hilt-compiler:2.48'
}

The api configuration ensures that any consumer of the module can access the public APIs without needing to redeclare transitive dependencies. The implementation scope is for internal use only.

To build and publish the module, run:

./gradlew :feature-user-profile:assembleRelease --configuration=release

This produces feature-user-profile-release.aar, which can be consumed by other modules or included in a standalone APK or AAB.

On iOS, use Swift Package Manager (SPM) to define a module. Create Package.swift in the module directory:

// Package.swift
import PackageDescription

let package = Package(
    name: "UserProfileFeature",
    platforms: [
        .iOS(.v13),
        .macOS(.v11)
    ],
    products: [
        .library(name: "UserProfileFeature", targets: ["UserProfileFeature"]),
    ],
    dependencies: [
        .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.30.0"),
        .package(path: "../SharedUI"),
        .package(path: "../DomainModels"),
    ],
    targets: [
        .target(
            name: "UserProfileFeature",
            dependencies: [
                "SharedUI",
                "DomainModels",
                .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
            ],
            path: "Sources/UserProfileFeature"
        ),
        .testTarget(
            name: "UserProfileFeatureTests",
            dependencies: ["UserProfileFeature"],
            path: "Tests/UserProfileFeatureTests"
        ),
    ]
)

Publish the package by checking in the Package.swift file and tagging a release in Git. Consumers add it via:

// Package.swift (consumer)
dependencies: [
    .package(url: "https://github.com/example/myapp-user-profile", from: "1.0.0")
]

Incremental Build and Dependency Management

With multiple modules, build times grow unless incremental compilation is enforced. Use --build-cache in Gradle to reuse compiled outputs:

./gradlew build --build-cache --configure-on-demand

The --configure-on-demand flag reduces configuration overhead by only loading the build files for modules that are part of the build.

On iOS, SPM automatically caches builds in ~/.build. To enable incremental builds, use --enable-incremental-build in the Swift toolchain or set it in swift build:

swift build --enable-incremental-build --configuration release

A common failure mode is missing api vs implementation in Gradle: a module that depends on UserProfileFeature fails at runtime because it cannot resolve UserProfileViewModel, even though it’s declared in the module’s public API. The error is:

java.lang.NoClassDefFoundError: com/example/myapp/feature/userprofile/UserProfileViewModel
    at com.example.myapp.MainApplication.onCreate(MainApplication.java:18)
Caused by: java.lang.ClassNotFoundException: com.example.myapp.feature.userprofile.UserProfileViewModel
    at java.net.URLClassLoader.findClass(URLClassLoader.java:476)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:654)

The root cause is that the module’s UserProfileViewModel was declared in implementation scope, so it’s not exposed to consumers.

Sharing Logic Across Platforms

Shared modules should be agnostic to platform-specific code. Use a common module that defines interfaces and data models in Kotlin Multiplatform (KMP), and bridge them to native code via expect/actual.

Define a common module with shared-models/src/commonMain/kotlin/:

// shared-models/src/commonMain/kotlin/User.kt
expect class User(
    val id: String,
    val name: String,
    val email: String
)

expect fun User.toDisplayString(): String

On iOS, implement the actual class:

// shared-models/src/iosMain/kotlin/User.swift
import Foundation

public class User {
    public let id: String
    public let name: String
    public let email: String

    public init(id: String, name: String, email: String) {
        self.id = id
        self.name = name
        self.email = email
    }
}

public extension User {
    func toDisplayString() -> String {
        return "\(name) <\(email)>"
    }
}

On Android, implement the actual class:

// shared-models/src/androidMain/kotlin/User.kt
import androidx.annotation.Keep

@Keep
class User(
    val id: String,
    val name: String,
    val email: String
) {
    fun toDisplayString(): String = "$name <$email>"
}

The @Keep annotation in Kotlin prevents ProGuard from stripping the class in release builds.

A subtle failure occurs when a module defines a User class in common but another module uses a different User in androidMain. The expect declaration is not matched, and User from shared-models is not resolved at compile time, leading to a mismatched type error at runtime.

Versioning and Release Strategy

Modules must be versioned independently. Use semantic versioning (MAJOR.MINOR.PATCH) and publish to a package registry.

For Android, publish to a Maven repository:

./gradlew :feature-user-profile:uploadArchives

This uploads feature-user-profile-release.aar and feature-user-profile-release.aar.md5 to https://repo.example.com/maven/.

On iOS, use GitHub Packages:


git tag -a v1.1.0 -m "Feature: Add profile photo upload"
git push origin v1.1.0

The Package.swift file then references the version:

.package(url: "https://github.com/example/myapp-user-profile", from: "1.1.0")

A frequent failure: a module is updated, but the consumer does not update its Package.swift or build.gradle, leading to a version mismatch. The app runs with old logic, and new features do not appear.

To detect such mismatches, add a build step that verifies module versions at build time:

# In CI/CD, after dependency resolution
for module in $(find . -name Package.swift -type f); do
    dir=$(dirname "$module")
    version=$(grep -o 'from: "[^"]*"' "$module" | head -1 | sed 's/.*"//;s/"//')
    if ! git describe --tags --exact-match "$version" > /dev/null 2>&1; then
        echo "⚠️ Version $version not found in git history for $dir"
        exit 1
    fi
done

Testing and Isolation

Each module must have its own test suite, runnable independently.

On Android, use test and androidTest source sets:

// feature-user-profile/build.gradle
android {
    sourceSets {
        test {
            setRoot('src/test')
        }
        androidTest {
            setRoot('src/androidTest')
        }
    }
}

Run tests for a single module:

./gradlew :feature-user-profile:test

On iOS, use swift test:

cd feature-user-profile
swift test --enable-coverage

A common failure: test setup is not isolated. A test in UserProfileFeature depends on a shared database setup, but the test runs with a different database schema than expected. The error is:

java.sql.SQLSyntaxErrorException: Table 'users' doesn't exist
    at org.h2.jdbc.JdbcSQLException.generateMessage(JdbcSQLException.java:124)
    at org.h2.jdbc.JdbcSQLException.<init>(JdbcSQLException.java:107)
    at org.h2.message.DbException.getException(DbException.java:345)
    at org.h2.command.PreparedQuery.<init>(PreparedQuery.java:62)
    at org.h2.command.Command.prepare(Command.java:125)
    at org.h2.engine.Session.prepareCommand(Session.java:625)
    at org.h2.jdbc.JdbcPreparedStatement.<init>(JdbcPreparedStatement.java:107)
    at org.h2.jdbc.JdbcConnection.prepareStatement(JdbcConnection.java:755)
    at com.example.myapp.feature.userprofile.UserRepository.fetchUser(UserRepository.kt:27)

The cause is that UserProfileFeature expects a users table, but the test database was created by SharedUI module, which uses user_profiles instead.

To fix, define a test database schema in the module’s test source set:

-- feature-user-profile/src/test/resources/db/migration/V1__create_users_table.sql
CREATE TABLE users (
    id VARCHAR(36) PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL
);

Use Flyway or Liquibase to manage migrations, and run them before tests.

Dependency Resolution and Conflicts

When multiple modules depend on the same library, version conflicts arise. Use resolutionStrategy in Gradle:

// feature-user-profile/build.gradle
configurations.all {
    resolutionStrategy {
        force 'org.jetbrains.kotlin:kotlin-stdlib:1.9.0'
        preferProjectModules()
        cacheDynamicVersionsFor 120, 'minutes'
    }
}

The preferProjectModules() flag ensures that modules defined in the project take precedence over external dependencies.

A subtle failure: two modules depend on dagger, but one uses 2.45 and the other 2.46. The app runs with 2.46, but a @Component in UserProfileFeature fails because it expects 2.45 in the @Component annotation. The error:

java.lang.NoSuchMethodError: dagger.Component.getUsersComponent()Ldagger/Component;
    at com.example.myapp.feature.userprofile.UserProfileModule.configure(UserProfileModule.java:34)

The cause is that the UserProfileModule class was compiled against dagger-2.45, but the runtime classpath includes dagger-2.46. The getUsersComponent() method was added in 2.46, but 2.45 does not have it.

To avoid this, enforce consistent dependency versions across the monorepo:

# In CI/CD
./gradlew dependencies --configuration=runtimeClasspath

This generates a dependency report that highlights conflicts. Use dependencyUpdates plugin to detect outdated versions.

Evolving the Modular Structure

As the app grows, modules become too large. Split a monolithic module into smaller ones:

# Move feature into submodules
mkdir -p feature-user-profile/{profile,settings,avatar}
mv src/main/java/com/example/myapp/feature/userprofile/{UserProfileViewModel.java,UserProfileActivity.java} src/main/java/com/example/myapp/feature/userprofile/profile/
mv src/main/java/com/example/myapp/feature/userprofile/{UserSettingsFragment.java} src/main/java/com/example/myapp/feature/userprofile/settings/

Update the build.gradle to reflect the new structure and declare new modules. Consumers must now depend on feature-user-profile-profile, feature-user-profile-settings, and feature-user-profile-avatar.

A failure: the UserProfileActivity in profile module depends on UserSettingsFragment in settings, but the settings module is not declared as a dependency in profile’s build.gradle. The error:

java.lang.NoClassDefFoundError: com/example/myapp/feature/userprofile/settings/UserSettingsFragment
    at com.example.myapp.feature.userprofile.profile.UserProfileActivity.onCreate(UserProfileActivity.java:19)
Caused by: java.lang.ClassNotFoundException: com.example.myapp.feature.userprofile.settings.UserSettingsFragment

The fix is to add:

dependencies {
    implementation project(':feature-user-profile-settings')
}

Summary

Mobile modularization is not just splitting code into folders. It is a disciplined practice of defining clear boundaries, managing dependencies, versioning independently, testing in isolation, and evolving the structure over time. It succeeds when each module can be developed, tested, and released without affecting others. It fails silently when module boundaries are blurred, dependency versions drift, and consumers forget to update their dependencies. The result is a mobile app that scales not just in code, but in team velocity and technical sustainability.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.