Componentization Architecture Landscape: From Big Ball of Mud to Layered Modularization
In its early days, an Android project usually consists of a single app module—all code is stuffed into this "super package". An Activity directly instantiates a Repository via new, the Repository references constants from the UI layer in return, and utility classes are scattered randomly across packages. Anyone modifying a network request callback could accidentally trigger a full rebuild of the entire project.
This is what we call a "Big Ball of Mud"—a chaotic state characterized by blurred code boundaries, high inter-module coupling, and unpredictable butterfly effects. When the team scales from 3 to 30 engineers, and features bloat from 10 to 200 screens, this mud ball becomes an engineering nightmare: an 8-minute build time, inevitable Git merge conflicts, and fixing one bug only to introduce three new ones.
Componentization Architecture is a systematic engineering solution to this crisis. It is not a framework or a library, but a design philosophy of Divide and Conquer—splitting a massive monolithic application into a combination of independent, well-bounded modules that possess single responsibilities and can be compiled and debugged in isolation.
As the overarching guide to the "Componentization Architecture" topic, this article will establish a holistic understanding of the entire componentization ecosystem: from the Why, to the logic of layered architecture, and finally to the underlying mechanisms guaranteeing module boundaries. Subsequent deep-dive articles will dissect the internal workings of the ARouter framework and the intricacies of Gradle dependency governance.
Pathology of the Big Ball of Mud: Why Monoliths Inevitably Collapse
Before diving into architectural solutions, we must thoroughly diagnose the root causes of the "Big Ball of Mud"—this dictates the Why behind every layer of our subsequent design.
Symptom 1: Compilation Avalanche
Under a single-module architecture, Gradle's incremental compilation model faces a severe problem: The compilation boundary equals the entire project.
Gradle's incremental compilation relies on ABI (Application Binary Interface) change detection. When a .kt or .java file is modified, Gradle checks if its public API has changed. If it has, every compilation unit depending on that file must be recompiled. In a single-module architecture, all files reside in the same compilation unit—meaning a signature change in a simple utility class could trigger the recompilation of thousands of files.
Compilation Impact Radius in a Single-Module Architecture:
Modify a method signature in NetworkUtils.kt
↓
Gradle detects an ABI change
↓
Recompile the entire app module (2000+ files)
↓
Build time: 8 minutes
A multi-module architecture limits this "blast radius" to within the module:
Compilation Impact Radius in a Multi-Module Architecture:
Modify NetworkUtils.kt inside the :core:network module
↓
Gradle detects an ABI change
↓
Only recompile :core:network (50 files)
+ modules that directly depend on it AND use the changed API
↓
Build time: 40 seconds
Symptom 2: Dependency Hell
There is no compilation-level access control within a single module. Kotlin's internal modifier is effectively equal to public in a single-module project because the entire project is the module. This renders "internal implementations" useless, allowing any developer to directly reference any class.
// Intended to be an internal implementation of :feature:login
// But in a single-module setup, :feature:order can simply import it
internal class LoginTokenManager {
fun getToken(): String = "..."
}
// Code in other functional areas references it freely
class OrderSubmitter {
// Calling across "boundaries" directly—compiles fine, architecture rots
val token = LoginTokenManager().getToken()
}
Symptom 3: Collaboration Quagmire
When 10 developers modify code in the same module, Git merge conflicts become a daily routine. More critically, without physical boundaries, it's difficult to define "who owns what code." A strings.xml file might be simultaneously modified by three different teams, and a single Application.onCreate() might bear the initialization logic for a dozen disparate SDKs.
We can summarize these issues in a pathology diagnostic table:
| Symptom | Root Cause | Componentization Solution |
|---|---|---|
| Long build times | Compilation boundary = Entire project | Split into independent compilation units to limit change propagation. |
| Arbitrary cross-references | No compile-time access control | Isolate visibility via Gradle implementation. |
| Frequent merge conflicts | Everyone edits the same module | Each team owns independent module repositories. |
| Resource naming collisions | Global resource merging | Force namespaces via resourcePrefix. |
| Inability to debug independently | Only one startup entry point | Dynamic switching between application / library modes. |
Modularization vs. Componentization: Conceptual Clarification
In the Android ecosystem, "Modularization" and "Componentization" are often used interchangeably. An analogy clears up the difference:
Modularization is "partitioning a room"—dividing a large studio into a bedroom, kitchen, and bathroom, each with a specific purpose. Componentization is "building an apartment complex"—each unit not only has independent rooms but also its own door, its own utility meters, and can be rented out independently.
More precisely:
| Dimension | Modularization | Componentization |
|---|---|---|
| Core Objective | Logical separation of code | Physical isolation of business features |
| Independent Execution | Not required | Required (Can be compiled as a standalone APK for debugging) |
| Communication | Direct dependencies allowed | Must be decoupled via routing / interfaces |
| Gradle Plugin | Usually fixed as library |
Dynamically switches between application / library |
| Team Boundaries | Logical division | Physical division (Teams own different components) |
Google's official documentation uses the term Modularization, which aligns closer to the former concept. However, the term "Componentization" popular in the Chinese Android community builds upon modularization by adding two core requirements: Independent Compilation/Debugging and Decoupled Routing Communication.
This article discusses Componentization in its broader sense—encompassing the layered design of modularization alongside the engineering practices of independent execution and decoupled communication.
The Four-Tier Architecture Model: The Skeleton of Componentization
An industrial-grade componentization architecture is typically divided into four tiers. From top to bottom:
┌─────────────────────────────────────────────┐
│ App Shell │ ← Shell App: Entry, integration, global config
├──────────┬──────────┬──────────┬─────────────┤
│ :feature │ :feature │ :feature │ :feature │ ← Business Components: Independent feature modules
│ :login │ :home │ :order │ :payment │
├──────────┴──────────┴──────────┴─────────────┤
│ Module-API Layer │ ← Contract Layer: Interfaces, DTOs, routing definitions
│ (:login-api) (:order-api) (:pay-api) │
├──────────────────────────────────────────────┤
│ Core Components │ ← Infrastructure: Network, DB, UI components
│ :core:network :core:database :core:ui │
│ :core:common :core:designsystem │
└──────────────────────────────────────────────┘
Tier 1: App Shell
The Shell is the entry module of the application. It acts like a Final Assembly Plant: it doesn't manufacture parts itself; it merely assembles various business components together.
// build.gradle.kts of the :app module
plugins {
id("com.android.application")
}
dependencies {
// The shell only "assembles" by depending on business components
implementation(project(":feature:login"))
implementation(project(":feature:home"))
implementation(project(":feature:order"))
implementation(project(":feature:payment"))
// Depends on core components
implementation(project(":core:network"))
implementation(project(":core:designsystem"))
}
Responsibilities of the App Shell:
- App Entry Point: Declares the
Applicationclass, executes global initializations. - Navigation Graph Assembly: Registers pages from feature modules into the global navigation graph.
- Root DI Configuration: Configures top-level modules for Hilt or Dagger.
- ProGuard / R8 Rule Aggregation: Centrally manages obfuscation rules.
There is one ironclad rule for the App Shell: It must contain NO business logic. If you write an if (userType == VIP) branch inside the shell, business code has leaked into the shell layer—a blatant signal of architectural rot.
Tier 2: Feature Modules (Business Components)
Each business component encapsulates a complete business feature. It should be highly cohesive—the login component contains its own UI, ViewModel, Repository, and Data Sources, forming a self-contained vertical slice.
:feature:login/
├── src/main/
│ ├── java/com/example/login/
│ │ ├── ui/ # Login UI
│ │ │ ├── LoginScreen.kt
│ │ │ └── LoginViewModel.kt
│ │ ├── data/ # Data layer
│ │ │ ├── LoginRepository.kt
│ │ │ └── AuthDataSource.kt
│ │ └── di/ # DI configuration
│ │ └── LoginModule.kt
│ ├── res/ # Module-private resources
│ └── AndroidManifest.xml
└── build.gradle.kts
The core rule between feature modules is absolute: Feature modules must NEVER directly depend on each other.
✗ FORBIDDEN ✓ ALLOWED
:feature:login ──→ :feature:order :feature:login ──→ :order-api
:feature:order ──→ :order-api (implements it)
If the login module needs to invoke a capability from the order module (e.g., "Check if the user has pending orders"), it cannot import any classes from the order module. It can only depend on the contract interfaces exposed by the order module (the Module-API layer below).
Tier 3: Module-API Layer (Contract Layer)
This is the most elegant design layer in the entire architecture. Its philosophy originates from the Anti-Corruption Layer (ACL) in Domain-Driven Design (DDD) and the Dependency Inversion Principle (DIP) from SOLID.
Use an analogy to understand its role:
The Module-API is like "Customs" in international trade. Direct "smuggling" of goods between two countries (business components) is prohibited; everything must pass through Customs. Customs defines standardized declaration forms (interfaces and DTOs), and goods must be converted into this format to pass. Even if the internal systems of one country completely change, as long as the declaration form remains the same, the other country is unaffected.
Implementation: Create a lightweight -api module for each business component, containing only three types of artifacts:
- Interfaces: Capabilities exposed by the component.
- Data Transfer Objects (DTOs): Data structures passed across module boundaries.
- Route Constants: Path definitions for page navigation.
// :feature:order-api module
// This module only has interfaces and data classes, ZERO implementation
/**
* Public contract for the Order Service.
* Other modules depend on this interface to acquire order capabilities, NOT the order implementation module.
*/
interface IOrderService {
/** Check if the user has pending orders */
suspend fun hasPendingOrder(userId: String): Boolean
/** Get order summary */
suspend fun getOrderSummary(orderId: String): OrderSummaryDTO
}
/**
* Order summary data passed across modules.
* Note: This is an API-layer DTO, NOT the database Entity from the implementation layer.
*/
data class OrderSummaryDTO(
val orderId: String,
val totalAmount: Long, // Using Long (cents) to avoid floating-point precision issues
val status: OrderStatus,
)
/** Order status enum, part of the public contract */
enum class OrderStatus {
PENDING, PAID, SHIPPED, COMPLETED, CANCELLED
}
The implementation module (:feature:order), in turn, depends on and implements this interface:
// Implementation inside the :feature:order module
// Note: This class is marked 'internal'; external modules cannot access it directly.
internal class OrderServiceImpl @Inject constructor(
private val orderRepository: OrderRepository,
) : IOrderService {
override suspend fun hasPendingOrder(userId: String): Boolean {
return orderRepository.getOrdersByUser(userId)
.any { it.status == OrderStatus.PENDING }
}
override suspend fun getOrderSummary(orderId: String): OrderSummaryDTO {
val order = orderRepository.getOrder(orderId)
// Map internal OrderEntity to public OrderSummaryDTO
return OrderSummaryDTO(
orderId = order.id,
totalAmount = order.totalAmountInCents,
status = order.status,
)
}
}
The dependency flow:
:feature:login ───implementation───→ :feature:order-api ← Sees ONLY the interface
↑
:feature:order ───implementation───→ :feature:order-api ← Implements the interface
This design yields three critical benefits:
| Benefit | Description |
|---|---|
| Compilation Isolation | Regardless of how code inside :feature:order changes, as long as order-api remains unchanged, modules depending on order-api won't recompile. |
| Swappable Implementation | Easily provide different implementations for the same interface (e.g., Mock implementations for testing). |
| Prevent API Leakage | Implementation classes are marked internal; only interfaces and DTOs are visible externally. |
Tier 4: Core Components Layer
Core components provide domain-agnostic generic capabilities: network wrappers, database wrappers, image loaders, design systems, etc. They sit at the lowest level of the architecture and are depended upon by all higher modules.
:core:network/ # Retrofit/OkHttp wrapper, Interceptors, Token refresh logic
:core:database/ # Room wrapper, generic DAO base classes
:core:common/ # Pure Kotlin utils (date formatting, math), no Android dependencies
:core:designsystem/ # Compose themes, colors, typography, generic UI widgets
:core:testing/ # Test utilities, Fake implementations, custom Test Rules
There is an ironclad rule for Core components: Core components must NEVER depend upstream on Feature components. Dependencies must strictly flow downwards.
✓ :feature:login → :core:network (Upper depends on lower)
✗ :core:network → :feature:login (Lower depends on upper — FORBIDDEN)
Google's Now in Android open-source project is the pinnacle example of this 4-tier architecture. It explicitly delineates :app, :feature:*, and :core:* tiers (though Nia's feature modules are relatively simple and don't strictly separate -api layers, the core layering philosophy remains identical).
The Underlying Mechanism of Gradle Module Types
Executing componentization within the Gradle build system hinges on the correct application of two plugins: com.android.application and com.android.library. Understanding their fundamental differences is key to deciphering the seemingly "weird" configurations in componentized projects.
Application vs. Library: Artifacts and Build Pipeline Differences
com.android.application com.android.library
│ │
▼ ▼
Compile .kt/.java → .class Compile .kt/.java → .class
│ │
▼ ▼
R8/ProGuard Obfuscation No Obfuscation
│ │
▼ ▼
DEX Conversion → .dex Packaged as .aar
│ (classes.jar + res/
▼ + AndroidManifest.xml
APK Packaging & Signing + R.txt + proguard.txt)
│
▼
Final Artifact: .apk Final Artifact: .aar
Key Differences:
| Dimension | application |
library |
|---|---|---|
| Artifact Format | APK (Installable and executable) | AAR (For consumption by other modules) |
| applicationId | Mandatory | Forbidden |
| DEX Conversion | Executed | Not executed (deferred to the consuming app module) |
| Resource R Class | Fields in R.java are static final (Constants) |
Fields in R.java are static (Variables) |
| ProGuard/R8 | Executed during this phase | Only provides rule files; executed by the app module |
| Signing | Executed | Not executed |
There is a subtle detail here that trips up many: Library module R class fields are NOT compile-time constants. This means in a Library module, you cannot use R.id.xxx as a case value in a when expression (or Java switch), because when strictly requires compile-time constants.
// In a Library module, this code will fail to compile!
when (view.id) {
R.id.btn_login -> { /* ... */ } // Error: R.id.btn_login is not a constant
R.id.btn_register -> { /* ... */ }
}
// Correct approach: Use if-else
if (view.id == R.id.btn_login) { /* ... */ }
Why aren't Library R fields constants? Because a Library might be consumed by multiple Applications. During the final packaging phase, AAPT2 reassigns IDs for all resources to avoid collisions. The Library module cannot possibly know the final resource ID values at its own compile time. Only the Application module can determine the final ID distribution, making its R fields final.
Dynamic Application/Library Switching: Independent Debugging
One of the crowning engineering feats of componentization is allowing every business component to be compiled into an APK for independent execution and debugging, bypassing the need to compile the massive monolithic project. This is achieved through dynamic Gradle plugin switching.
Step 1: Define a Global Switch
In the root gradle.properties:
# true = Component Mode (Standalone App), false = Integration Mode (Library)
isModule=false
Step 2: Dynamically Apply Plugins in the Module's build.gradle.kts
// :feature:login/build.gradle.kts
// Read the global switch
val isModule: Boolean = project.properties["isModule"]?.toString()?.toBoolean() ?: false
plugins {
// We conditionally apply plugins based on the flag
// Note: Gradle Kotlin DSL doesn't support conditional logic inside the plugins {} block.
// In practice, use the apply(plugin = ...) method outside.
}
if (isModule) {
apply(plugin = "com.android.application")
} else {
apply(plugin = "com.android.library")
}
android {
namespace = "com.example.feature.login"
defaultConfig {
// Application ID is required to install as a standalone app
if (isModule) {
applicationId = "com.example.feature.login.debug"
}
}
// CRUCIAL: Switch AndroidManifest based on mode
sourceSets {
getByName("main") {
if (isModule) {
// Component Mode: Full manifest with LAUNCHER intent-filter
manifest.srcFile("src/main/debug/AndroidManifest.xml")
} else {
// Integration Mode: Stripped-down manifest
manifest.srcFile("src/main/AndroidManifest.xml")
}
}
}
}
Step 3: Prepare Two Sets of AndroidManifest
<!-- src/main/AndroidManifest.xml (Integration Mode — Stripped) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".ui.LoginActivity"
android:exported="false" />
</application>
</manifest>
<!-- src/main/debug/AndroidManifest.xml (Component Mode — Full) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".debug.LoginDebugApplication"
android:theme="@style/Theme.Login">
<activity android:name=".ui.LoginActivity"
android:exported="true">
<!-- Launch entry required for standalone execution -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Under the Hood: When isModule=true, Gradle applies the com.android.application plugin. This drastically shifts the build task graph, injecting DEX conversion, APK packaging, and signing tasks. The module instantly transforms into an installable application. The developer can compile just this single module and deploy it to a device, obliterating the agonizing compile-debug loop time.
Inter-Module Communication: Three Weapons of Decoupling
If business components cannot directly depend on one another, how do they communicate? Industry practice utilizes three primary paradigms, each tailored for different scenarios.
Approach 1: Routing Frameworks (e.g., ARouter)
Routing frameworks are the industry standard for Page Navigation across components. The core ideology is replacing Java/Kotlin class references with String Paths, achieving indirect addressing via a "Routing Table".
// Without Routing (Tight Coupling — Requires importing the target class)
val intent = Intent(this, OrderDetailActivity::class.java)
intent.putExtra("orderId", "12345")
startActivity(intent)
// With ARouter (Decoupled — Only needs to know the path string)
ARouter.getInstance()
.build("/order/detail") // Path addressing, no imports needed
.withString("orderId", "12345")
.navigation()
ARouter operates in two distinct phases:
- Compile-Time: An APT (Annotation Processing Tool) scans all
@Routeannotations, generating a routing map for each module (essentially aMap<String, Class<?>>). - Run-Time: During app launch, it loads the maps. Upon a routing request, it looks up the target
Classobject via the string path and fires a standardIntent.
For a deep dive into how ARouter leverages APT to inject routing maps and dispatches intents at runtime, refer to the upcoming article Under the Hood of the ARouter Framework.
Approach 2: Interface Sinking + Dependency Injection (Hilt / Dagger)
For non-navigational communication (e.g., invoking a service method from another module), the recommended pattern is "Interface Sinking + DI".
This is the exact practical application of the Module-API layer mentioned earlier:
// 1. Define the interface in :feature:order-api (Sunk to the public layer)
interface IOrderService {
suspend fun hasPendingOrder(userId: String): Boolean
}
// 2. Provide the implementation inside :feature:order
internal class OrderServiceImpl @Inject constructor(
private val orderRepo: OrderRepository,
) : IOrderService {
override suspend fun hasPendingOrder(userId: String): Boolean =
orderRepo.countPendingOrders(userId) > 0
}
// 3. Bind interface to implementation in :feature:order's Hilt Module
@Module
@InstallIn(SingletonComponent::class)
abstract class OrderModule {
@Binds
abstract fun bindOrderService(impl: OrderServiceImpl): IOrderService
}
// 4. Inject it into :feature:login — No need to know the implementation class!
@HiltViewModel
class LoginViewModel @Inject constructor(
private val orderService: IOrderService, // Hilt automatically injects OrderServiceImpl
) : ViewModel() {
fun checkPendingOrders(userId: String) {
viewModelScope.launch {
val hasPending = orderService.hasPendingOrder(userId)
// ... Handle result
}
}
}
Hilt uses KSP (Kotlin Symbol Processing) to generate DI glue code at compile-time and instantiates objects via the Dagger component tree at runtime. Because everything is resolved at compile-time, the performance overhead is negligible compared to reflection-based approaches.
Approach 3: SPI Service Discovery (ServiceLoader)
For scenarios that forbid heavyweight DI frameworks, the JDK-native SPI (Service Provider Interface) mechanism is ideal:
// 1. Define the interface in a common module
interface IPaymentService {
fun getPaymentMethods(): List<String>
}
// 2. Implement the interface in :feature:payment
class PaymentServiceImpl : IPaymentService {
override fun getPaymentMethods(): List<String> =
listOf("Alipay", "WeChat Pay", "Credit Card")
}
// 3. Register it under resources/META-INF/services/ in :feature:payment
// Filename: com.example.api.IPaymentService
// File Content: com.example.payment.PaymentServiceImpl
// 4. Discover it dynamically from anywhere via ServiceLoader
val paymentService = ServiceLoader.load(IPaymentService::class.java)
.firstOrNull()
?: throw IllegalStateException("IPaymentService implementation not found")
Comparison of the three paradigms:
| Approach | Scenarios | Advantages | Disadvantages |
|---|---|---|---|
| ARouter | Cross-module page routing, parameter passing | Interceptors, fallback degradation | Third-party dependency, weak type safety |
| Hilt/Dagger DI | Service invocation, object lifecycle management | Compile-time safety checks, zero reflection | Steep learning curve |
| SPI ServiceLoader | Lightweight interface discovery | Zero external dependencies, JDK native | Runtime reflection overhead, no lifecycle management |
In industrial practice, these are usually mixed: ARouter manages page routing, while Hilt governs service injection and lifecycle, synergizing to cover 100% of inter-module communication scenarios.
Resource Isolation: Defensive Mechanisms Against Naming Collisions
When multiple modules possess their own resource files (layouts, strings, drawables), AAPT2 will merge them into a unified resource table during the final packaging phase. If two modules both define an R.string.title, the latter will silently overwrite the former. There are no compile-time errors—only baffling runtime behavior.
resourcePrefix: Compile-Time Namespace Constraints
Gradle provides the resourcePrefix configuration to force all resource names within a module to begin with a designated prefix:
// :feature:login/build.gradle.kts
android {
resourcePrefix = "login_" // Forces all resources to start with "login_"
}
If you define a drawable named bg_main inside :feature:login after this configuration, the IDE will throw a yellow Lint warning (Note: This is a Lint warning, not a hard compilation error):
Resource named 'bg_main' does not start with the prefix 'login_'
The correct name must be login_bg_main.
Manifest Merge Rules
When multiple modules define an AndroidManifest.xml, AGP merges them into a final manifest based on a strict priority hierarchy during the build process:
Build Variant Manifest (src/fullDebug/)
↓ overrides
Build Type Manifest (src/debug/)
↓ overrides
Product Flavor Manifest (src/full/)
↓ overrides
Main Module Manifest (src/main/AndroidManifest.xml)
↓ overrides
Library Dependency Manifests
When attribute collisions occur, utilize the tools namespace to explicitly dictate the merge behavior:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:theme="@style/AppTheme"
tools:replace="android:theme"> <!-- Forcibly use this module's theme -->
</application>
</manifest>
api vs. implementation: A Compiler-Level Understanding of Transitive Dependencies
Gradle's api and implementation keywords are the core weapons for enforcing module boundaries. Understanding their differences requires looking beyond "one passes transitively, the other doesn't." We must grasp the separation between the Compile Classpath and the Runtime Classpath in the Gradle build system.
Module A depends on Module B, which depends on Module C.
Scenario 1: B uses api(C)
┌──────────────────────────────────────┐
│ A's Compile Classpath: B + C │ ← A can "see" C's classes during compilation
│ A's Runtime Classpath: B + C │ ← A can use C's classes at runtime
└──────────────────────────────────────┘
Scenario 2: B uses implementation(C)
┌──────────────────────────────────────┐
│ A's Compile Classpath: B │ ← A CANNOT "see" C during compilation (Compilation Isolation)
│ A's Runtime Classpath: B + C │ ← A can still use C at runtime (Classes are in the APK)
└──────────────────────────────────────┘
Why does implementation accelerate compilation? Consider what happens when code inside Module C is modified:
- If B uses
api(C): Gradle assumes A might have referenced C's classes directly → A must be recompiled. - If B uses
implementation(C): Gradle definitively knows A cannot see C → A skips recompilation entirely (unless B's public APIs also changed).
This is exactly why the Golden Rule in componentized projects is: Default to implementation. Use api ONLY when your module directly exposes a type from the dependency in its own Public API.
// When MUST you use `api`?
// When your public method signatures (parameters or return types) use a type from the dependency.
// UserRepository's public method returns a `Flow` (from kotlinx-coroutines-core).
// Consumers of UserRepository MUST be able to see the `Flow` type definition at compile time.
class UserRepository {
fun getUsers(): Flow<List<User>> = flow { /* ... */ }
// ↑ Flow comes from kotlinx-coroutines-core
// → MUST use api("org.jetbrains.kotlinx:kotlinx-coroutines-core:...")
}
Convention Plugins: Build Consistency at Scale
When your project balloons to 30, 50, or even 100 modules, manually configuring compileSdk, minSdk, Kotlin compiler arguments, and Compose configurations for every module devolves into a maintenance nightmare.
Convention Plugins are the industrial-grade remedy. The philosophy is straightforward: Extract redundant build configurations into reusable custom Gradle plugins.
Project Root/
├── build-logic/ # Convention plugins module
│ ├── convention/
│ │ └── src/main/kotlin/
│ │ ├── AndroidLibraryConventionPlugin.kt
│ │ ├── AndroidApplicationConventionPlugin.kt
│ │ └── AndroidComposeConventionPlugin.kt
│ └── build.gradle.kts
├── gradle/
│ └── libs.versions.toml # Unified version catalog
├── feature/
│ ├── login/build.gradle.kts # Extremely minimalist config
│ └── order/build.gradle.kts
└── settings.gradle.kts
A typical implementation of an Android Library Convention Plugin:
// build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// Unify plugin application
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
// Unify Android build parameters
extensions.configure<LibraryExtension> {
compileSdk = 35
defaultConfig {
minSdk = 24
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
// Unify Kotlin compiler options
extensions.configure<KotlinAndroidProjectExtension> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
// Treat all warnings as errors — forcing code quality
allWarningsAsErrors.set(true)
}
}
}
}
}
With Convention Plugins, the build.gradle.kts file of a feature module becomes breathtakingly clean:
// :feature:login/build.gradle.kts
plugins {
id("example.android.library") // Apply the convention plugin
id("example.android.compose") // Conventions can be stacked
}
android {
namespace = "com.example.feature.login"
}
dependencies {
implementation(project(":core:designsystem"))
implementation(libs.hilt.android) // Leverages Version Catalog
ksp(libs.hilt.compiler)
}
Combined with the Version Catalog (libs.versions.toml), dependency versions are centralized across all modules, entirely eradicating the version hell of "Module A using Retrofit 2.9, while Module B uses Retrofit 2.7".
The Progressive Roadmap for Componentization Rollout
Componentization is not a one-shot overnight migration. It should act like "Urban Planning," progressively expanding alongside the project's growth:
Phase 1: Foundation Layering (Team ≤ 5)
├── :app
├── :core:network
├── :core:common
└── :core:designsystem
→ Goal: Extract generic capabilities, establish the base tier.
Phase 2: Business Splitting (Team 5~15)
├── :app
├── :feature:login
├── :feature:home
├── :feature:order
├── :core:*
└── Introduce ARouter / Navigation
→ Goal: Independence of business components, prohibition of cross-dependencies.
Phase 3: Contract Governance (Team 15+)
├── :app
├── :feature:login + :feature:login-api
├── :feature:order + :feature:order-api
├── :core:*
├── build-logic/ (Convention Plugins)
└── gradle/libs.versions.toml (Version Catalog)
→ Goal: Establish the Module-API anti-corruption layer, unify build configs.
Phase 4: Infrastructure Maturity (Team 30+)
├── CI Parallel Compilation for modules
├── Gradle Remote Cache
├── Automated Module Dependency Graph checks
└── Optional: Independent repositories for modules (Mono-repo → Multi-repo)
→ Goal: Extreme build performance optimization, aligning DX with small projects.
The transition into each phase should be driven by clear ROI objectives. If the current phase's compilation speed, collaboration efficiency, and code quality already meet the team's needs, there is no reason to prematurely rush into the next. The overhead of managing over-engineered componentization can easily eclipse the problems it attempts to solve.
Componentization Architecture is ultimately a Divide and Conquer strategy—decomposing the Big Ball of Mud into manageable, atomic units via physical isolation (Gradle module boundaries) and contract constraints (Module-API + DIP). Gradle's application/library mechanisms provide the compile-level guarantee of isolation, api/implementation dictate visibility control, and Convention Plugins alongside Version Catalogs ensure config consistency at a massive scale.
The subsequent two articles will deeply unpack two crucial topics: How ARouter utilizes APT to weave routing maps at compile-time and dispatch requests at runtime (Under the Hood of the ARouter Framework), and how to govern Gradle dependencies in multi-module projects—including transitive mechanisms, Version Catalogs, and eradicating circular dependencies (Componentization Gradle Dependency Governance).