This project uses the modules-graph-assert Gradle plugin to enforce and validate inter-module dependencies.
This project started as a single-module architecture and was later split into modules following clean architecture principles.
Based on this article and Google's modularization recommendations, the project uses a matrix-like structure where:
- Columns represent features (Home, Settings, Session, Statistics)
- Rows represent architectural layers (Presentation, Domain, Data)
The dependency graph is automatically updated by CI when changes are merged to master. A separate workflow validates dependencies on every PR to ensure module rules are enforced before merging. This ensures the graph always reflects the current, validated module structure from the mobile app's perspective.
Data Layer (:data)
- Extracted as a single module
- Manages Room database and DataStore
- Could be split further, but databases for users and sessions are joined to maintain referential integrity
Domain Layer (:domain:*)
- Feature-specific modules (
:domain:home,:domain:session,:domain:settings,:domain:statistics) - Shared common module (
:domain:common) - Pure business logic with no framework dependencies
Shared UI Layer (:shared-ui:*) - NEW: KMP Migration Path
- Feature-specific modules (
:shared-ui:home,:shared-ui:session,:shared-ui:settings,:shared-ui:statistics) - Contains ViewModels, Interactors, ViewStates, and business logic previously in platform UI
- KMP-ready: Only depends on domain layer and commonUtils (no Android-specific dependencies)
- Shared between mobile and TV platforms
Presentation Layer (:android:mobile:ui:*, :android:tv:ui:*)
- Platform-specific (Mobile/TV)
- Feature-specific UI modules under each platform
- Platform UI only: Compose components, screens, dialogs (Material3 for mobile, TV Material for TV)
- Depends on corresponding shared-ui feature module for business logic
Common Modules
:commonUtils- Foundation utilities, no dependencies:commonResources- Shared resources (strings, drawables, themes) - Android-specific:android:common- Platform common components
Why commonResources → domain:common?
The commonResources module depends on domain:common to enable type-safe resource management. This pattern:
-
Mapper classes connect domain to resources
- Domain enums/sealed classes represent pure business concepts (e.g.,
Exercise) commonResourcescontains mapper classes (e.g.,ExerciseDisplayNameMapper)- Mappers translate domain objects to resource IDs:
mapper.map(exercise) → R.string.exercise_name
- Domain enums/sealed classes represent pure business concepts (e.g.,
-
Benefits:
- Domain stays pure: No Android resource references in business logic
- Compile-time safety: Exhaustive
whenexpressions ensure all domain cases handled - Centralized mapping: All domain-to-resource logic in one place
- Type-safe: Can't request resources for non-existent domain objects
- Testable: Mappers are simple, pure functions easy to unit test
- Refactoring-friendly: IDE renames domain types → mapper updates automatically
-
No circular dependency:
- Resources depend on domain types (to map them)
- Domain layer never depends on resources (pure business logic)
- UI injects mappers and uses them:
getString(exerciseMapper.map(exercise))
Example:
// Domain (pure)
sealed class Exercise { object Lunges : Exercise() }
// commonResources (mapper)
class ExerciseDisplayNameMapper {
fun map(exercise: Exercise): Int = when (exercise) {
Exercise.Lunges -> R.string.exercise_lunges
}
}
// UI (uses mapper)
val name = getString(exerciseMapper.map(exercise))This creates a clean dependency chain: UI → commonResources (mappers) → domain:common (types) where mappers act as a type-safe bridge between pure domain concepts and their visual representation.
This project enforces strict clean architecture with automated validation.
┌──────────────────────────────┐
│ App Modules │ ← Top (can depend on everything)
│ (:android:mobile:app, etc) │
├──────────────────────────────┤
│ Platform UI Modules │ ← Platform-specific UI (Compose components)
│ (:android:mobile:ui:*, │ Depends on: shared-ui + ui:common
│ :android:tv:ui:*) │
├──────────────────────────────┤
│ Shared UI Layer (NEW) │ ← Shared business logic (ViewModels)
│ (:shared-ui:*) │ **KMP-ready**: domain + commonUtils only
│ │ No Android dependencies!
├──────────────────────────────┤
│ Domain Layer │ ← Pure business logic
│ (:domain:*) │ No framework dependencies
├──────────────────────────────┤
│ Data Layer │ ← Data sources (Room, DataStore)
│ (:data) │ Depends on: domain:common + commonUtils
├──────────────────────────────┤
│ Common Modules │ ← Foundation
│ (:commonUtils, etc) │ No dependencies
└──────────────────────────────┘
Rule of thumb: Dependencies flow top-to-bottom only. Lateral dependencies (within same layer) are restricted to common modules.
New Architecture Pattern (with shared-ui):
- UI Features → Only contain Compose UI (screens, components, dialogs)
- shared-ui Features → Contain all business logic (ViewModels, Interactors, ViewStates)
- Domain Features → Contain use cases and models
- Data → Implements domain interfaces
This separation enables: ✅ Sharing business logic between mobile and TV ✅ Preparing for KMP migration (shared-ui has no Android dependencies) ✅ Platform-specific UI while keeping logic unified
Dependency rules are defined in the moduleGraphAssert block in:
android/mobile/app/build.gradle.kts(mobile-specific rules)android/tv/app/build.gradle.kts(TV-specific rules)
Each configuration specifies:
allowed: Array of permitted dependency patterns (using regex)restricted: Array of explicitly forbidden dependenciesmaxHeight: Maximum tree height (currently 7 levels)
- Domain layer remains pure: cannot depend on data, UI, or app modules
- Data layer doesn't know about presentation: cannot depend on UI or app modules
- shared-ui layer is KMP-ready: cannot depend on Android-specific modules (
:android:.*,:commonResources) - UI features access domain only through shared-ui: cannot depend directly on domain modules
- No upward dependencies: no module can depend on app modules
- Foundation isolation:
commonUtilsandtestUtilscannot depend on anything - Lateral restriction: features can only share via
commonmodules - Explicit dependencies: UI feature modules use explicit allowed rules (no wildcards)
UI Feature Module (e.g., :android:mobile:ui:home):
dependencies {
implementation(projects.android.common)
implementation(projects.android.mobile.ui.common)
implementation(projects.sharedUi.home)
implementation(projects.commonUtils) // ← Used directly in UI code
implementation(projects.commonResources) // ← Used directly for R.string/R.drawable
implementation(projects.domain.common) // ← Used directly to render domain models
}Why UI needs direct dependencies:
commonUtils: UI code directly uses logging, extensions, and formatterscommonResources: UI code directly accesses strings and drawables viaR.string.*domain:common: Compose components directly render domain models (User,Exercise, etc.)
These aren't exposed transitively because shared-ui uses implementation (not api).
Shared-UI Feature Module (e.g., :shared-ui:home):
dependencies {
implementation(projects.commonUtils)
implementation(projects.domain.common)
implementation(projects.domain.home) // ← Business logic layer
}UI Common Module (e.g., :android:mobile:ui:common):
dependencies {
implementation(projects.android.common)
implementation(projects.domain.common) // ← For shared utilities
implementation(projects.commonUtils)
implementation(projects.commonResources)
}What's NOT allowed:
// ❌ UI feature CANNOT access domain features directly (only domain:common)
implementation(projects.domain.home) // Business logic via shared-ui only
// ❌ UI feature CANNOT access data
implementation(projects.data)
// ❌ shared-ui CANNOT depend on Android modules
implementation(projects.android.common)
implementation(projects.commonResources)The dependency tree is limited to a maximum height of 7 levels. Current longest path:
App → UI Feature → UI Common → Android Common → Common Resources → Domain Common → Common Utils
To modify dependency rules:
-
Edit the
moduleGraphAssertblock in the relevant build file:android/mobile/app/build.gradle.ktsandroid/tv/app/build.gradle.kts
-
Run checks locally:
./gradlew assertModuleGraph --no-configure-on-demand
-
Commit changes and create PR - CI will validate the new rules
Your dependency tree is too deep. Either:
- Refactor to reduce nesting (recommended)
- Increase
maxHeightvalue (only if justified)

