Skip to content

Latest commit

 

History

History
252 lines (195 loc) · 11.1 KB

File metadata and controls

252 lines (195 loc) · 11.1 KB

Module Dependency Enforcement

This project uses the modules-graph-assert Gradle plugin to enforce and validate inter-module dependencies.

Modularization Strategy

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)

Module Matrix Concept

Current Module Structure

Module Dependency Graph

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.

Layer Responsibilities

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

Special Design Pattern: Type-Safe Resources

Why commonResourcesdomain:common?

The commonResources module depends on domain:common to enable type-safe resource management. This pattern:

  1. Mapper classes connect domain to resources

    • Domain enums/sealed classes represent pure business concepts (e.g., Exercise)
    • commonResources contains mapper classes (e.g., ExerciseDisplayNameMapper)
    • Mappers translate domain objects to resource IDs: mapper.map(exercise) → R.string.exercise_name
  2. Benefits:

    • Domain stays pure: No Android resource references in business logic
    • Compile-time safety: Exhaustive when expressions 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
  3. 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.

Dependency Rules

This project enforces strict clean architecture with automated validation.

Architecture Layers

┌──────────────────────────────┐
│      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

Rule Configuration

Dependency rules are defined in the moduleGraphAssert block in:

Each configuration specifies:

  • allowed: Array of permitted dependency patterns (using regex)
  • restricted: Array of explicitly forbidden dependencies
  • maxHeight: Maximum tree height (currently 7 levels)

Key Principles

  • 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: commonUtils and testUtils cannot depend on anything
  • Lateral restriction: features can only share via common modules
  • Explicit dependencies: UI feature modules use explicit allowed rules (no wildcards)

Dependency Examples

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 formatters
  • commonResources: UI code directly accesses strings and drawables via R.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)

Maximum Tree Height

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

Modifying the Rules

To modify dependency rules:

  1. Edit the moduleGraphAssert block in the relevant build file:

    • android/mobile/app/build.gradle.kts
    • android/tv/app/build.gradle.kts
  2. Run checks locally:

    ./gradlew assertModuleGraph --no-configure-on-demand
  3. Commit changes and create PR - CI will validate the new rules

Troubleshooting

Build Fails with "Maximum height exceeded"

Your dependency tree is too deep. Either:

  1. Refactor to reduce nesting (recommended)
  2. Increase maxHeight value (only if justified)

References