learn()
{ start }
build++
const tutorial

Modern Android App Architecture - Part 1: Foundation & Layers

Deep dive into modern Android app architecture, covering layered architecture, MVVM, unidirectional data flow, and dependency injection with Hilt. Essential guide for building scalable Android applications.

Cuppa Team7 min read
androidkotlinarchitecturejetpack-composemvvmclean-architecture

This is Part 1 of our comprehensive Modern Android App Architecture series. We'll cover the foundational principles, layered architecture, and key components that form the basis of scalable Android applications.

Architectural Overview

Modern Android architecture follows a layered approach with clear separation of concerns:

┌────────────────────────────────┐
│      Presentation Layer        │  ← UI (Compose/Views) + ViewModels
├────────────────────────────────┤
│       Domain Layer             │  ← Use Cases + Domain Models
├────────────────────────────────┤
│        Data Layer              │  ← Repositories + Data Sources
└────────────────────────────────┘

Key Principles

  1. Separation of Concerns: Each layer has a single, well-defined responsibility
  2. Dependency Rule: Inner layers don't know about outer layers
  3. Unidirectional Data Flow: Data flows in one direction, reducing complexity
  4. Single Source of Truth: Each piece of data has a single source

Layer Breakdown

1. Presentation Layer

The UI layer observes and displays data from the domain layer.

ViewModel with StateFlow

@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val getUserProfileUseCase: GetUserProfileUseCase,
    private val updateProfileUseCase: UpdateProfileUseCase,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    // UI State as single source of truth
    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()

    init {
        loadProfile()
    }

    fun loadProfile() {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Loading

            getUserProfileUseCase()
                .onSuccess { profile ->
                    _uiState.value = ProfileUiState.Success(profile)
                }
                .onFailure { error ->
                    _uiState.value = ProfileUiState.Error(error.message ?: "Unknown error")
                }
        }
    }

    fun updateProfile(name: String, bio: String) {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Updating

            updateProfileUseCase(name, bio)
                .onSuccess { profile ->
                    _uiState.value = ProfileUiState.Success(profile)
                }
                .onFailure { error ->
                    _uiState.value = ProfileUiState.Error(error.message ?: "Update failed")
                }
        }
    }
}

// UI State sealed class
sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data object Updating : ProfileUiState
    data class Success(val profile: UserProfile) : ProfileUiState
    data class Error(val message: String) : ProfileUiState
}

Jetpack Compose UI

@Composable
fun ProfileScreen(
    viewModel: ProfileViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    ProfileContent(
        uiState = uiState,
        onUpdateProfile = viewModel::updateProfile,
        onRetry = viewModel::loadProfile
    )
}

@Composable
private fun ProfileContent(
    uiState: ProfileUiState,
    onUpdateProfile: (String, String) -> Unit,
    onRetry: () -> Unit
) {
    when (uiState) {
        is ProfileUiState.Loading -> {
            LoadingIndicator()
        }
        is ProfileUiState.Success -> {
            ProfileForm(
                profile = uiState.profile,
                onSave = onUpdateProfile
            )
        }
        is ProfileUiState.Error -> {
            ErrorView(
                message = uiState.message,
                onRetry = onRetry
            )
        }
        is ProfileUiState.Updating -> {
            ProfileForm(
                profile = null,
                enabled = false,
                onSave = onUpdateProfile
            )
        }
    }
}

2. Domain Layer

The domain layer contains business logic and is independent of Android framework.

Use Cases

