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.
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")
]
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.
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.
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
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.
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.
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')
}
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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy