potential. Build Time Overhead reflects incremental compile impact vs. baseline monolith. Maintenance Cost captures refactoring, bug triage, and onboarding hours.*
Clean Architecture combined with Unidirectional Data Flow (UDF) demands higher initial scaffolding but pays compounding dividends in state predictability, test isolation, and long-term velocity. MVC and traditional MVVM degrade rapidly as feature complexity crosses 15-20 screens.
Core Solution
Building a production-grade mobile architecture requires enforcing boundaries, standardizing state flow, and aligning with platform lifecycles. The following implementation uses a layered, modular approach with unidirectional data flow. Examples are provided in Kotlin/Android, but the principles map directly to Swift/SwiftUI and Dart/Flutter.
Step 1: Enforce Strict Layer Boundaries
Divide the codebase into three logical layers. Each layer communicates only with its immediate neighbor through explicit contracts.
:data/ β Network, database, storage, platform services
:domain/ β Use cases, domain models, business rules
:presentation/ β State holders, UI models, navigation contracts
Rule: Never import :presentation into :domain or :data. Never reference platform UI classes in :domain.
Step 2: Implement Unidirectional Data Flow (UDF)
State flows in one direction: UI events β State reducer β New state β UI render. This eliminates bidirectional binding races and makes state predictable.
// Domain contract
data class UserProfile(
val id: String,
val displayName: String,
val subscriptionTier: Tier
)
// Presentation state contract
sealed class ProfileUiState {
object Idle : ProfileUiState()
object Loading : ProfileUiState()
data class Success(val profile: ProfileDomainModel) : ProfileUiState()
data class Error(val code: Int, val message: String) : ProfileUiState()
}
// Single event channel for side effects (navigation, toasts)
sealed class ProfileEffect {
object NavigateToSettings : ProfileEffect()
data class ShowToast(val message: String) : ProfileEffect()
}
Step 3: Wire Dependency Injection with Module Scoping
Use constructor injection. Avoid static accessors. Scope dependencies to their lifecycle.
// Hilt/Koin style pseudocode
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
abstract fun bindUserRepository(impl: UserRemoteRepository): UserRepository
}
// Feature scope
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val fetchProfile: FetchProfileUseCase,
private val dispatcher: DefaultDispatcher
) : ViewModel() {
private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Idle)
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
init { load() }
fun load() = viewModelScope.launch(dispatcher) {
_state.value = ProfileUiState.Loading
_state.value = try {
ProfileUiState.Success(fetchProfile())
} catch (e: Exception) {
ProfileUiState.Error(500, e.message.orEmpty())
}
}
}
Mobile platforms kill processes under memory pressure. State must survive configuration changes and process death.
- Use
ViewModel (Android) or @Observable/@StateObject (Swift) to retain state across configuration changes.
- Persist critical state to
DataStore/UserDefaults or restore via savedStateHandle.
- Never store large objects (bitmaps, raw JSON) in memory state. Cache at the repository layer.
Navigation is a side effect, not business logic. Emit navigation events through the effect channel.
// UI consumption (Compose example)
LaunchedEffect(Unit) {
viewModel.effect.collectLatest { effect ->
when (effect) {
is ProfileEffect.NavigateToSettings -> navController.navigate("settings")
is ProfileEffect.ShowToast -> snackbarHostState.showSnackbar(effect.message)
}
}
}
Architecture Decisions
| Decision | Recommendation | Rationale |
|---|
| State holder | ViewModel/StateObject | Survives configuration changes, integrates with DI |
| Data flow | Unidirectional (State + Effect) | Eliminates race conditions, simplifies testing |
| Caching | Repository pattern with TTL | Decouples network from UI, enables offline-first |
| DI | Constructor injection + compile-time graph | Predictable object graph, zero reflection overhead |
| Error handling | Domain-specific Result/Either types | Prevents silent failures, forces explicit handling |
Pitfall Guide
-
Treating Architecture as a One-Time Setup
Architecture requires continuous boundary enforcement. Without lint rules, module dependency graphs, and code review gates, layers will bleed. Enforce with dependency-guard or custom Gradle/Maven checks.
-
Over-Modularization Before Feature Stability
Splitting into 15 modules for a 5-screen app introduces build overhead without architectural benefit. Modularize at feature boundaries only after the feature set stabilizes.
-
Bypassing DI with Static Accessors
object AppContainer or static sharedInstance creates implicit singletons that break testability and hide dependency cycles. If you need a shortcut, you're missing a contract.
-
Ignoring Platform Lifecycle in State Management
Storing UI state in Activity/ViewController or using raw var properties causes data loss on rotation and memory pressure. Always anchor state to lifecycle-aware containers.
-
Mixing Navigation and Business Logic
ViewModels should not call NavController.navigate(). Navigation is a presentation side effect. Emit it through an effect channel or router contract.
-
Assuming Framework Features Replace Architectural Discipline
Compose/SwiftUI/Flutter handle rendering, not state architecture. Declarative UI reduces boilerplate but does not prevent state mutation races or layer violations.
-
Neglecting Memory Profiling in State Flows
Unbounded StateFlow/Combine/Stream buffers accumulate emissions. Use conflate(), drop(1), or platform-specific backpressure strategies. Profile with LeakCanary/Instruments weekly.
Production Bundle
Action Checklist
Decision Matrix
| App Complexity | Team Size | Recommended Pattern | Justification |
|---|
| <10 screens, MVP | 1-3 devs | MVVM + Single Module | Low overhead, fast iteration, acceptable debt ceiling |
| 10-30 screens, growing | 4-8 devs | Clean + UDF + Feature Modules | Predictable state, test isolation, scales with team |
| 30+ screens, enterprise | 8+ devs | Clean + UDF + Strict Modularization + DI Graph Validation | Enforces boundaries, enables parallel development, minimizes regression |
| Cross-platform (Flutter/RN) | Any | BLoC/Redux or Clean + UDF | Framework-agnostic state management, consistent testing strategy |
Configuration Template
Gradle Module Structure & Dependency Guard
// build.gradle.kts (root)
subprojects {
pluginManager.apply("com.android.library")
apply(plugin = "org.jetbrains.kotlin.android")
// Enforce layer boundaries
configurations.all {
resolutionStrategy {
failOnVersionConflict()
}
}
}
// :presentation/build.gradle.kts
dependencies {
implementation(project(":domain"))
// Forbidden: implementation(project(":data"))
implementation(libs.lifecycle.viewmodel)
implementation(libs.kotlinx.coroutines)
}
// :domain/build.gradle.kts
dependencies {
implementation(libs.kotlinx.coroutines)
// Platform-agnostic. No Android/iOS dependencies allowed.
}
State Contract Template (Kotlin)
sealed class FeatureState {
object Initial : FeatureState()
object Loading : FeatureState()
data class Content(val items: List<Item>) : FeatureState()
data class Failure(val throwable: Throwable) : FeatureState()
}
sealed class FeatureEffect {
object NavigateToDetail : FeatureEffect()
data class ShowError(val message: String) : FeatureEffect()
}
interface FeatureContract {
val state: StateFlow<FeatureState>
val effect: Channel<FeatureEffect, BufferOverflow.SUSPEND>
fun load()
fun onItemClicked(id: String)
}
Quick Start Guide
- Scaffold Layers: Create
:data, :domain, and :presentation modules. Add dependency guard rules to prevent upward imports.
- Define Contracts: Write sealed
State and Effect classes for your first feature. Map all UI interactions to explicit intents/actions.
- Implement Use Case: Build a domain use case that returns
Result<T> or Either<Error, Data>. Inject dependencies via constructor.
- Wire State Holder: Create a ViewModel/StateObject that collects use case results into
StateFlow. Emit side effects through a Channel.
- Connect UI: Subscribe to
state and effect in your UI layer. Render based on state, handle effects via router/snackbar. Validate with unit tests for state transitions.
Mobile app architecture is not about choosing a pattern. It is about enforcing boundaries, standardizing state flow, and aligning with platform constraints. The initial scaffolding cost is real, but the compounding returns in stability, testability, and team velocity make it the highest-leverage investment in mobile engineering. Ship features fast, but never at the expense of the control plane.