class GetUserProfileUseCase @Inject constructor(
    private val userRepository: UserRepository
) {
    suspend operator fun invoke(): Result<UserProfile> {
        return try {
            val profile = userRepository.getUserProfile()
            Result.success(profile)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

class UpdateProfileUseCase @Inject constructor(
    private val userRepository: UserRepository,
    private val validator: ProfileValidator
) {
    suspend operator fun invoke(name: String, bio: String): Result<UserProfile> {
        // Business logic validation
        if (!validator.isValidName(name)) {
            return Result.failure(ValidationException("Invalid name"))
        }

        if (!validator.isValidBio(bio)) {
            return Result.failure(ValidationException("Bio too long"))
        }

        return try {
            val profile = userRepository.updateProfile(name, bio)
            Result.success(profile)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

Domain Models

data class UserProfile(
    val id: String,
    val name: String,
    val email: String,
    val bio: String,
    val avatarUrl: String?,
    val createdAt: Instant,
    val updatedAt: Instant
) {
    companion object {
        fun empty() = UserProfile(
            id = "",
            name = "",
            email = "",
            bio = "",
            avatarUrl = null,
            createdAt = Instant.now(),
            updatedAt = Instant.now()
        )
    }
}

3. Data Layer

The data layer manages data from various sources and exposes it to the domain layer.

Repository Pattern

interface UserRepository {
    suspend fun getUserProfile(): UserProfile
    suspend fun updateProfile(name: String, bio: String): UserProfile
    fun observeUserProfile(): Flow<UserProfile>
}

class UserRepositoryImpl @Inject constructor(
    private val remoteDataSource: UserRemoteDataSource,
    private val localDataSource: UserLocalDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : UserRepository {

    override suspend fun getUserProfile(): UserProfile = withContext(ioDispatcher) {
        try {
            // Try to fetch from remote
            val remote = remoteDataSource.fetchProfile()

            // Cache locally
            localDataSource.saveProfile(remote)

            remote.toDomainModel()
        } catch (e: Exception) {
            // Fallback to cached data
            localDataSource.getProfile()?.toDomainModel()
                ?: throw NoDataException("No profile data available")
        }
    }

    override suspend fun updateProfile(name: String, bio: String): UserProfile = withContext(ioDispatcher) {
        val updated = remoteDataSource.updateProfile(name, bio)
        localDataSource.saveProfile(updated)
        updated.toDomainModel()
    }

    override fun observeUserProfile(): Flow<UserProfile> {
        return localDataSource.observeProfile()
            .map { it.toDomainModel() }
            .flowOn(ioDispatcher)
    }
}

Data Sources

// Remote Data Source
interface UserRemoteDataSource {
    suspend fun fetchProfile(): UserDto
    suspend fun updateProfile(name: String, bio: String): UserDto
}

class UserRemoteDataSourceImpl @Inject constructor(
    private val apiService: ApiService
) : UserRemoteDataSource {

    override suspend fun fetchProfile(): UserDto {
        return apiService.getProfile()
    }

    override suspend fun updateProfile(name: String, bio: String): UserDto {
        return apiService.updateProfile(
            UpdateProfileRequest(name = name, bio = bio)
        )
    }
}

// Local Data Source (Room)
interface UserLocalDataSource {
    suspend fun getProfile(): UserEntity?
    suspend fun saveProfile(profile: UserDto)
    fun observeProfile(): Flow<UserEntity>
}

class UserLocalDataSourceImpl @Inject constructor(
    private val userDao: UserDao
) : UserLocalDataSource {

    override suspend fun getProfile(): UserEntity? {
        return userDao.getProfile()
    }

    override suspend fun saveProfile(profile: UserDto) {
        userDao.insertProfile(profile.toEntity())
    }

    override fun observeProfile(): Flow<UserEntity> {
        return userDao.observeProfile()
    }
}

Dependency Injection with Hilt

Application Setup

@HiltAndroidApp
class CuppaApplication : Application()

Module Configuration

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(AuthInterceptor())
            .addInterceptor(LoggingInterceptor())
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BuildConfig.API_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "cuppa-database"
        )
            .addMigrations(MIGRATION_1_2)
            .build()
    }

    @Provides
    fun provideUserDao(database: AppDatabase): UserDao {
        return database.userDao()
    }
}

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository
}

@Module
@InstallIn(ViewModelComponent::class)
object DispatcherModule {

    @Provides
    fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
}

Error Handling

Result Wrapper

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val exception: Throwable) : Result<Nothing>()
    data object Loading : Result<Nothing>()

    inline fun onSuccess(action: (T) -> Unit): Result<T> {
        if (this is Success) action(data)
        return this
    }

    inline fun onError(action: (Throwable) -> Unit): Result<T> {
        if (this is Error) action(exception)
        return this
    }
}

// Extension function for Flow
fun <T> Flow<T>.asResult(): Flow<Result<T>> {
    return this
        .map<T, Result<T>> { Result.Success(it) }
        .onStart { emit(Result.Loading) }
        .catch { emit(Result.Error(it)) }
}

Custom Exceptions

sealed class AppException(message: String) : Exception(message) {
    class NetworkException(message: String) : AppException(message)
    class ValidationException(message: String) : AppException(message)
    class AuthException(message: String) : AppException(message)
    class NoDataException(message: String) : AppException(message)
}

Testing Strategy

ViewModel Testing

@ExperimentalCoroutinesTest
class ProfileViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private lateinit var viewModel: ProfileViewModel
    private lateinit var getUserProfileUseCase: GetUserProfileUseCase
    private lateinit var updateProfileUseCase: UpdateProfileUseCase

    @Before
    fun setup() {
        getUserProfileUseCase = mockk()
        updateProfileUseCase = mockk()
        viewModel = ProfileViewModel(getUserProfileUseCase, updateProfileUseCase, mockk())
    }

    @Test
    fun `when loadProfile succeeds, uiState is Success`() = runTest {
        // Given
        val profile = UserProfile.empty()
        coEvery { getUserProfileUseCase() } returns Result.success(profile)

        // When
        viewModel.loadProfile()
        advanceUntilIdle()

        // Then
        val state = viewModel.uiState.value
        assertTrue(state is ProfileUiState.Success)
        assertEquals(profile, (state as ProfileUiState.Success).profile)
    }
}

Best Practices

  1. Keep ViewModels Lean: ViewModels should orchestrate, not contain business logic
  2. Use Sealed Classes for UI State: Makes states exhaustive and type-safe
  3. Single Flow of Truth: One StateFlow per screen
  4. Proper Cancellation: Use viewModelScope for automatic cancellation
  5. Layer Communication: Always go through defined interfaces

Next Steps

In Part 2, we'll dive deeper into:

  • Reactive data streams with Flow and StateFlow
  • Advanced state management patterns
  • Offline-first architecture with Room
  • Synchronization strategies

Resources

Continue to Part 2: Reactive Patterns & State Management