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.
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
- Separation of Concerns: Each layer has a single, well-defined responsibility
- Dependency Rule: Inner layers don't know about outer layers
- Unidirectional Data Flow: Data flows in one direction, reducing complexity
- 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
- Keep ViewModels Lean: ViewModels should orchestrate, not contain business logic
- Use Sealed Classes for UI State: Makes states exhaustive and type-safe
- Single Flow of Truth: One StateFlow per screen
- Proper Cancellation: Use viewModelScope for automatic cancellation
- 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